TTL cache with a reaper

On the roadmap:Beginner · step 7: Caching basics

Entries expire at an absolute time. Reads expire lazily, and a background sweep reclaims entries nobody reads again.

Starterabout 25 minTypeScript · no dependencies

In plain words

A cache where every entry has an expiry time. Expired items are removed when someone reads them, and a background sweeper cleans up the ones nobody asks for.

Why it matters

Cached data goes stale, so it needs a lifetime. Removing only on read leaves forgotten entries in memory forever; the sweeper is what keeps memory in check.

Where you meet it

  • Redis expiry (it uses both lazy and active removal)
  • DNS and CDN caches
  • Session and token stores

You will be able to

  • Store absolute expiry times
  • Combine lazy expiry with a periodic sweep
  • Avoid everything expiring at once

In interviews

Part of every 'design a cache' answer: how do you keep data fresh without a stampede?

Practise on the Interview Ready roadmap

1Learn the idea first

2See it work before you read the code

3Build it and run the check

What you build

  • Store an absolute expiry time per entry
  • Lazy expiry: delete an expired entry when it is read
  • An active sweep that checks a sample of entries
  • An injectable clock for tests
Press “Run the check” to see it work. The check runs in your browser.
export class TtlCache<V> {
  private map = new Map<string, { value: V; expiresAt: number }>();
  constructor(private now: () => number = Date.now) {}

  set(key: string, value: V, ttlMs: number): void {
    this.map.set(key, { value, expiresAt: this.now() + ttlMs });
  }

  get(key: string): V | undefined {
    const e = this.map.get(key);
    if (!e) return undefined;
    if (e.expiresAt <= this.now()) { this.map.delete(key); return undefined; } // lazy expiry
    return e.value;
  }

  get size(): number { return this.map.size; } // includes entries not yet reaped

  /** Active expiry: look at up to `sample` entries and drop the expired ones. Returns how many were removed. */
  sweep(sample = 20): number {
    let removed = 0, seen = 0;
    for (const [k, e] of this.map) {
      if (seen++ >= sample) break;
      if (e.expiresAt <= this.now()) { this.map.delete(k); removed++; }
    }
    return removed;
  }
}

Rather run it on your own computer?

Save the code as index.ts and the check as check.ts in one folder. The check exits with an error if anything is wrong and prints ttl-cache ok when everything passes. You need Node.js installed.

Commands
mkdir ttl-cache && cd ttl-cache
# save index.ts and check.ts in this folder
npx tsx check.ts

How real systems do it

This lab is the core idea in a small form. Here is what production versions add or do differently.

  • Redis expires keys two ways, exactly like this lab: lazily when read, and with a background sampler that removes some expired keys each cycle.
  • When many hot entries expire together, every request hits the database at once (a stampede). Real caches add random jitter to expiry times and let one request refresh while the rest wait or get stale data.
  • At very large scale, timers are stored in structures like timer wheels so finding what has expired stays cheap.

Ways to expire cache entries

How it worksUnread entriesCostPick it when
Lazy expiry onlyCheck the time when an entry is readStay in memory foreverNone in the backgroundYou have few entries and they are all read
Active sweeper onlyA background job removes expired entriesCleaned upSome CPUMany entries and you cannot rely on reads
Both (as Redis does)Remove on read and sample in the backgroundCleaned up graduallyBalancedGeneral purpose caching
A timer per entryA timer fires at each expiryCleaned up exactlyHeavy with many entriesA few entries that must expire on time
Timer wheelEntries are grouped into time slotsCleaned up on scheduleCheap at large scale, more complexMillions of timers

What it cannot do

  • Expired entries can stay in memory

    Time alone deletes nothing. They wait for a read or the sweeper.

  • The sweeper only samples

    It checks a few entries each time, so a large expired pile clears in steps.

  • Mass expiry causes a stampede

    If many entries expire together, every request misses at once and the database takes the hit.

  • There is no size limit

    TTL does not bound memory. Pair it with an eviction rule such as LRU.

4Now extend it

Each one makes your version closer to what real systems do. Pick one and make the check cover it.

  • Add a maximum size with LRU eviction
  • Add jitter to expiry times to avoid mass expiry
  • Refresh popular entries before they expire (stale-while-revalidate)
Saved to your account when you are signed in.

Check yourself

1 of 4. An entry's time to live passes. Is it deleted?