title: Cache With Time Limit
slug: cache-with-time-limit
difficulty: medium
leetcode_id: 2622
leetcode_url: https://leetcode.com/problems/cache-with-time-limit/
categories:
  - hash-tables
patterns:
  - heap

description: |
  Write a class that allows getting and setting key-value pairs, however a **time until expiration** is associated with each key.

  The class has three public methods:

  `set(key, value, duration)`: accepts an integer `key`, an integer `value`, and a `duration` in milliseconds. Once the `duration` has elapsed, the key should be inaccessible. The method should return `true` if the same un-expired key already exists and `false` otherwise. Both the value and duration should be overwritten if the key already exists.

  `get(key)`: if an un-expired key exists, it should return the associated value. Otherwise it should return `-1`.

  `count()`: returns the count of un-expired keys.

constraints: |
  - `0 <= key, value <= 10^9`
  - `0 <= duration <= 1000`
  - `1 <= actions.length <= 100`
  - `actions.length === values.length`
  - `actions.length === timeDelays.length`
  - `0 <= timeDelays[i] <= 1450`
  - `actions[i]` is one of `"TimeLimitedCache"`, `"set"`, `"get"` and `"count"`
  - First action is always `"TimeLimitedCache"` and must be executed immediately, with a 0-millisecond delay

examples:
  - input: |
      actions = ["TimeLimitedCache", "set", "get", "count", "get"]
      values = [[], [1, 42, 100], [1], [], [1]]
      timeDelays = [0, 0, 50, 50, 150]
    output: "[null, false, 42, 1, -1]"
    explanation: |
      At t=0, the cache is constructed.
      At t=0, a key-value pair (1: 42) is added with a time limit of 100ms. The value doesn't exist so false is returned.
      At t=50, key=1 is requested and the value of 42 is returned.
      At t=50, count() is called and there is one active key in the cache.
      At t=100, key=1 expires.
      At t=150, get(1) is called but -1 is returned because the cache is empty.
  - input: |
      actions = ["TimeLimitedCache", "set", "set", "get", "get", "get", "count"]
      values = [[], [1, 42, 50], [1, 50, 100], [1], [1], [1], []]
      timeDelays = [0, 0, 40, 50, 120, 200, 250]
    output: "[null, false, true, 50, 50, -1, 0]"
    explanation: |
      At t=0, the cache is constructed.
      At t=0, a key-value pair (1: 42) is added with a time limit of 50ms. The value doesn't exist so false is returned.
      At t=40, a key-value pair (1: 50) is added with a time limit of 100ms. A non-expired value already existed so true is returned and the old value was overwritten.
      At t=50, get(1) is called which returned 50.
      At t=120, get(1) is called which returned 50.
      At t=140, key=1 expires.
      At t=200, get(1) is called but the cache is empty so -1 is returned.
      At t=250, count() returns 0 because the cache is empty.

explanation:
  intuition: |
    Imagine a parking garage where each car gets a ticket with an expiration time. When you enter, you're given a ticket that's valid for a certain duration. If you try to use an expired ticket, it's worthless — the system won't recognise it anymore.

    The core insight for this problem is that we need to associate **two pieces of information** with each key: the value itself, and *when* it expires. Rather than storing the duration, we store the **absolute expiration timestamp** (current time + duration). This makes checking validity trivial — we just compare the current time against the stored expiration.

    Think of it like this: when you set a key, you're placing an item on a shelf with a "best before" date stamped on it. When someone asks for that item (`get`), you check if today's date is past the expiration — if so, the item is spoiled and you return `-1`. When counting items, you only count those that haven't expired yet.

    The elegant part is that we don't need to actively remove expired entries at the moment they expire. We can use **lazy deletion** — only check expiration when someone actually tries to access the key. This simplifies the implementation significantly.

  approach: |
    We solve this using a **Hash Map with Expiration Timestamps**:

    **Step 1: Design the data structure**

    - Use a `Map` (or object) to store key-value pairs
    - For each key, store both the `value` and the `expirationTime` (calculated as `Date.now() + duration`)
    - Optionally store the `timeoutId` so we can cancel old timers when overwriting

    &nbsp;

    **Step 2: Implement `set(key, value, duration)`**

    - Check if the key already exists AND hasn't expired
    - If an unexpired key exists, clear its old timeout and return `true`
    - Calculate the new expiration time: `Date.now() + duration`
    - Store the value and expiration time in the map
    - Set a `setTimeout` to automatically delete the key after `duration` milliseconds
    - Return `false` if the key didn't exist or was expired

    &nbsp;

    **Step 3: Implement `get(key)`**

    - Check if the key exists in the map
    - If it exists and `Date.now() < expirationTime`, return the value
    - Otherwise, return `-1`

    &nbsp;

    **Step 4: Implement `count()`**

    - Iterate through all entries in the map
    - Count only those where `Date.now() < expirationTime`
    - Return the count

    &nbsp;

    The `setTimeout` approach ensures keys are cleaned up eventually, while the expiration check provides correctness even if there are timing edge cases.

  common_pitfalls:
    - title: Storing Duration Instead of Expiration Time
      description: |
        A common mistake is storing the `duration` value directly and trying to compute expiration later.

        The problem is: later *when*? You'd need to also store the timestamp when the key was set. It's simpler and less error-prone to compute and store the absolute expiration time upfront: `expirationTime = Date.now() + duration`.

        This makes all subsequent checks trivial: `isExpired = Date.now() >= expirationTime`.
      wrong_approach: "Storing duration and set-time separately"
      correct_approach: "Store absolute expiration timestamp"

    - title: Not Cancelling Old Timers on Overwrite
      description: |
        When `set` is called with an existing key, the old timer is still running. If you don't cancel it with `clearTimeout`, the old timer will fire and delete your new entry prematurely!

        For example: `set(1, 42, 1000)` then `set(1, 99, 5000)`. If you don't cancel the first timer, it will delete key 1 after 1000ms, even though the new entry should last 5000ms.
      wrong_approach: "Ignoring the old timeout when overwriting"
      correct_approach: "Store timeoutId and call clearTimeout before overwriting"

    - title: Race Conditions with Lazy vs Eager Deletion
      description: |
        There are two approaches to handling expiration:
        - **Eager deletion**: Use `setTimeout` to delete exactly when the key expires
        - **Lazy deletion**: Check expiration only when the key is accessed

        Using *only* lazy deletion means expired keys still take up memory until accessed. Using *only* eager deletion can have edge cases with timer precision. The safest approach combines both: set a timeout for cleanup, but always verify expiration on access.
      wrong_approach: "Relying solely on one deletion strategy"
      correct_approach: "Combine setTimeout with expiration checks on access"

    - title: Forgetting That `set` Returns Based on Unexpired Status
      description: |
        The `set` method returns `true` only if an **unexpired** key already exists. If the key exists but has expired, you should return `false` (treating it as a new key).

        This is easy to miss if you simply check `map.has(key)` without also verifying the expiration status.
      wrong_approach: "Returning true if key exists in map (regardless of expiration)"
      correct_approach: "Return true only if key exists AND hasn't expired"

  key_takeaways:
    - "**Absolute timestamps over durations**: Store `expirationTime = now + duration` rather than storing duration separately — makes comparisons trivial"
    - "**Combine eager and lazy deletion**: Use `setTimeout` for cleanup but always verify expiration on access for correctness"
    - "**Cancel old timers**: When overwriting a key, always clear the previous timeout to prevent premature deletion"
    - "**This pattern is foundational**: Time-limited caches are used everywhere — session tokens, rate limiters, memoization with TTL, and API response caching"

  time_complexity: "O(1) for `set` and `get` operations using hash map lookup. O(n) for `count` where n is the number of entries in the cache (must check each for expiration)."
  space_complexity: "O(n) where n is the number of unexpired keys stored in the cache. Each entry stores the key, value, expiration time, and timeout reference."

