Five APIs, five different jobs
The browser gives you five places to put data, and they are not interchangeable. Picking the wrong one is how you end up with a 4KB cookie sent on every request, a synchronous write blocking your render, or user data silently evicted the next time the disk gets tight.
Very briefly:
- IndexedDB — the real database. Async, indexed, large, holds structured values.
- LocalStorage — a small synchronous string map that survives restarts.
- SessionStorage — the same thing, scoped to one tab and gone when it closes.
- Cookies — tiny values the browser attaches to HTTP requests. The only one the server sees automatically.
- Cache Storage — whole HTTP responses, keyed by request. The offline story.
Side by side
| Property | IndexedDB | LocalStorage | SessionStorage | Cookies | Cache Storage |
|---|---|---|---|---|---|
| What it holds | Structured-clone values: objects, Date, Map, Set, Blob, ArrayBuffer | Strings only | Strings only | Strings only | Request and Response pairs |
| API shape | Asynchronous, transactional | Synchronous, blocking | Synchronous, blocking | Synchronous string parsing | Asynchronous, promise-based |
| Practical size | Large; a share of available disk | About 5MB per origin | About 5MB per origin | About 4KB per cookie | Large; shares the same origin budget |
| Lifetime | Until deleted or evicted | Until deleted or evicted | Until the tab closes | Until its expiry | Until deleted or evicted |
| Sent to the server | Yes, on every matching request | ||||
| Available in workers | Not directly | ||||
| Indexed lookup | Yes, on any key path | By request URL |
IndexedDB: the one that scales
IndexedDB is the only browser storage that behaves like a database. It is asynchronous, transactional, stores structured values without serialising to a string, and lets you define indexes so lookups do not degrade to a full scan.
It is also the most awkward API of the five, because it predates promises and is built on request objects with event handlers:
const open = indexedDB.open("myapp", 1);
open.onupgradeneeded = () => {
const db = open.result;
const store = db.createObjectStore("orders", { keyPath: "id" });
store.createIndex("by_status", "status");
store.createIndex("by_created", "createdAt");
};
open.onsuccess = () => {
const db = open.result;
const tx = db.transaction("orders", "readwrite");
tx.objectStore("orders").put({
id: "ord_1",
status: "pending",
total: 24999,
createdAt: new Date(), // stays a Date, no stringifying
});
tx.oncomplete = () => db.close();
};Reach for it when you have more than a few hundred records, when the data has shape worth querying, when you need it in a Service Worker, or when it must hold binary data. Most people use a wrapper such as Dexie or idb rather than the raw API, and that is a sensible default.
The catch: everything is async, so it cannot answer a question during a synchronous render. And it is subject to eviction, which is covered in the quotas post.
LocalStorage: small, synchronous, tempting
LocalStorage is a string-to-string map that persists across restarts. Its appeal is that it is three lines and needs no setup:
localStorage.setItem("theme", "dark");
localStorage.getItem("theme"); // "dark"
// Objects have to be stringified, with the usual losses
localStorage.setItem("user", JSON.stringify({ id: 1, seenAt: new Date() }));
JSON.parse(localStorage.getItem("user")).seenAt; // a string now, not a DateIts problem is the word synchronous. Every read and write happens on the main thread and blocks it. A few small keys is nothing. Parsing a megabyte of JSON out of LocalStorage on startup is a measurable stall in your load time, and it is a common cause of slow first renders.
Use it for genuinely small, genuinely synchronous needs: a theme preference, a sidebar collapsed state, a feature flag you must read before first paint. Once you are stringifying arrays of objects into it, you wanted IndexedDB.
Also note it is unavailable in Web Workers and Service Workers, so anything shared with a worker cannot live here.
SessionStorage: same API, shorter memory
SessionStorage has LocalStorage’s exact API and one difference that matters: its scope is a single tab. Open your site in two tabs and they get two independent stores. Close the tab and the data is gone. A page reload keeps it; a new tab does not inherit it.
That makes it the right home for state that should not leak between tabs or outlive the visit: a multi-step form’s progress, a scroll position to restore, a redirect target you are round-tripping through an auth flow. Using LocalStorage for those is how two tabs end up fighting over one checkout.
Cookies: the only ones the server sees
Cookies are the oldest mechanism and the only one that leaves the browser on its own. Every matching request carries them, which is exactly why they are the right tool for session identity and the wrong tool for everything else.
// Readable from JS only when HttpOnly is not set
document.cookie = "locale=en-GB; path=/; max-age=31536000; SameSite=Lax";
// Anything security-relevant should be set by the server instead:
// Set-Cookie: session=...; HttpOnly; Secure; SameSite=StrictThe size limit is roughly 4KB per cookie, and that budget is spent on every single request to the origin, including images and API calls. Storing a JSON blob in a cookie means paying for it on all of them.
A session token belongs in an HttpOnly cookie, set by the server, so JavaScript cannot read it. If your JS is reading an auth token out of document.cookie or LocalStorage, any script that gets injected into the page can read it too.
Cache Storage: responses, not values
Cache Storage is different in kind from the other four. It does not hold values, it holds HTTP Request and Response pairs. It exists so a Service Worker can answer a network request from disk:
const cache = await caches.open("assets-v3");
await cache.addAll(["/", "/app.css", "/app.js"]);
// In a Service Worker fetch handler: serve from cache, fall back to network
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then((hit) => hit ?? fetch(event.request)),
);
});Use it for the app shell, static assets and offline fallbacks. Do not use it as a general key-value store by inventing fake request URLs; that is IndexedDB’s job, and you lose indexing and querying by doing it.
The version-in-the-name convention above is load-bearing. Stale caches are a classic source of “it works after a hard refresh” bugs, and the fix is deleting old cache names during the Service Worker’s activate step.
A decision shortcut
- Does the server need it on every request? Cookie, set
HttpOnlyif it is sensitive. - Is it one small value you must read synchronously before paint? LocalStorage.
- Same, but it must not outlive the tab? SessionStorage.
- Is it an HTTP response you want to serve offline? Cache Storage.
- Anything else, and certainly anything you will query or that has more than trivial size: IndexedDB.
None of the five is a security boundary. All of it is readable and writable by the user and by any script running on the page, so validate on the server and store nothing there you would not want the user to see or change. That is also true of how easily IndexedDB values can be edited.
Inspecting all five
Chrome’s Application panel lists all five surfaces, which makes it the natural first stop. Its limitation is that each one is a separate read-only tree, so a bug that spans a cookie, a flag in LocalStorage and a record in IndexedDB means three views and no way to change anything in place.

IdxBeaver puts all five in one panel and makes each editable, with queries and exports over IndexedDB specifically. There is a comparison with the built-in panel if you want the specifics, and one against the other IndexedDB extensions.
Related reading
- Browser storage quotas explained — the limits and eviction rules behind the size column above.
- Debugging IndexedDB in Chrome DevTools — the inspection workflow in detail.
- Exporting IndexedDB data — getting data out without losing types.