ESC

Type to search the knowledge base.

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.

beginner6 min read
  • 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) writes JSON.stringify({ value, expiresAt }) where expiresAt is Date.now() + ttlMs.
  • get returns null on miss, on parse failure, and on expiry. Expiry and garbage both removeItem.
  • Expired keys still count against quota until removed. get is lazy eviction, not a sweeper.
  • JSON.parse belongs in try/catch. Corrupt strings are normal.
  • Guard window / localStorage for SSR. setItem can throw QuotaExceededError.

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

  1. Check storage exists. If not, get is null, set is false.
  2. set: stringify { value, expiresAt: now() + ttlMs }, setItem in try/catch.
  3. get: getItem, JSON.parse in try/catch. Bad JSON: removeItem, null.
  4. If now() >= expiresAt, removeItem, null. Else return value.
  5. Optional sweep on 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 guides