solutions:
  - approach_name: Hash Map with setTimeout
    is_optimal: true
    language: typescript
    code: |
      class TimeLimitedCache {
          // Map stores: key -> { value, expirationTime, timeoutId }
          private cache: Map<number, {
              value: number;
              expirationTime: number;
              timeoutId: ReturnType<typeof setTimeout>;
          }>;

          constructor() {
              this.cache = new Map();
          }

          set(key: number, value: number, duration: number): boolean {
              // Check if unexpired key already exists
              const existing = this.cache.get(key);
              const keyExisted = existing !== undefined &&
                                 Date.now() < existing.expirationTime;

              // Cancel old timer if key exists (expired or not)
              if (existing) {
                  clearTimeout(existing.timeoutId);
              }

              // Calculate absolute expiration time
              const expirationTime = Date.now() + duration;

              // Set up automatic cleanup when key expires
              const timeoutId = setTimeout(() => {
                  this.cache.delete(key);
              }, duration);

              // Store the entry with all metadata
              this.cache.set(key, { value, expirationTime, timeoutId });

              return keyExisted;
          }

          get(key: number): number {
              const entry = this.cache.get(key);

              // Key doesn't exist
              if (!entry) return -1;

              // Key exists but has expired (lazy check for edge cases)
              if (Date.now() >= entry.expirationTime) return -1;

              return entry.value;
          }

          count(): number {
              const now = Date.now();
              let activeCount = 0;

              // Count only unexpired entries
              for (const entry of this.cache.values()) {
                  if (now < entry.expirationTime) {
                      activeCount++;
                  }
              }

              return activeCount;
          }
      }
    explanation: |
      **Time Complexity:** O(1) for `set` and `get`, O(n) for `count`.

      **Space Complexity:** O(n) for storing cache entries.

      This solution uses a Map to store entries with their expiration timestamps and timeout IDs. The `setTimeout` ensures eventual cleanup, while the expiration check on `get` provides correctness. Storing the `timeoutId` allows us to cancel old timers when overwriting keys.

  - approach_name: Simple Map with Lazy Deletion
    is_optimal: false
    language: typescript
    code: |
      class TimeLimitedCache {
          // Map stores: key -> { value, expirationTime }
          private cache: Map<number, { value: number; expirationTime: number }>;

          constructor() {
              this.cache = new Map();
          }

          set(key: number, value: number, duration: number): boolean {
              const now = Date.now();
              const existing = this.cache.get(key);

              // Check if an unexpired key exists
              const keyExisted = existing !== undefined &&
                                 now < existing.expirationTime;

              // Store with absolute expiration time
              this.cache.set(key, {
                  value,
                  expirationTime: now + duration
              });

              return keyExisted;
          }

          get(key: number): number {
              const entry = this.cache.get(key);

              // No entry or expired
              if (!entry || Date.now() >= entry.expirationTime) {
                  return -1;
              }

              return entry.value;
          }

          count(): number {
              const now = Date.now();
              let count = 0;

              for (const entry of this.cache.values()) {
                  if (now < entry.expirationTime) {
                      count++;
                  }
              }

              return count;
          }
      }
    explanation: |
      **Time Complexity:** O(1) for `set` and `get`, O(n) for `count`.

      **Space Complexity:** O(n) but expired entries persist until overwritten.

      This simpler approach uses only lazy deletion — expired entries remain in the map until accessed or overwritten. It's correct but uses more memory since expired entries aren't cleaned up automatically. Suitable for scenarios with frequent overwrites or short-lived caches.
