localStorage with Expiry
Wrap localStorage in a JSON envelope with expiresAt. Stale keys still occupy quota until you remove them, and JSON.parse can throw.
- javascript
- localstorage
- web-storage
- interview
localStorage has no TTL. Teams store JSON.stringify(value) and hope. A week later the key is still there, quota is still charged, and JSON.parse of a leftover raw string throws and takes down a render. localStorage with expiry is a tiny envelope: { value, expiresAt }. get returns null if the row is stale and calls removeItem. Stale keys you never read still occupy quota until something removes them.
Web Storage is a synchronous string map, origin-scoped. It is not a cache API with eviction. The localStorage and sessionStorage page is the platform. This page is the wrapper they ask you to type.
Key takeaways
set(key, value, ttlMs)writesJSON.stringify({ value, expiresAt })whereexpiresAtisDate.now() + ttlMs.getreturnsnullon miss, on parse failure, and on expiry. Expiry and garbage bothremoveItem.- Expired keys still count against quota until removed.
getis lazy eviction, not a sweeper.JSON.parsebelongs intry/catch. Corrupt strings are normal.- Guard
window/localStoragefor SSR.setItemcan throwQuotaExceededError.
JSON envelope with expiresAt, and stale keys that still occupy quota
function canUseStorage() {
try {
return typeof window !== 'undefined' && !!window.localStorage;
} catch {
return false;
}
}
function createExpiringStorage({
storage = canUseStorage() ? window.localStorage : null,
now = () => Date.now(),
prefix = 'exp:',
} = {}) {
const fullKey = (key) => prefix + key;
function set(key, value, ttlMs) {
if (!storage) return false;
if (typeof ttlMs !== 'number' || ttlMs <= 0) {
throw new TypeError('ttlMs must be a positive number');
}
const envelope = {
value,
expiresAt: now() + ttlMs,
};
try {
storage.setItem(fullKey(key), JSON.stringify(envelope));
return true;
} catch {
return false;
}
}
function get(key) {
if (!storage) return null;
const slot = fullKey(key);
const raw = storage.getItem(slot);
if (raw == null) return null;
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
storage.removeItem(slot);
return null;
}
if (
parsed === null ||
typeof parsed !== 'object' ||
typeof parsed.expiresAt !== 'number'
) {
storage.removeItem(slot);
return null;
}
if (now() >= parsed.expiresAt) {
storage.removeItem(slot);
return null;
}
return parsed.value;
}
function remove(key) {
if (!storage) return;
storage.removeItem(fullKey(key));
}
function sweep() {
if (!storage) return;
const keys = [];
for (let i = 0; i < storage.length; i++) {
const k = storage.key(i);
if (k && k.startsWith(prefix)) keys.push(k.slice(prefix.length));
}
for (const key of keys) get(key);
}
return { set, get, remove, sweep };
}
const store = createExpiringStorage({
storage: window.localStorage,
now: () => 1_000,
});
store.set('session', { id: 'abc' }, 500);
store.get('session'); // { id: 'abc' }
const later = createExpiringStorage({
storage: window.localStorage,
now: () => 1_600,
});
later.get('session'); // null, key removed
expiresAt is an absolute timestamp, not a leftover TTL. A leftover TTL would require storing createdAt too and would lie if you read the same key twice without rewriting. Absolute time plus now() is one comparison.
value can be any JSON-able value: objects, arrays, strings, numbers, booleans, null. It cannot be undefined (stringify omits it), a function, a Date that you wanted to stay a Date (it comes back as a string), or a cycle. Those are JSON pitfalls, not storage bugs. If you need a Date, store an ISO string inside value and revive it yourself.
Quota does not care about expiry
localStorage quota is per origin (commonly around 5 MB, host-defined). The storage API does not know your envelope. A key whose expiresAt is last month still occupies its string length until removeItem or clear. get evicts on read. A write-only dump of stale keys will still throw QuotaExceededError on the next setItem.
That is why sweep exists: collect keys first (mutating storage while you index length skips entries), then get each so stale rows removeItem. Prefix keys (exp: here) so a sweep cannot JSON.parse a bare 'dark' theme string and delete a key you do not own. Do not sweep on every read. Do sweep on app boot or before a large write if you are close to quota.
set returns false on throw. Private mode, disabled storage, and quota all throw. Catching inside the wrapper keeps a theme toggle from crashing the tree. A useLocalStorage hook has the same try/catch job plus SSR and the storage event.
JSON.parse can throw
Anything can be in that key: a previous version wrote a bare "dark" string, a user edited DevTools, a partial write. JSON.parse throws SyntaxError. Uncaught, that is a white screen. Catch, removeItem, return null. Treat a parsed primitive (no expiresAt) as corrupt too, or you will >= against undefined and evict or keep wrongly.
getItem itself does not throw on miss; it returns null. The try around localStorage in canUseStorage is for browsers that throw on access (some privacy modes). SSR: window is missing. Reading storage in a React first render can hydrate mismatch; the hook page covers that. This helper should no-op and return null / false without touching window.
sessionStorage and cookies
Same envelope works on sessionStorage if you pass it in. Session already dies with the tab; TTL is still useful for short-lived tokens you do not want surviving a long-lived tab. Do not put secrets in Web Storage. Cookies with Max-Age are the browser-native expiry, sent to the server, and have their own size and CSRF rules. IndexedDB is for larger data and real eviction policies.
Procedure
- Check storage exists. If not,
getisnull,setisfalse. set: stringify{ value, expiresAt: now() + ttlMs },setItemintry/catch.get:getItem,JSON.parseintry/catch. Bad JSON:removeItem,null.- If
now() >= expiresAt,removeItem,null. Else returnvalue. - Optional
sweepon boot so unread stale keys free quota. Sweep only prefixed keys.
Interview answer (out loud)
localStorage has no TTL, so I store a JSON envelope with value and expiresAt. get parses in try/catch, returns null if the row is missing, corrupt, or stale, and removeItem on corrupt or stale. Expired keys still occupy quota until removed; get is lazy, so I might sweep on boot, and I prefix keys so that sweep cannot delete other app data. setItem is in try/catch because quota and private mode throw. I check window for SSR. I would not claim this is a cache with LRU; it is a string map with a timestamp.
Further reading
Related on this site
- localStorage and sessionStorage
- useLocalStorage hook
- JSON parse and stringify pitfalls
- JSON.parse implementation
- try/catch/finally
- IndexedDB overview
Related guides
- Analytics SDK Event QueueQueue analytics events, send them in series, retry the ones that fail, and flush on page hide without dropping the last click.
- Arrays That Emit Change EventsWrap push, pop, and splice so subscribers hear adds and removes. Proxy vs subclass, and why mutating via index assignment still slips through.
- Array.filter PolyfillWrite filter that skips holes, respects thisArg, and never writes empty slots for rejected items. Contrast with map’s hole-preserving result.
- Array.find PolyfillWrite Array.prototype.find: first callback hit wins, holes are read as undefined, nothing matching returns undefined, and findIndex is the index twin.
- Array.flat PolyfillImplement flat(depth) with Infinity, holes, and non-array array-likes. Recursion vs stack, and why concat(spread) blows up on huge inputs.