A sliding TTL cache expires each entry a fixed time after its last access, not after it was created — so a key that keeps getting read stays alive, while a key nobody touches for ttl milliseconds expires on its own. It is the caching rule behind idle session timeouts: stay signed in while you are active, time out once you walk away.
Implement slidingTtlCache(ttl), which returns an object with get, set, and has methods plus a size count. Every set and every live get restarts that entry's countdown; an entry that goes ttl milliseconds without any access expires.
function slidingTtlCache(ttl: number): {
get(key: unknown): unknown; // live -> slide deadline + return value; else undefined
set(key: unknown, value: unknown): void; // store + (re)start the countdown
has(key: unknown): boolean; // live? read-only peek, never slides
size: number; // count of live (non-expired) entries
};
const cache = slidingTtlCache(1000);
cache.set('token', 'abc');
// 900ms later — still inside the window:
cache.get('token'); // 'abc' (deadline slides to ~1900ms from set)
// another 900ms later (1800ms from set) — still alive, thanks to that read:
cache.get('token'); // 'abc'
const cache = slidingTtlCache(1000);
cache.set('draft', { title: 'Hi' });
cache.has('draft'); // true — a peek does not extend the deadline
// 1000ms of no access later:
cache.get('draft'); // undefined — expired and evicted
cache.size; // 0
get resets the entry's expiry to ttl ms from now, and set restarts it whenever it writes a key.has is a read-only peek — it reports whether a live entry exists but must not slide the deadline.expiresAt timestamp and treat an entry as gone once Date.now() reaches it, or use a setTimeout you restart on access; either is fine as long as access slides it.0, '', null, and false must store and read back, so decide liveness from the deadline, not the value's truthiness.Map is enough here.You are building a Map that forgets an entry once it has gone untouched for ttl milliseconds — and every read or write resets that countdown.
Think of a login session. While you keep clicking, you stay signed in; once you walk away and stop interacting, the session should time out. A sliding TTL cache is that rule for stored data: each entry carries a countdown, every access resets it to ttl, and an entry nobody touches for ttl milliseconds expires on its own. Contrast a fixed TTL, where the countdown starts at write time and never moves — there a hot key you read constantly still dies on schedule.
Give every entry a deadline: the wall-clock time at which it dies. set writes the deadline as Date.now() + ttl. A live get slides it forward to a fresh Date.now() + ttl; an idle entry keeps its old deadline and eventually falls behind the clock. Deciding whether an entry is alive is then just comparing its deadline against Date.now() — no timers required.
The obvious version stamps a deadline on set and honours it on get:
function slidingTtlCache(ttl) {
const store = new Map(); // key -> { value, expiresAt }
return {
set(key, value) {
store.set(key, { value, expiresAt: Date.now() + ttl });
},
get(key) {
const entry = store.get(key);
if (!entry || entry.expiresAt <= Date.now()) return undefined;
return entry.value; // reads the value but never moves the deadline
},
has(key) {
const entry = store.get(key);
return !!entry && entry.expiresAt > Date.now();
},
get size() {
let n = 0;
for (const entry of store.values()) {
if (entry.expiresAt > Date.now()) n++;
}
return n;
},
};
}
This is a fixed TTL, not a sliding one. set stamps a deadline and get respects it, but get never moves it. So a key you read every few milliseconds still dies exactly ttl after it was written — the reads that were supposed to keep it warm do nothing. It stores and reads correctly and it expires an idle key, but the moment a caller reads a key mid-life expecting that read to buy more time, it breaks.
The whole feature is one extra line: on a successful get, re-stamp the deadline.
function slidingTtlCache(ttl) {
const store = new Map(); // key -> { value, expiresAt }
// An entry is live while its deadline is still in the future.
const live = (entry) => entry !== undefined && entry.expiresAt > Date.now();
return {
set(key, value) {
// Writing (or overwriting) starts the countdown fresh.
store.set(key, { value, expiresAt: Date.now() + ttl });
},
get(key) {
const entry = store.get(key);
if (!live(entry)) {
store.delete(key); // drop it if it was there but expired
return undefined;
}
entry.expiresAt = Date.now() + ttl; // the slide: extend life from now
return entry.value;
},
has(key) {
const entry = store.get(key);
if (!live(entry)) {
store.delete(key);
return false;
}
return true; // a peek: report liveness without sliding
},
get size() {
let count = 0;
for (const [key, entry] of store) {
if (entry.expiresAt > Date.now()) count++;
else store.delete(key); // purge expired entries as we pass them
}
return count;
},
};
}
module.exports = { slidingTtlCache };
Two lines carry the idea. set stamps expiresAt = Date.now() + ttl, and the new line in get — entry.expiresAt = Date.now() + ttl — re-stamps it on every successful read. That single assignment is the entire difference between fixed and sliding. has deliberately omits it, so a peek can report a live entry without keeping a dying one alive. Expiry stays lazy: nothing runs on a timer, an entry is simply ignored (and deleted) the first time an operation notices its deadline has passed.
Take slidingTtlCache(50) and follow the key 'a' on the wall clock:
set('a', 1) stores { value: 1, expiresAt: 50 }.get('a') finds the entry live (50 > 30), returns 1, and re-stamps expiresAt to 30 + 50 = 80. The deadline just slid 30ms into the future.get('a') again. The original deadline of 50 is long past, but the read at t = 30 pushed it to 80, and 80 > 65, so the entry is still live: it returns 1 and slides the deadline once more, now to 115.50. At t = 65, 50 > 65 is false, so get finds the entry expired, deletes it, and returns undefined.The read at t = 30 is what saves the entry: without it the key dies at 50; with it the key lives to at least 80.
get returns the value but forgets entry.expiresAt = Date.now() + ttl, you have a fixed TTL: a constantly-read key still expires on its original schedule. The re-stamp is the whole point.has that slides — making has refresh the deadline turns a read-only peek into an access, so merely checking whether a key exists would keep it alive forever. Keep has free of the re-stamp.if (store.get(key)) treats a stored 0, '', false, or null as a miss. Decide liveness from the deadline and the entry's presence, not the value's truthiness.get, has, and size only skip expired entries without deleting them, the Map grows forever with dead keys. Delete on the first access that notices expiry.setTimeout(ttl) that deletes it, clearing and restarting the timer on every access. It frees memory the instant a key expires, at the cost of managing timer handles.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
A sliding TTL cache expires each entry a fixed time after its last access, not after it was created — so a key that keeps getting read stays alive, while a key nobody touches for ttl milliseconds expires on its own. It is the caching rule behind idle session timeouts: stay signed in while you are active, time out once you walk away.
Implement slidingTtlCache(ttl), which returns an object with get, set, and has methods plus a size count. Every set and every live get restarts that entry's countdown; an entry that goes ttl milliseconds without any access expires.
function slidingTtlCache(ttl: number): {
get(key: unknown): unknown; // live -> slide deadline + return value; else undefined
set(key: unknown, value: unknown): void; // store + (re)start the countdown
has(key: unknown): boolean; // live? read-only peek, never slides
size: number; // count of live (non-expired) entries
};
const cache = slidingTtlCache(1000);
cache.set('token', 'abc');
// 900ms later — still inside the window:
cache.get('token'); // 'abc' (deadline slides to ~1900ms from set)
// another 900ms later (1800ms from set) — still alive, thanks to that read:
cache.get('token'); // 'abc'
const cache = slidingTtlCache(1000);
cache.set('draft', { title: 'Hi' });
cache.has('draft'); // true — a peek does not extend the deadline
// 1000ms of no access later:
cache.get('draft'); // undefined — expired and evicted
cache.size; // 0
get resets the entry's expiry to ttl ms from now, and set restarts it whenever it writes a key.has is a read-only peek — it reports whether a live entry exists but must not slide the deadline.expiresAt timestamp and treat an entry as gone once Date.now() reaches it, or use a setTimeout you restart on access; either is fine as long as access slides it.0, '', null, and false must store and read back, so decide liveness from the deadline, not the value's truthiness.Map is enough here.You are building a Map that forgets an entry once it has gone untouched for ttl milliseconds — and every read or write resets that countdown.
Think of a login session. While you keep clicking, you stay signed in; once you walk away and stop interacting, the session should time out. A sliding TTL cache is that rule for stored data: each entry carries a countdown, every access resets it to ttl, and an entry nobody touches for ttl milliseconds expires on its own. Contrast a fixed TTL, where the countdown starts at write time and never moves — there a hot key you read constantly still dies on schedule.
Give every entry a deadline: the wall-clock time at which it dies. set writes the deadline as Date.now() + ttl. A live get slides it forward to a fresh Date.now() + ttl; an idle entry keeps its old deadline and eventually falls behind the clock. Deciding whether an entry is alive is then just comparing its deadline against Date.now() — no timers required.
The obvious version stamps a deadline on set and honours it on get:
function slidingTtlCache(ttl) {
const store = new Map(); // key -> { value, expiresAt }
return {
set(key, value) {
store.set(key, { value, expiresAt: Date.now() + ttl });
},
get(key) {
const entry = store.get(key);
if (!entry || entry.expiresAt <= Date.now()) return undefined;
return entry.value; // reads the value but never moves the deadline
},
has(key) {
const entry = store.get(key);
return !!entry && entry.expiresAt > Date.now();
},
get size() {
let n = 0;
for (const entry of store.values()) {
if (entry.expiresAt > Date.now()) n++;
}
return n;
},
};
}
This is a fixed TTL, not a sliding one. set stamps a deadline and get respects it, but get never moves it. So a key you read every few milliseconds still dies exactly ttl after it was written — the reads that were supposed to keep it warm do nothing. It stores and reads correctly and it expires an idle key, but the moment a caller reads a key mid-life expecting that read to buy more time, it breaks.
The whole feature is one extra line: on a successful get, re-stamp the deadline.
function slidingTtlCache(ttl) {
const store = new Map(); // key -> { value, expiresAt }
// An entry is live while its deadline is still in the future.
const live = (entry) => entry !== undefined && entry.expiresAt > Date.now();
return {
set(key, value) {
// Writing (or overwriting) starts the countdown fresh.
store.set(key, { value, expiresAt: Date.now() + ttl });
},
get(key) {
const entry = store.get(key);
if (!live(entry)) {
store.delete(key); // drop it if it was there but expired
return undefined;
}
entry.expiresAt = Date.now() + ttl; // the slide: extend life from now
return entry.value;
},
has(key) {
const entry = store.get(key);
if (!live(entry)) {
store.delete(key);
return false;
}
return true; // a peek: report liveness without sliding
},
get size() {
let count = 0;
for (const [key, entry] of store) {
if (entry.expiresAt > Date.now()) count++;
else store.delete(key); // purge expired entries as we pass them
}
return count;
},
};
}
module.exports = { slidingTtlCache };
Two lines carry the idea. set stamps expiresAt = Date.now() + ttl, and the new line in get — entry.expiresAt = Date.now() + ttl — re-stamps it on every successful read. That single assignment is the entire difference between fixed and sliding. has deliberately omits it, so a peek can report a live entry without keeping a dying one alive. Expiry stays lazy: nothing runs on a timer, an entry is simply ignored (and deleted) the first time an operation notices its deadline has passed.
Take slidingTtlCache(50) and follow the key 'a' on the wall clock:
set('a', 1) stores { value: 1, expiresAt: 50 }.get('a') finds the entry live (50 > 30), returns 1, and re-stamps expiresAt to 30 + 50 = 80. The deadline just slid 30ms into the future.get('a') again. The original deadline of 50 is long past, but the read at t = 30 pushed it to 80, and 80 > 65, so the entry is still live: it returns 1 and slides the deadline once more, now to 115.50. At t = 65, 50 > 65 is false, so get finds the entry expired, deletes it, and returns undefined.The read at t = 30 is what saves the entry: without it the key dies at 50; with it the key lives to at least 80.
get returns the value but forgets entry.expiresAt = Date.now() + ttl, you have a fixed TTL: a constantly-read key still expires on its original schedule. The re-stamp is the whole point.has that slides — making has refresh the deadline turns a read-only peek into an access, so merely checking whether a key exists would keep it alive forever. Keep has free of the re-stamp.if (store.get(key)) treats a stored 0, '', false, or null as a miss. Decide liveness from the deadline and the entry's presence, not the value's truthiness.get, has, and size only skip expired entries without deleting them, the Map grows forever with dead keys. Delete on the first access that notices expiry.setTimeout(ttl) that deletes it, clearing and restarting the timer on every access. It frees memory the instant a key expires, at the cost of managing timer handles.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.