Skip to main content

Browser storage types explained

September 9, 20269 min read

IndexedDB, LocalStorage, SessionStorage, Cookies, and Cache Storage each solve a different problem. What each is for, and the limits that matter.

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

PropertyIndexedDBLocalStorageSessionStorageCookiesCache Storage
What it holdsStructured-clone values: objects, Date, Map, Set, Blob, ArrayBufferStrings onlyStrings onlyStrings onlyRequest and Response pairs
API shapeAsynchronous, transactionalSynchronous, blockingSynchronous, blockingSynchronous string parsingAsynchronous, promise-based
Practical sizeLarge; a share of available diskAbout 5MB per originAbout 5MB per originAbout 4KB per cookieLarge; shares the same origin budget
LifetimeUntil deleted or evictedUntil deleted or evictedUntil the tab closesUntil its expiryUntil deleted or evicted
Sent to the serverYes, on every matching request
Available in workersNot directly
Indexed lookupYes, on any key pathBy 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 Date

Its 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=Strict

The 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 HttpOnly if 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 showing browser storage surfaces in one Chrome DevTools panel with an editable data grid
IndexedDB, LocalStorage, SessionStorage, Cookies and Cache Storage in one editable panel.

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


← All posts