Expiration
Set a TTL or an absolute expiration time and use optional reactive notifications and lazy cleanup.
Some data is only useful for a limited time: cached responses become stale, temporary preferences
should reset, and dismissed notices may need to reappear. localStorage has no built-in expiration.
ultrastorage lets you set a lifetime or an expiration date so expired values are treated as missing when read.
import { createStorage } from 'ultrastorage';
export const appStorage = createStorage({ prefix: 'acme' });
import { appStorage } from './lib/app-storage';
// Expires in 60 seconds
appStorage.setItem('token', 'abc123', { ttl: 60_000 });
// Expires at a specific date
appStorage.setItem('session', { id: 1 }, { expiresAt: new Date('2025-12-31') });
// Expired items return null
appStorage.getItem('token'); // null (after 60s)
Cleanup and notifications
By default, passing an expiration timestamp does not schedule a timer or notify subscribers. Cleanup through getItem() or clearExpired() emits an expire event. Bookkeeping reads (has(), key(), keys(), and length) exclude expired entries without deleting them.
ttl and expiresAt cannot be used together. Both accept finite millisecond values; expiresAt
also accepts a valid Date.
An entry expires when the current time is strictly greater than its expiry timestamp. A new write
without expiration options preserves the current unexpired deadline, including writes through updateItem()
or a React setter. getOrInit() leaves expiration unchanged when it returns an existing value;
see initialization and updates.
For React rendering, see snapshots and expiration.
Reactive expiration
Opt in per subscription to receive an expire event when the deadline passes, without
waiting for a read or cleanup:
const unsubscribe = appStorage.subscribe(
'notice',
(change) => {
if (change.type === 'expire') {
console.log('The notice has expired');
}
},
{ reactiveExpiration: true },
);
Only opted-in listeners receive timer events. The timer does not delete the stored entry;
its expiration metadata remains inspectable. A listener that calls getItem() may trigger
cleanup, and later cleanup can emit another expire event to all listeners.
Each subscription owns its timer and uses its instance’s serializer. Writes, deadline changes, and cross-tab events reschedule it. Unsubscribing cancels it. Missing, unreadable, and non-expiring entries need no timer. Already-expired entries notify asynchronously after subscribing. An unchanged observed entry and deadline are announced only once per subscription.
Timers recheck the stored entry and current time before notifying. Window focus and returning to a visible page also recheck opted-in subscriptions, without waiting for delayed timers. These browser listeners are shared across backends and removed after the last reactive subscription unsubscribes. Repeated focus and visibility events do not repeat an expiration notification for the same observed entry and deadline. Browser throttling or tab suspension may delay notification; exact timing is not guaranteed. Long deadlines are checked in chunks. Timer checks that fail to access the backend report the error and stop until another storage change or focus/visibility recheck retries them.
React hooks accept { reactiveExpiration: true } too; see
snapshots and expiration. Rendering and SSR never
start timers. Explicit server-side subscriptions with a supplied backend can use timers,
which do not keep the process alive where unref() is supported.
Inspect expiration
Inspect the stored deadline without reading the value:
const expiration = appStorage.getExpiration('draft');
// { expiresAt: 1790000000000 } for an entry with a deadline, even if it has passed
// { expiresAt: null } for an entry that never expires
// null for a missing or unreadable entry
expiresAt is a Unix timestamp in milliseconds. Unlike getItem() and has(), inspection
includes expired entries that are still physically stored. Once getItem() or clearExpired()
removes an expired entry, its expiration metadata is no longer available.
Inspection does not delete entries or notify subscribers. To check whether the deadline has
passed, use Date.now() > expiration.expiresAt after checking both the result and its deadline
for null. Remaining lifetime can be calculated as expiration.expiresAt - Date.now();
a negative result means the deadline has passed.
Change expiration
Reset a draft’s lifetime without supplying its value:
appStorage.setExpiration('draft', { ttl: 7 * 24 * 60 * 60_000 });
appStorage.setExpiration('draft', { expiresAt: new Date('2030-01-01') });
appStorage.setExpiration('draft', null); // Keep the value indefinitely
ttl starts from now; it does not add time to the old deadline. The method returns true for
an existing, unexpired entry, including when the deadline is already the requested value.
Missing, expired, and unreadable entries return false without cleanup. Expired values are
never revived. Stored null counts as an existing entry.
Pass exactly one of ttl or expiresAt, or pass null to remove expiration. Invalid options
throw a TypeError. Backend and serialization errors propagate. The value is preserved, but
the backend still rewrites the serialized entry; changes are not atomic across tabs.
Changed stored content emits a set event, including across instances on the same page.
An unchanged deadline does not write or notify. Setting a deadline in the past makes the
entry expired immediately on its next read; cleanup emits expire as usual. No timer is scheduled.
Preserve expiration when writing
Writes preserve the current unexpired entry’s deadline by default:
appStorage.setItem('draft', { title: 'First draft' }, { ttl: 60_000 });
appStorage.setItem('draft', { title: 'Revised draft' }); // Keeps the original deadline
appStorage.updateItem<number>('count', (count) => (count ?? 0) + 1);
The lifetime does not restart on updates. Missing, expired, unreadable, and non-expiring entries produce a value without expiration. The deadline is inspected at write time; updates remain non-atomic across tabs.
Pass ttl or expiresAt to replace the deadline, or expiresAt: null to remove it:
appStorage.setItem('draft', { title: 'Keep this draft' }, { expiresAt: null });
React setters follow the same defaults and accept the same options:
setDraft(nextDraft); // Keeps the current unexpired deadline
setDraft(nextDraft, { expiresAt: null }); // Removes expiration
getOrInit() uses these rules when writing its factory result. An existing non-null value is
returned without changing expiration regardless of write options.
This changes the previous write default, which removed expiration. To retain that behavior,
pass { expiresAt: null } explicitly. The preserveExpiration option has been removed.