Browser storage is not one interchangeable bucket with several APIs. A cookie is request metadata managed under HTTP rules. IndexedDB is an asynchronous transactional database for application records. The Cache API stores request-response pairs for replay. Choosing by familiarity or a remembered capacity limit ignores the properties that decide whether data remains correct and safe.
The useful design question is not “where can this value fit?” It is “what role does this data play, who is authoritative, and what must happen when it is unavailable?” Authentication state, a draft document, an offline mutation, and a cached image have different owners and recovery rules even if each can be serialized as a string.
This article develops a decision framework around lifetime, transactions, eviction, partitioning, security, and synchronization. The goal is resilient behavior when storage is constrained, cleared, duplicated across tabs, or temporarily disconnected from the server, not merely success in one browser session.
Classify Data Before Choosing an API
Start by writing a storage contract for each data class. Include its source of truth, sensitivity, required lifetime, maximum plausible size and count, update pattern, atomicity needs, and recovery behavior. The contract should answer whether losing the local copy is an inconvenience, a retriable failure, or user-visible data loss.
A useful classification is:
| Data role | Example | Authority | Local-loss response |
|---|---|---|---|
| Request credential | Opaque server session identifier | Server | Reauthenticate |
| Preference | Theme or compact-density choice | User profile or local device | Restore default or resync |
| Durable local work | Unsynced draft | Browser until acknowledged | Warn, retry, export, or recover |
| Offline operation | Comment waiting to upload | Server after accepted mutation | Retry idempotently |
| Derived record | Search index over saved articles | Rebuildable source data | Recompute |
| HTTP representation | Article response and image | Origin server | Refetch when online |
Do not persist data just because it might be useful later. Persistence adds migration, privacy, retention, deletion, and synchronization obligations. A transient UI selection often belongs in memory. A URL can be the durable owner of shareable navigation state. Server-managed profile data may need only a bounded local cache.
Define failure semantics explicitly. If storage writes fail because the quota is exhausted or the context is restricted, can the action continue online? If the application claims offline safety, when does it tell the user that a draft is durably stored? A success message shown before the transaction commits is a false promise.
Capacity figures should be treated as runtime policy rather than constants. Browser, device, available disk, origin engagement, private mode, and embedding context can all affect quotas and eviction. Estimate usage with navigator.storage.estimate(), but design for both values to be approximate and for writes still to fail.
Use Cookies Only When HTTP Needs the State
Cookies are attached to matching HTTP requests according to domain, path, security, same-site, partitioning, and expiry rules. That automatic transport is their defining feature and their cost: every matching request can carry the bytes, even when the response does not need them. Keep cookies small and few, and scope them to the narrowest useful host and path.
An authentication cookie should usually contain an opaque session identifier rather than profile data or a large self-contained state object. A server can set a hardened cookie such as:
Set-Cookie: __Host-session=opaque-value; Path=/; Secure; HttpOnly; SameSite=Lax
The __Host- prefix requires a secure cookie with Path=/ and no Domain, preventing broader subdomain scope. HttpOnly prevents ordinary JavaScript access, which limits token theft through an injected script, although that script can still issue authenticated actions from the page. Secure restricts transport to secure contexts. SameSite reduces some cross-site request contexts; it is defense in depth, not a complete CSRF strategy for every workflow.
State-changing endpoints still need deliberate request-forgery protection, origin checks where appropriate, and authorization. Cookie confidentiality does not stop cross-site submission, session fixation, server-side leakage, or an attacker controlling the application origin. Rotate sessions after privilege changes and revoke them server-side.
Expiry expresses retention, not guaranteed survival. Users and browsers can clear cookies early. Session-cookie restoration also varies. Conversely, a long Max-Age can retain identifiers beyond product need. Make deletion part of logout and account-erasure paths, while recognizing that the server session must be invalidated independently.
Third-party cookie behavior is increasingly restricted or partitioned. A Partitioned cookie is keyed by the top-level site in addition to the cookie’s origin, allowing an embedded service to keep separate state per embedding site where supported. That is useful for isolation, but it does not create a shared identity across sites. Embedded experiences should expect denied access, partitioned state, and explicit user-mediated access mechanisms rather than assuming the first-party behavior of a top-level page.
Cookies are a poor home for cached responses, bulk preferences, drafts, or operation queues. Their string format, request overhead, and coarse update model do not become database semantics merely because JSON can be encoded into them.
Use IndexedDB for Transactional Application Records
IndexedDB stores structured-clone values in object stores, supports indexes and key ranges, and groups reads and writes into transactions. It fits collections such as drafts, downloaded article metadata, operation queues, and rebuildable local indexes. Its asynchronous API avoids the synchronous main-thread blocking associated with Web Storage, but application code still needs careful transaction boundaries.
Schema changes occur during a version upgrade. Create or delete stores and indexes in the upgrade transaction, and keep migrations deterministic. Another tab with an older connection can block the upgrade, so listen for versionchange, close old connections, and show a bounded “refresh another tab” recovery state rather than leaving startup pending forever.
The following queue writes an offline operation and updates its document atomically:
type PendingEdit = {
operationId: string;
documentId: string;
baseVersion: number;
body: string;
createdAt: string;
};
function enqueueEdit(db: IDBDatabase, edit: PendingEdit): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['documents', 'outbox'], 'readwrite');
transaction.objectStore('documents').put({
id: edit.documentId,
body: edit.body,
syncState: 'pending',
baseVersion: edit.baseVersion,
});
transaction.objectStore('outbox').add(edit);
transaction.oncomplete = () => resolve();
transaction.onabort = () => reject(transaction.error);
transaction.onerror = () => reject(transaction.error);
});
}
The UI should announce durable local success only after oncomplete. A failed transaction rolls back both stores, so the document cannot claim pending without a corresponding operation. Use a unique operationId that the server accepts as an idempotency key; browser retries alone cannot guarantee exactly-once delivery.
Avoid awaiting arbitrary network or timer work inside an active transaction. IndexedDB transactions become inactive and auto-commit when no requests keep them active and control returns to the event loop. Perform remote work outside, then open a short transaction to record the result. Short transactions also reduce contention across tabs.
Indexes speed reads but add write cost and storage. Normalize values used as keys, put a schema version inside complex records when useful, and test upgrades with data from every supported prior version. Structured clone supports many JavaScript values, but class prototypes and application invariants do not magically survive; decode records through an explicit validation boundary.
Use the Cache API for Replayable Request-Response Pairs
The Cache API maps Request objects to Response objects. It is appropriate when the unit being stored is an HTTP representation: an article document, API response, stylesheet, script, image, or font. It can be used from a window as well as a service worker, though service-worker interception is what enables controlled offline response behavior.
Cache storage is separate from the browser’s ordinary HTTP cache. Calling cache.put() explicitly stores a response; it does not automatically implement freshness, revalidation, or application-specific authorization. Cache-Control headers remain valuable metadata and affect normal HTTP caching, but a custom Cache API strategy must decide when to match, update, and delete entries.
Request identity matters. Method, URL including query parameters, and Vary behavior affect matching. Cache only GET responses under deliberate keys. Avoid ignoring search parameters or Vary unless the application can prove those dimensions do not change representation. Never place one user’s private response under a key another signed-in user can receive.
A bounded stale-while-revalidate helper can return a saved article and update it in the background:
const ARTICLE_CACHE = 'articles-v3';
async function articleResponse(request: Request): Promise<Response> {
const cache = await caches.open(ARTICLE_CACHE);
const cached = await cache.match(request);
const update = fetch(request).then(async (response) => {
if (response.ok && response.type === 'basic') {
await cache.put(request, response.clone());
}
return response;
});
if (cached) {
void update.catch(() => undefined);
return cached;
}
return update;
}
This is a mechanism, not a complete policy. It needs a service-worker lifetime hook when used during interception, cache retention bounds, an offline fallback, and product rules for acceptable staleness. Opaque cross-origin responses offer limited visibility and can consume meaningful quota; cache them only when their lifecycle is understood.
Version cache names by compatibility, not every deployment by reflex. During activation, delete caches no longer referenced by any supported worker. Do not delete a cache still used by an older active worker controlling another tab. Worker update and cache migration are a distributed-version problem within one browser profile.
Work an Offline Reading and Editing Design
Consider a reading application that lets a user save articles, annotate them offline, and sync edits later. The server remains authoritative for published article content and accepted annotations. The browser must preserve unsynced work and should make previously saved reading available without a network.
Use the three stores by role:
- A hardened cookie carries the opaque session on same-site API requests. No article or draft data lives in it.
- The Cache API stores successful article and image responses under canonical request URLs. A manifest in IndexedDB records which article versions are intentionally saved.
- IndexedDB stores annotations, local document state, and an outbox of immutable operations with operation IDs and base versions.
When the user selects “save offline,” fetch the required responses, validate success and content type, put them in a staging cache, and write the saved-version manifest. Only then expose the article as available offline. A failed image need not invalidate text if the product defines a partial state, but the UI must represent that state honestly.
When an offline annotation changes, one IndexedDB transaction updates the local annotation and appends an outbox operation. The interface displays saved on this device, not synced. A connectivity event can prompt a sync attempt, but it is not proof of internet reachability; foreground entry and explicit retry should also drain the queue.
The sync worker sends the oldest eligible operation with its idempotency key and base server version. An acknowledgement transaction removes the outbox item and records the new version. A conflict response preserves both the local operation and server state for deterministic merge or user resolution. Last-write-wins based only on device clocks risks silent loss and should not be an accidental default.
Logout requires a product decision. Shared-device security may require deleting private IndexedDB records and Cache API entries immediately while invalidating the server session. An offline-first product that promises recoverable drafts may instead require an explicit warning and export before destructive logout. Storage APIs cannot decide this policy.
Plan for Quota, Eviction, and Privacy Partitioning
Origin storage is commonly best-effort. Under pressure, a browser may evict data according to its policy. navigator.storage.persist() can request persistent treatment in supporting contexts, but approval is not guaranteed and does not protect against user clearing, private browsing teardown, corruption, or application bugs. A local copy without server acknowledgement must therefore remain visible as at risk and should have an export or recovery path when its value warrants one.
Set application budgets below estimated quota. Track bytes indirectly through known payload sizes or record counts, and prune by policy: old derived indexes first, replaceable responses next, user-created unsynced data last. Cache API and IndexedDB may draw from a shared origin quota, so an unbounded media cache can prevent a critical draft transaction.
Storage is scoped by origin and increasingly partitioned by top-level site for embedded contexts. Two subdomains are different origins unless architecture deliberately centralizes data through a server boundary. An iframe may see storage isolated per embedding site, denied, or subject to access prompts. Test the actual top-level and embedded configurations in supported browsers; a first-party localhost test does not establish third-party behavior.
Do not use fingerprint-like identifiers to work around privacy controls. Minimize retained data, describe its purpose, honor consent and deletion requirements, and set retention based on product need. Storage telemetry should report coarse counts and failure categories without copying private values into logs.
Secure and Synchronize Every Durable Boundary
Any script executing in the origin can generally access that origin’s IndexedDB and Cache API data. Content Security Policy, dependency control, output encoding, and injection prevention therefore protect local records as much as network responses. Encrypting data with a key delivered to the same compromised script does not solve an active cross-site scripting attack, though platform-backed cryptography can still support narrower threat models.
Treat records read from storage as untrusted versioned input. Validate shape, constrain lengths, and handle unknown versions. Clear or migrate incompatible derived data. For sensitive offline content, assess whether storing it at all is appropriate on shared devices and whether the server can revoke access once a response has been cached locally.
Cross-tab synchronization needs an explicit model. IndexedDB transactions serialize overlapping writes, but they do not resolve business conflicts. Use BroadcastChannel or another notification mechanism to tell peers that committed state changed, then reread from the database. Notifications can be lost across process suspension, so startup must always reconcile from durable records rather than assuming every message was observed.
Test failure paths in real browsers: denied or unavailable storage, quota exhaustion, transaction abort, blocked database upgrade, two tabs editing one record, worker update during use, stale cached authorization, user clearing site data, offline reload, reconnect with duplicate operations, conflict responses, and logout. Instrument counts for write failures, blocked upgrades, outbox age, retry classifications, cache hit by policy, and storage estimates. Avoid logging record contents.
The final decision is role-based. Use cookies when the server needs small state on matching HTTP requests. Use IndexedDB when the browser needs indexed records and atomic local changes. Use Cache API when the stored unit is a request and its replayable response. It is normal for a robust offline feature to use all three, but each should have one clear responsibility, a bounded retention policy, and a tested answer for loss. Browser persistence is reliable only when the application assumes it can fail.