The browser's localStorage keeps key-value pairs around forever — there is no built-in way to say "remember this auth token, but only for an hour." You're going to build a thin wrapper that adds a time-to-live (TTL): a value you save with set(key, value, ttlMs) reads back normally until ttlMs milliseconds have passed, and null after that. This is the same caching-with-expiry idea behind HTTP Cache-Control: max-age, Redis EXPIRE, and session cookies.
The catch: a real localStorage only stores strings, and it has no concept of time. So the wrapper has to write the expiry deadline into storage alongside the value, then compare against the clock on every read.
// backingStore: a localStorage-shaped store — { getItem, setItem, removeItem }.
// Defaults to an in-memory adapter so this runs with no real localStorage.
// now: () => number — current time in ms. Defaults to () => Date.now().
function createStorageWithExpiry(
backingStore?,
now?,
): {
set(key: string, value: unknown, ttlMs: number): void; // persist value + deadline
get(key: string): unknown | null; // value if live, else null
remove(key: string): void; // delete a key outright
};
const store = createStorageWithExpiry();
store.set('token', 'abc123', 60_000); // good for 60 seconds
store.get('token'); // → 'abc123' (read immediately)
// ...61 seconds later...
store.get('token'); // → null (the TTL has elapsed)
// Values are not limited to strings — objects round-trip too.
store.set('user', { id: 7, name: 'Ada' }, 5_000);
store.get('user'); // → { id: 7, name: 'Ada' }
// Overwriting restarts the clock: this is good for a fresh 5_000ms.
store.set('user', { id: 7, name: 'Ada' }, 5_000);
localStorage and you must not depend on the wall clock for deterministic tests. createStorageWithExpiry(backingStore, now) lets a test pass a Map-backed stub and a clock it advances by hand. In a real browser you'd call createStorageWithExpiry(window.localStorage). See the solution's Notes for why this matters.expiresAt = now() + ttlMs, not the raw ttlMs. Storage has no clock of its own, so a read has to compare a stored absolute timestamp against the current time.get finds an expired entry, remove it from the backing store before returning null — don't let dead rows pile up.ttlMs is a positive number of milliseconds. Don't worry about negative or zero TTLs, NaN, or cross-tab synchronization — those are out of scope here.JSON.stringify / JSON.parse. Functions, undefined, and circular references are out of scope.You'll wrap a key-value store so that every value you save carries a deadline, and reads start returning null the moment that deadline passes.
You save an auth token to the browser and want it gone in an hour — but localStorage has no expiry. It holds whatever you put in it until someone clears it, and everything it holds is a string. So you can't just store the token; you have to store the token plus the time it should die, and check that time yourself on the way out. Think of it like a carton of milk: storage is the fridge, which keeps things cold forever and asks no questions. You're the one who writes the use-by date on the carton and throws it out when the date is past.
Two operations, two jobs. On set, you compute an absolute deadline — expiresAt = now() + ttlMs — and write { value, expiresAt } into storage as one JSON string. On get, you read that string back, parse it, and ask one question: has the deadline passed? If not, hand back the value. If it has, delete the entry and return null.
The crucial choice is absolute deadline, not remaining duration. Storage has no clock — it can't count down ttlMs for you. So you bake the current time into the stored value at write time, and every read compares that frozen deadline against a fresh now().
The obvious version stores the value directly, the way you'd use localStorage normally:
function createStorageWithExpiry(backingStore) {
return {
set(key, value, ttlMs) {
backingStore.setItem(key, JSON.stringify(value)); // ttlMs ignored!
},
get(key) {
const raw = backingStore.getItem(key);
return raw === null ? null : JSON.parse(raw);
},
};
}
This passes the "read it right back" test and nothing else. Look at set: it takes ttlMs and drops it on the floor. There is no deadline written anywhere, so get has nothing to check against — the value reads back the same at one second or one century. You can't expire what you didn't timestamp. The duration has to survive the write, and the only place it can survive is inside storage, next to the value.
A second, subtler attempt fixes that but trips on a different rock — it stores the duration and tries to age the entry without a clock:
set(key, value, ttlMs) {
backingStore.setItem(key, JSON.stringify({ value, ttlMs }));
},
get(key) {
const raw = backingStore.getItem(key);
if (raw === null) return null;
const { value, ttlMs } = JSON.parse(raw);
// ...how much time has passed? We have ttlMs, but no idea how long ago set ran.
return value;
}
Now the metadata is there, but get is stuck: it knows the value was good for ttlMs, but not when the clock started. Without the write-time timestamp, "remaining time" is unknowable. Storing expiresAt = now() + ttlMs instead of ttlMs resolves both problems at once — the deadline is self-contained, and any later read needs nothing but the current time.
function createStorageWithExpiry(
backingStore = createMemoryStore(),
now = () => Date.now(),
) {
function set(key, value, ttlMs) {
// Store the absolute deadline, not the duration. Storage has no clock, so a
// future read needs a timestamp it can compare against now() directly.
const record = { value, expiresAt: now() + ttlMs };
// localStorage only holds strings, so serialize the whole record.
backingStore.setItem(key, JSON.stringify(record));
}
function get(key) {
const raw = backingStore.getItem(key);
if (raw === null || raw === undefined) return null; // never written
const record = JSON.parse(raw);
if (now() >= record.expiresAt) {
// Expired. Purge the dead row so it doesn't linger, then report absence.
backingStore.removeItem(key);
return null;
}
return record.value;
}
function remove(key) {
backingStore.removeItem(key);
}
return { set, get, remove };
}
// Default backing store: a Map dressed up with the three localStorage methods
// the wrapper uses. Lets createStorageWithExpiry() run with no real localStorage
// (in Node, in tests, on a server) while a browser caller passes window.localStorage.
function createMemoryStore() {
const data = new Map();
return {
getItem: (k) => (data.has(k) ? data.get(k) : null),
setItem: (k, v) => data.set(k, String(v)),
removeItem: (k) => data.delete(k),
};
}
module.exports = { createStorageWithExpiry };
The shape that does all the work is { value, expiresAt }. set computes the deadline once and freezes it into storage; get reads it back and compares against a live now(). Three things deserve a second look — the comparison boundary, the purge-on-read, and the two parameters with defaults.
The comparison is now() >= record.expiresAt, inclusive. At the exact millisecond the deadline arrives, the entry is dead. Using > instead would keep it alive for one extra tick — usually harmless, but the inclusive form matches the intuition "good for 1000ms," i.e. good on [set, set + 1000).
get deletes before returning null on the expired branch. A read isn't supposed to mutate, but an expired entry is garbage — leaving it in storage means dead rows accumulate until something clears them. Purging on read is the cheapest possible cleanup: the read already had the record in hand. (This is sometimes called lazy expiration — you only pay to evict the things you actually touch.)
backingStore and now are parameters with defaults. This is the whole testability story. Both default to real-world implementations — an in-memory store and Date.now — so createStorageWithExpiry() with no arguments just works. But because they're parameters, a test (or a server with no localStorage) can swap in a Map-backed stub and a clock it advances by hand. Nothing inside the wrapper reaches for a global; the two things it can't control in a test are the two things you pass in.
Take the timeline from the mental-model section and run it with a clock we control. The clock starts at t = 1000; clock.advance(ms) is the only thing that moves time.
t = 1000 store.set('k', 'v', 1000)
expiresAt = now() + ttlMs = 1000 + 1000 = 2000
storage['k'] = '{"value":"v","expiresAt":2000}'
t = 1000 store.get('k')
raw is present → parse → now()=1000 >= 2000? no
→ return 'v' ✓ live
clock.advance(999) // t = 1999, one tick before the deadline
t = 1999 store.get('k')
now()=1999 >= 2000? no
→ return 'v' ✓ still live
clock.advance(1) // t = 2000, exactly at the deadline
t = 2000 store.get('k')
now()=2000 >= 2000? yes
→ removeItem('k'); storage['k'] is gone
→ return null ✗ expired + purged
t = 2000 store.get('k') // again
raw is null (we deleted it) → return null
The deadline 2000 was computed once, at write time, and never moved. Every read after that is a single comparison against whatever now() reports — 1999 reads the value, 2000 reads null and sweeps the row. Because we injected the clock, none of this waited on a real second to pass: the test moved time by hand and got a deterministic answer instantly.
set writes JSON.stringify(value) with no metadata, there is no deadline to check and get returns the value forever. You must store { value, expiresAt } (or { value, ttlMs, savedAt }) — some timestamp has to survive the write. Storing the duration alone (ttlMs) isn't enough either: without the write time, get can't tell how much of the window is left.expiresAt = now() + ttlMs is self-contained — a read needs only the current time. If you store ttlMs and try to compute "time remaining" on read, you also need the write timestamp, which is one more field and one more chance to get the arithmetic wrong. Collapse it to a single absolute deadline.localStorage coerces values to strings, so setItem('k', { a: 1 }) stores the literal "[object Object]". Always JSON.stringify on the way in and JSON.parse on the way out — that's also what lets objects, numbers, and booleans round-trip instead of becoming strings.get calls Date.now() directly, the only way to test expiry is to actually wait ttlMs — slow, flaky, and at the mercy of the machine. Inject now so a test can advance time by hand (or stub localStorage on globalThis and use jest.useFakeTimers() + jest.advanceTimersByTime()). Injection is the cleaner of the two; see Notes.null without calling removeItem leaves dead rows in storage. They never come back to life (every future read re-checks the deadline), but they waste the ~5MB localStorage budget. Evict on the read that discovers the expiry.getItem returns null, not undefined, for a missing key. Real localStorage.getItem returns null when a key is absent. Guard the missing-key case (raw === null) before you JSON.parse — JSON.parse(null) doesn't throw (it returns null), but JSON.parse(undefined) does, so handle both if your store might return either.get, rewrite the record with a fresh expiresAt = now() + ttlMs. One extra setItem inside the live branch turns fixed expiry into idle expiry, the model behind "log out after 30 minutes of inactivity."'user' key will clobber each other, and a blanket localStorage.clear() wipes unrelated data. Prefix every key with a namespace (set writes prefix + ':' + key, get reads the same) so one wrapper instance owns a slice of the keyspace and can clear only its own entries.localStorage fires a storage event on other tabs whenever a key changes. Listening for it lets a logout (or a cache bust) in one tab immediately propagate to every open tab, instead of each tab discovering the change only on its next get. The injected-store seam stays the same; you'd layer the event listener on top in the browser adapter.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
The browser's localStorage keeps key-value pairs around forever — there is no built-in way to say "remember this auth token, but only for an hour." You're going to build a thin wrapper that adds a time-to-live (TTL): a value you save with set(key, value, ttlMs) reads back normally until ttlMs milliseconds have passed, and null after that. This is the same caching-with-expiry idea behind HTTP Cache-Control: max-age, Redis EXPIRE, and session cookies.
The catch: a real localStorage only stores strings, and it has no concept of time. So the wrapper has to write the expiry deadline into storage alongside the value, then compare against the clock on every read.
// backingStore: a localStorage-shaped store — { getItem, setItem, removeItem }.
// Defaults to an in-memory adapter so this runs with no real localStorage.
// now: () => number — current time in ms. Defaults to () => Date.now().
function createStorageWithExpiry(
backingStore?,
now?,
): {
set(key: string, value: unknown, ttlMs: number): void; // persist value + deadline
get(key: string): unknown | null; // value if live, else null
remove(key: string): void; // delete a key outright
};
const store = createStorageWithExpiry();
store.set('token', 'abc123', 60_000); // good for 60 seconds
store.get('token'); // → 'abc123' (read immediately)
// ...61 seconds later...
store.get('token'); // → null (the TTL has elapsed)
// Values are not limited to strings — objects round-trip too.
store.set('user', { id: 7, name: 'Ada' }, 5_000);
store.get('user'); // → { id: 7, name: 'Ada' }
// Overwriting restarts the clock: this is good for a fresh 5_000ms.
store.set('user', { id: 7, name: 'Ada' }, 5_000);
localStorage and you must not depend on the wall clock for deterministic tests. createStorageWithExpiry(backingStore, now) lets a test pass a Map-backed stub and a clock it advances by hand. In a real browser you'd call createStorageWithExpiry(window.localStorage). See the solution's Notes for why this matters.expiresAt = now() + ttlMs, not the raw ttlMs. Storage has no clock of its own, so a read has to compare a stored absolute timestamp against the current time.get finds an expired entry, remove it from the backing store before returning null — don't let dead rows pile up.ttlMs is a positive number of milliseconds. Don't worry about negative or zero TTLs, NaN, or cross-tab synchronization — those are out of scope here.JSON.stringify / JSON.parse. Functions, undefined, and circular references are out of scope.You'll wrap a key-value store so that every value you save carries a deadline, and reads start returning null the moment that deadline passes.
You save an auth token to the browser and want it gone in an hour — but localStorage has no expiry. It holds whatever you put in it until someone clears it, and everything it holds is a string. So you can't just store the token; you have to store the token plus the time it should die, and check that time yourself on the way out. Think of it like a carton of milk: storage is the fridge, which keeps things cold forever and asks no questions. You're the one who writes the use-by date on the carton and throws it out when the date is past.
Two operations, two jobs. On set, you compute an absolute deadline — expiresAt = now() + ttlMs — and write { value, expiresAt } into storage as one JSON string. On get, you read that string back, parse it, and ask one question: has the deadline passed? If not, hand back the value. If it has, delete the entry and return null.
The crucial choice is absolute deadline, not remaining duration. Storage has no clock — it can't count down ttlMs for you. So you bake the current time into the stored value at write time, and every read compares that frozen deadline against a fresh now().
The obvious version stores the value directly, the way you'd use localStorage normally:
function createStorageWithExpiry(backingStore) {
return {
set(key, value, ttlMs) {
backingStore.setItem(key, JSON.stringify(value)); // ttlMs ignored!
},
get(key) {
const raw = backingStore.getItem(key);
return raw === null ? null : JSON.parse(raw);
},
};
}
This passes the "read it right back" test and nothing else. Look at set: it takes ttlMs and drops it on the floor. There is no deadline written anywhere, so get has nothing to check against — the value reads back the same at one second or one century. You can't expire what you didn't timestamp. The duration has to survive the write, and the only place it can survive is inside storage, next to the value.
A second, subtler attempt fixes that but trips on a different rock — it stores the duration and tries to age the entry without a clock:
set(key, value, ttlMs) {
backingStore.setItem(key, JSON.stringify({ value, ttlMs }));
},
get(key) {
const raw = backingStore.getItem(key);
if (raw === null) return null;
const { value, ttlMs } = JSON.parse(raw);
// ...how much time has passed? We have ttlMs, but no idea how long ago set ran.
return value;
}
Now the metadata is there, but get is stuck: it knows the value was good for ttlMs, but not when the clock started. Without the write-time timestamp, "remaining time" is unknowable. Storing expiresAt = now() + ttlMs instead of ttlMs resolves both problems at once — the deadline is self-contained, and any later read needs nothing but the current time.
function createStorageWithExpiry(
backingStore = createMemoryStore(),
now = () => Date.now(),
) {
function set(key, value, ttlMs) {
// Store the absolute deadline, not the duration. Storage has no clock, so a
// future read needs a timestamp it can compare against now() directly.
const record = { value, expiresAt: now() + ttlMs };
// localStorage only holds strings, so serialize the whole record.
backingStore.setItem(key, JSON.stringify(record));
}
function get(key) {
const raw = backingStore.getItem(key);
if (raw === null || raw === undefined) return null; // never written
const record = JSON.parse(raw);
if (now() >= record.expiresAt) {
// Expired. Purge the dead row so it doesn't linger, then report absence.
backingStore.removeItem(key);
return null;
}
return record.value;
}
function remove(key) {
backingStore.removeItem(key);
}
return { set, get, remove };
}
// Default backing store: a Map dressed up with the three localStorage methods
// the wrapper uses. Lets createStorageWithExpiry() run with no real localStorage
// (in Node, in tests, on a server) while a browser caller passes window.localStorage.
function createMemoryStore() {
const data = new Map();
return {
getItem: (k) => (data.has(k) ? data.get(k) : null),
setItem: (k, v) => data.set(k, String(v)),
removeItem: (k) => data.delete(k),
};
}
module.exports = { createStorageWithExpiry };
The shape that does all the work is { value, expiresAt }. set computes the deadline once and freezes it into storage; get reads it back and compares against a live now(). Three things deserve a second look — the comparison boundary, the purge-on-read, and the two parameters with defaults.
The comparison is now() >= record.expiresAt, inclusive. At the exact millisecond the deadline arrives, the entry is dead. Using > instead would keep it alive for one extra tick — usually harmless, but the inclusive form matches the intuition "good for 1000ms," i.e. good on [set, set + 1000).
get deletes before returning null on the expired branch. A read isn't supposed to mutate, but an expired entry is garbage — leaving it in storage means dead rows accumulate until something clears them. Purging on read is the cheapest possible cleanup: the read already had the record in hand. (This is sometimes called lazy expiration — you only pay to evict the things you actually touch.)
backingStore and now are parameters with defaults. This is the whole testability story. Both default to real-world implementations — an in-memory store and Date.now — so createStorageWithExpiry() with no arguments just works. But because they're parameters, a test (or a server with no localStorage) can swap in a Map-backed stub and a clock it advances by hand. Nothing inside the wrapper reaches for a global; the two things it can't control in a test are the two things you pass in.
Take the timeline from the mental-model section and run it with a clock we control. The clock starts at t = 1000; clock.advance(ms) is the only thing that moves time.
t = 1000 store.set('k', 'v', 1000)
expiresAt = now() + ttlMs = 1000 + 1000 = 2000
storage['k'] = '{"value":"v","expiresAt":2000}'
t = 1000 store.get('k')
raw is present → parse → now()=1000 >= 2000? no
→ return 'v' ✓ live
clock.advance(999) // t = 1999, one tick before the deadline
t = 1999 store.get('k')
now()=1999 >= 2000? no
→ return 'v' ✓ still live
clock.advance(1) // t = 2000, exactly at the deadline
t = 2000 store.get('k')
now()=2000 >= 2000? yes
→ removeItem('k'); storage['k'] is gone
→ return null ✗ expired + purged
t = 2000 store.get('k') // again
raw is null (we deleted it) → return null
The deadline 2000 was computed once, at write time, and never moved. Every read after that is a single comparison against whatever now() reports — 1999 reads the value, 2000 reads null and sweeps the row. Because we injected the clock, none of this waited on a real second to pass: the test moved time by hand and got a deterministic answer instantly.
set writes JSON.stringify(value) with no metadata, there is no deadline to check and get returns the value forever. You must store { value, expiresAt } (or { value, ttlMs, savedAt }) — some timestamp has to survive the write. Storing the duration alone (ttlMs) isn't enough either: without the write time, get can't tell how much of the window is left.expiresAt = now() + ttlMs is self-contained — a read needs only the current time. If you store ttlMs and try to compute "time remaining" on read, you also need the write timestamp, which is one more field and one more chance to get the arithmetic wrong. Collapse it to a single absolute deadline.localStorage coerces values to strings, so setItem('k', { a: 1 }) stores the literal "[object Object]". Always JSON.stringify on the way in and JSON.parse on the way out — that's also what lets objects, numbers, and booleans round-trip instead of becoming strings.get calls Date.now() directly, the only way to test expiry is to actually wait ttlMs — slow, flaky, and at the mercy of the machine. Inject now so a test can advance time by hand (or stub localStorage on globalThis and use jest.useFakeTimers() + jest.advanceTimersByTime()). Injection is the cleaner of the two; see Notes.null without calling removeItem leaves dead rows in storage. They never come back to life (every future read re-checks the deadline), but they waste the ~5MB localStorage budget. Evict on the read that discovers the expiry.getItem returns null, not undefined, for a missing key. Real localStorage.getItem returns null when a key is absent. Guard the missing-key case (raw === null) before you JSON.parse — JSON.parse(null) doesn't throw (it returns null), but JSON.parse(undefined) does, so handle both if your store might return either.get, rewrite the record with a fresh expiresAt = now() + ttlMs. One extra setItem inside the live branch turns fixed expiry into idle expiry, the model behind "log out after 30 minutes of inactivity."'user' key will clobber each other, and a blanket localStorage.clear() wipes unrelated data. Prefix every key with a namespace (set writes prefix + ':' + key, get reads the same) so one wrapper instance owns a slice of the keyspace and can clear only its own entries.localStorage fires a storage event on other tabs whenever a key changes. Listening for it lets a logout (or a cache bust) in one tab immediately propagate to every open tab, instead of each tab discovering the change only on its next get. The injected-store seam stays the same; you'd layer the event listener on top in the browser adapter.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.