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.
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 roadmap1Learn 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
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.
mkdir ttl-cache && cd ttl-cache
# save index.ts and check.ts in this folder
npx tsx check.tsHow 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 works | Unread entries | Cost | Pick it when | |
|---|---|---|---|---|
| Lazy expiry only | Check the time when an entry is read | Stay in memory forever | None in the background | You have few entries and they are all read |
| Active sweeper only | A background job removes expired entries | Cleaned up | Some CPU | Many entries and you cannot rely on reads |
| Both (as Redis does) | Remove on read and sample in the background | Cleaned up gradually | Balanced | General purpose caching |
| A timer per entry | A timer fires at each expiry | Cleaned up exactly | Heavy with many entries | A few entries that must expire on time |
| Timer wheel | Entries are grouped into time slots | Cleaned up on schedule | Cheap at large scale, more complex | Millions 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)