Find the bug · Medium

Fix the TTL Cache Expiry

Medium Find the bug Seen at Stripe, Cloudflare

TTLCache(ttlMs) stores integer values that expire ttlMs milliseconds after they were set. Time is passed in explicitly (nowMs) so the cache is testable.

  • set(key, value, nowMs) stores the value; it is valid while nowMs < setTime + ttlMs.
  • get(key, nowMs) returns the value, or -1 if the key is missing or expired.

An entry set at time 0 with a 1,000 ms TTL must be gone at exactly 1,000 ms. The cache below still returns it. Find the bug and fix it.

Example 1

Input: ["TTLCache","set","get","get"]
[[1000],[1,10,0],[1,999],[1,1000]]
Output: [null,null,10,-1]
Explanation: The buggy version returns 10 at 1000 ms.

The buggy code

class TTLCache {
  constructor(ttlMs) {
    this.ttlMs = ttlMs;
    this.entries = new Map(); // key -> { value, expiresAt }
  }

  set(key, value, nowMs) {
    this.entries.set(key, { value, expiresAt: nowMs + this.ttlMs });
  }

  get(key, nowMs) {
    const entry = this.entries.get(key);
    if (!entry || nowMs > entry.expiresAt) return -1;
    return entry.value;
  }
}

Hints

Hint 1

Write down the exact time interval during which the entry is valid. Is the end included?

Find and fix the bug

Loading the editor… (JavaScript is required to run code in your browser.)

Solution and explanation

Show the bug and the fix

Time O(1) per operation · Space O(n)

The bug: the entry is valid on the half-open interval [setTime, setTime + ttl), but the check treats it as expired only when now > expiresAt. At exactly expiresAt the stale value is still served. With a 1 ms TTL the entry lives twice as long as it should.

The fix: expire when now >= expiresAt (and optionally delete the entry). Off-by-one errors at time boundaries are a favourite interview bug because tests with "round" times rarely hit the boundary.

Fixed version

class TTLCache {
  constructor(ttlMs) {
    this.ttlMs = ttlMs;
    this.entries = new Map(); // key -> { value, expiresAt }
  }

  set(key, value, nowMs) {
    this.entries.set(key, { value, expiresAt: nowMs + this.ttlMs });
  }

  get(key, nowMs) {
    const entry = this.entries.get(key);
    if (!entry) return -1;
    if (nowMs >= entry.expiresAt) { // valid on [setTime, expiresAt)
      this.entries.delete(key);
      return -1;
    }
    return entry.value;
  }
}

Common mistakes

  • Not refreshing the expiry when an existing key is set again.
  • Reading the wall clock inside the class, which makes the boundary impossible to test.

Follow-up questions

  • Evict expired entries without scanning (min-heap or timing wheel).
  • Bound the size as well: combine with LRU eviction.