Persistent Client State
What survives a reload, a tab discard, a browser restart and a new device are four different questions — and anything you persist is a schema you now have to migrate.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
What should still be here after a reload, a crash, a week away, or a switch to another device?
Someone comes back. They expect the interface to remember the small things — dark mode, the sidebar they collapsed, the draft they had not sent — and they expect their actual data to be there regardless of which device they picked up.
Persist the store to localStorage on every change and rehydrate it on load. It is two lines, it makes reloads feel instant, and the user gets everything back.
The store contains server data, so the application boots from a snapshot of unknown age. A price, a permission or an order status from last Tuesday renders as current (State Synchronization).
- The store contains server data, so the application boots from a snapshot of unknown age. A price, a permission or an order status from last Tuesday renders as current (State Synchronization).
- The store contains the user's role, so someone whose access was revoked still sees the admin navigation until a request happens to fail (Authorization-Aware UI).
- The store contains a token, so any script on the page can read it, and it survives a logout in another tab (Storage Security and Durability).
- The next release changes the store's shape. Returning users rehydrate last month's shape into this month's code and the app throws on boot — for exactly the users who were most engaged.
localStorageis synchronous and main-thread-blocking, so serialising a large store on every change adds work at interaction time and reading it back delays startup (localStorage and sessionStorage).- The user opens the app on their phone and none of it is there, because storage is per-device and per-browser and nobody told them that.
- The quota fills — storage limits are per-origin and shared across everything you wrote — and the write throws where nobody handles it (Choosing Browser Storage).
What is actually happening
In the browser, not in the framework.
- "Survives" is four different guarantees, and they are commonly conflated: surviving a soft navigation (in memory), a reload (the URL and storage), a tab discard or crash (storage and the session history entry), and a different device (only the server).
- A tab discard is normal, not exceptional. Browsers evict background tabs under memory pressure and restore them from the session history entry when the user returns — the document is re-created, so everything in memory is gone and the URL is not (The Multi-Process Browser).
- The storage APIs differ on the axes that matter — lifetime, synchronicity, capacity, structure, and whether the server sees it — and choosing between them is its own lesson (Choosing Browser Storage).
- Persistence is not durable. Browsers evict origin storage under pressure, privacy modes discard it at session end, and users clear it. Treat every read as "may be absent or may be old", never as "will be there".
- Anything persisted is a serialised schema with no owner. The code that wrote it has shipped and been replaced; the code that reads it is new. Without a version tag there is no way to tell which release produced what you just read.
- Storage is shared across tabs of the same origin, so two tabs are two writers with no coordination. The
storageevent tells other tabs a key changed; the writing tab does not receive its own event (Auth Across Tabs). - Restoring persisted state before the first paint changes what the server-rendered HTML and the hydrated client disagree about — a classic hydration mismatch, because the server has no access to the user's storage (Hydration Mismatch).
What this makes the browser do
And which of it is avoidable.
- Web Storage reads and writes are synchronous on the main thread. A large JSON serialise on every state change is main-thread work at interaction frequency, and the parse on boot delays everything after it (localStorage and sessionStorage).
- IndexedDB is asynchronous and structured, so it does not block, but it costs a transaction, an event round trip and real API complexity (IndexedDB).
- Cookies are sent on every matching request, so anything stored there is bandwidth on every navigation and API call — a per-request cost paid forever (Cookies).
- Rehydrating before first paint delays the paint; rehydrating after it causes a visible change of state. Neither is free, and which one is right depends on whether the value affects layout (Visual Stability).
- Cache Storage holds responses rather than values and is the right home for offline assets and payloads, at the cost of a service worker to manage it (Cache Storage).
Four different meanings of "survives"
Teams say "it should still be there when they come back" and mean four incompatible things. The table separates them, because the mechanism that satisfies one does not satisfy the next, and the most common design error is answering the fourth question with a solution to the second.
The row worth staring at is the tab discard. It is not a crash and not an error — it is the browser reclaiming memory from a background tab, and it is routine on phones. Everything in memory is gone; the URL and storage come back.
| Event | In-memory state | URL | Web Storage / IndexedDB | Server | What this means in practice |
|---|---|---|---|---|---|
| Client-side route change | Survives if held above the route | Changes by design | Survives | Untouched | The only case a plain store handles on its own |
| Reload or crash | Lost | Survives | Survives | Untouched | A URL-driven view returns intact; a store-driven one does not |
| Tab discard, then return | Lost | Survives (restored from session history) | Survives | Untouched | Routine on memory-constrained devices (The Multi-Process Browser) |
| Browser restart | Lost | Only if the session is restored | Survives (sessionStorage does not) | Untouched | sessionStorage is per-tab-session, which is narrower than most people assume |
| Private window closed | Lost | Lost | Discarded | Untouched | Never depend on persistence being available |
| Storage evicted or cleared | Unaffected | Unaffected | Gone | Untouched | Every read needs a default |
| Different device or browser | Lost | Only if the link was shared | Absent | Present | Only the server crosses this line |
| New release deployed | Lost on reload | Survives — old parameters and all | Survives — old schema and all | Migrated by you | Both survivors carry last release's assumptions (Deploying a Frontend) |
Where should this actually be written?
Once you know it must outlive the page, there are five destinations and they are not interchangeable. The deciding axes are who needs to read it, how big it is, whether a synchronous write is acceptable, and what it costs you if it leaks.
The default answer for most values is smaller than teams expect: a handful of preference keys in Web Storage, one cookie for anything the server must know at render time, and the server for everything that is actually the user's data.
Who needs to read this, how large is it, and what happens if it leaks or disappears?
when The server must know it while rendering — a theme that would otherwise flash, a locale, a session identifier. Set HttpOnly when script has no business reading it, plus Secure and a SameSite policy.
cost Sent on every matching request forever, tightly size-limited, and a CSRF surface if it authenticates a state-changing request (Cross-Site Request Forgery).
when A small, synchronous, per-device value that must outlive the session: a theme override, a density setting, a collapsed-sidebar flag, a dismissed-banner id.
cost Synchronous main-thread reads and writes, string-only, readable by every script on the origin, and a schema you now version (localStorage and sessionStorage).
when It should survive a reload but not outlive the tab: a multi-step flow's progress, a scroll anchor, a one-time return target.
cost Per-tab-session, so it is invisible to a duplicated tab and gone after a restart — which is either exactly right or a surprise (localStorage and sessionStorage).
when It is large, structured, written often, or must not block: an offline mutation queue, cached documents, an autosaved draft of any size.
cost Asynchronous, transactional and considerably more API surface; still origin-readable and still evictable (IndexedDB).
when The user would expect it on another device, or it must not be stale or wrong: real preferences on an account, actual data, anything authorization touches.
cost A request per change, an offline story, and latency on read — in exchange for one authoritative copy everywhere (Server State Is Not Your State).
The bugs that only returning users see
Every row below shares a property: it cannot happen on a fresh profile. Development runs on a fresh profile, CI runs on a fresh profile, and the QA pass after a release runs on a fresh profile — which is why persisted-state bugs reach production more reliably than almost any other category.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| A release changes the persisted shape | Returning users hit a boot error; new users are fine | Last month's payload rehydrated into this month's code | Version the payload; on an unknown version, discard and start clean rather than guessing. |
| Server data persisted along with preferences | The app boots instantly showing week-old prices | A cache with no age treated as startup state | Persist preferences only; treat server data as a cache with a freshness policy (State Synchronization). |
| Role or permission flags persisted | A revoked user still sees privileged navigation | An authorization decision cached across sessions | Re-derive from a verified claim on boot; enforce server-side always (Authorization-Aware UI). |
| Quota exhausted | A save silently fails, or throws from an unrelated action | Per-origin limits shared across everything you have ever written | Wrap writes, bound what you store, and prune oldest-first (Choosing Browser Storage). |
| Theme read from storage after first paint | A white flash then dark mode on every load | The server could not know the preference, so it rendered the default | Move the preference to a cookie the server can read, or apply it in a tiny inline script before paint (Visual Stability). |
| Two tabs writing the same preference key | A setting reverts for no visible reason | Two writers, last write wins, no notification | Listen for the storage event and reconcile (Auth Across Tabs). |
| Logout does not clear storage | The next person on a shared machine sees the previous user's drafts | Storage has no concept of a session ending | Enumerate and clear everything you wrote, including caches, on logout (Session Expiry and the Refresh Race). |
| Persisted state assumed present | The app throws in a private window | A read with no default, or an API that behaves differently there | Default on every read and wrap every write; absence is normal. |
How to build it
Most important first.
- Persist preferences and conveniences, not truth. Theme, density, collapsed panels, last-used filters, a recently-viewed list, an in-progress draft — things whose worst case is a mildly wrong default.
- Never persist authorization claims, tokens if you can avoid it, prices, permissions or anything the server is authoritative for. If it must not be stale and must not be wrong, it belongs to the server (What the Frontend Is Responsible For in Auth).
- Version every persisted payload from the first commit. Read the version, and on an unknown one discard rather than guess — a lost preference is a shrug, a corrupt boot is an outage.
- Choose the mechanism by the axes rather than by habit: cookies when the server needs it, Web Storage for small synchronous values, IndexedDB for anything large or structured, Cache Storage for responses (Choosing Browser Storage).
- Write on meaningful change rather than on every change, and keep the payload small enough that the synchronous cost is irrelevant.
- Handle absence and failure at every read: private mode, cleared storage, a full quota and a corrupt value are all normal. Every read gets a default; every write is wrapped.
- Clear on logout, deliberately and by enumerating what you wrote — including caches — because storage does not know a session ended (Session Expiry and the Refresh Race).
- Sync cross-device state through the server, and be honest in the UI about which settings are per-device. "Remembered on this device" is a sentence users understand (Server State Is Not Your State).
- If persisted state affects the first render, decide explicitly whether the server can know it — a cookie can be read server-side,
localStoragecannot — or render a neutral state and apply the preference after mount (Hydration Mismatch).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Preferences are accessibility settings. Persisted reduced-motion choices, font-size overrides, high-contrast selections and density are exactly what a returning user should not have to set again — losing them is a barrier, not an inconvenience (Contrast, Colour and Motion).
- Respect the platform first and persist only an explicit override.
prefers-reduced-motionandprefers-color-schemeare already the user's stated preference; a persisted value should record that they chose to differ from it, not silently replace it (Contrast, Colour and Motion). - Applying a persisted theme or font size after first paint produces a visible flash and a reflow, which is disorienting generally and actively harmful for users sensitive to motion or flashing. A cookie readable at render time avoids it (Visual Stability).
- A restored draft must be announced, not merely present. "Draft from Tuesday restored" in a live region, with a labelled way to discard it, tells a non-visual user why the fields are not empty (Live Regions and Announcement).
- Restoring state must not steal focus. Applying a persisted preference on load should never move the focus point away from where the browser put it (Focus Management).
What can go wrong
- Unversioned persisted state meeting a new release. The failure lands only on returning users, so it passes every test run against a fresh profile.
- The mitigation failing: a migration function that runs on a shape it did not expect and writes back something worse, which then persists across the fix.
- Quota exceeded on write, thrown from a code path with no handler, taking down whatever triggered the save (Choosing Browser Storage).
- Storage cleared by the browser or the user, and code that assumed presence rather than defaulting.
- Two tabs writing the same key with last-write-wins and no
storagelistener, so one tab's preferences silently revert (Auth Across Tabs). - Persisted data outliving the account: a shared machine where the next person sees the previous user's drafts, recent items and cached responses.
- A persisted server-data cache making the app boot fast and wrong, which is the most convincing failure in this lesson because it looks like a performance win (Long-Lived Clients and Version Skew).
- Two tabs writing the same key with no coordination: last write wins and the other tab is only told if it listens for the
storageevent (Auth Across Tabs). - A write racing a tab discard or a crash, leaving a partially written value that parses into something nonsensical.
- Rehydration racing the first fetch: persisted data renders, the network response arrives, and whichever applies last wins unless one is declared authoritative (State Synchronization).
- A logout in one tab racing a write in another, so the write re-creates data the logout had just cleared (Session Expiry and the Refresh Race).
- A service worker updating the Cache Storage while the page reads from it (The Service Worker Lifecycle).
- Web Storage is readable by every script running on the origin, including third-party ones. There is no HTTP-only equivalent, which is why a token there is a different risk profile from a token in a cookie (Cookies vs Script-Readable Tokens).
- Persistence multiplies the cost of an XSS: a script that runs once can read everything that was ever persisted, not just what is in memory now (Cross-Site Scripting).
- Storage survives logout and survives the user. On a shared or public machine, un-cleared drafts, recent items and cached responses are an exposure with no attacker required (Storage Security and Durability).
- The browser partitions storage by origin and increasingly by top-level site for embedded contexts, which it does enforce — but it enforces nothing about what you chose to write (Origins and the Sandbox).
- A persisted permission flag is a client-side authorization decision with a long lifetime. The server re-checks every request regardless of what boots (Authorization-Aware UI).
- "Persist the whole store — it is the same data anyway." It is the same data with a different lifetime, a different exposure and a different owner. That is three differences, not zero.
- "
localStorageis fine for a token." It is readable by every script on the origin and survives the session. Whether that is acceptable is a threat-model question with a usually-no answer (Cookies vs Script-Readable Tokens). - "Persisted means durable." Browsers evict under pressure, private modes discard, and users clear. Every read must tolerate absence.
- "Rehydration makes startup faster." It makes startup *earlier*, with content of unknown age. Those are different claims and only one of them is a performance win.
- "Storage is per-user." It is per-browser-profile and per-origin. Two accounts on one machine share it unless you clear it at logout.
- "IndexedDB is overkill." For anything large, structured, or written at speed it is the only option that does not block the main thread (IndexedDB).
Measuring it, and what changes in the field
- Application → Storage in devtools: read what you actually persisted and how big it is. It is routinely larger and more sensitive than the team believes (localStorage and sessionStorage).
- Load the app, then clear storage and load it again. Any difference in behaviour beyond preferences is state that should not have been persisted.
- Simulate a returning user across a release: write the previous version's payload, deploy the new code, and boot. That is the test the migration path never gets.
- Discard the tab (devtools can freeze and discard) and return to it. What comes back is what genuinely survives (The Multi-Process Browser).
- Error tracking filtered to boot-time exceptions grouped by persisted-state version tells you a migration broke before the support queue does (Frontend Error Tracking).
- On a memory-constrained device, tab discards are frequent, so persistence stops being a convenience and becomes how long tasks survive at all (The Multi-Process Browser).
- In a private window, storage is discarded at session end and some APIs behave differently; code that assumes persistence will be wrong there every time.
- Across a deploy, persisted state is the main carrier of last release's assumptions into this release's code (Deploying a Frontend).
- On a shared device, persisted state belongs to whoever sits down next unless logout clears it.
- With a large dataset — an offline mutation queue, a cached document set — Web Storage is the wrong mechanism and IndexedDB is the right one (The Offline Mutation Queue).
- Everything you persist becomes a schema you own forever, with a migration path, a version tag and a failure mode that only reaches returning users.
- Restoring before paint costs startup time; restoring after paint costs a visible flash. There is no option that costs neither.
- Storage is per-device, so it is cheap and instantly divergent from every other device the same person uses. The server is consistent everywhere and costs a request.
- Persisting more makes the app feel faster on return and increases both the exposure surface and the number of ways a boot can fail.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALOrigin-scoped storage, eviction under pressure, per-origin quotas and the tab lifecycle are common to Blink, Gecko and WebKit. The mechanisms and their trade-offs are the same everywhere; only the numbers and the eviction aggressiveness are not.
- BROWSER-SPECIFICRetention policy is where browsers differ most and where advice ages fastest. Safari applies time-based caps to script-written storage under its tracking-prevention policy, so a value that persists for months in Chrome may be gone in days there; embedded third-party contexts are storage-partitioned differently across browsers again. Never design a feature whose correctness depends on storage still being present after a given interval.
- SPEC-EVOLVINGThe Storage Standard's persistence and quota-estimate APIs, and the Storage Buckets proposal for per-bucket eviction policy, are still moving and are not uniformly implemented. Design so that eviction is survivable rather than depending on a persistence grant.
- FRAMEWORK-SPECIFICPersistence plugins hide the schema problem rather than solving it: Pinia's persisted-state plugin, redux-persist and equivalents serialise a store by default and leave versioning and migration to you, while SvelteKit and Angular server-render by default and so hit the storage-is-not-available-on-the-server case immediately. The plugin makes writing easy; nothing makes reading last month's shape easy.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Distributed Systems — persisted client state is a durable replica on a device you do not control, written by a version of your code that no longer exists. Schema evolution across independently upgrading replicas is exactly the problem, and versioning the payload is the same answer.
- — Testing & Reliability Engineering — the whole category is untestable against a fresh profile, so a returning-user fixture (write the old payload, deploy the new code, boot) has to be a deliberate part of the release process.