Storage

This section documents modern ECMAScript and core browser JavaScript APIs — it is not tied to any specific framework or library (React, Vue, Angular, etc.). This content was generated with the assistance of AI and should be verified against the current ECMAScript specification and MDN documentation before relying on it in production, since JavaScript language features and browser API support continue to evolve.

This section’s bibliography lists the reference material consulted while preparing these pages.

Web applications can store data on the user’s own device, giving an otherwise stateless page a memory across reloads, tabs, and visits. This client-side storage is always scoped by origin — pages from one site can never read data stored by another — and, because it lives unencrypted on the user’s device, it should never hold passwords, financial account numbers, or other sensitive information. Three mechanisms cover the large majority of client-side storage needs, in roughly the order they appeared: cookies, the Web Storage API (localStorage/sessionStorage), and IndexedDB.

Cookies

A cookie is a small named piece of data associated with a page or site, originally designed for server-side use: cookies are an extension to the HTTP protocol, and any cookie set for a URL is transmitted automatically to the server with every HTTP request to a matching URL, whether or not the server actually needs it for that request. The cookie property of Document makes cookies scriptable from the client, but the API predates modern JavaScript conventions and is awkward by design — there are no methods, only specially formatted strings read and written through a single property.

Reading and writing cookies

Reading document.cookie returns every cookie visible to the current document as one string, with name=value pairs separated by "; ". There’s no built-in parsing, so extracting individual values means splitting the string yourself:

// Return the document's cookies as a Map, assuming values were
// encoded with encodeURIComponent() when they were stored.
function getCookies() {
  let cookies = new Map();
  let all = document.cookie;
  for (let cookie of all.split("; ")) {
    if (!cookie.includes("=")) continue;
    let p = cookie.indexOf("=");
    let name = cookie.substring(0, p);
    let value = decodeURIComponent(cookie.substring(p + 1));
    cookies.set(name, value);
  }
  return cookies;
}

Writing to document.cookie doesn’t replace all cookies — it sets (or updates) just the one name=value pair you assign, leaving every other cookie for the document untouched:

document.cookie = `version=${encodeURIComponent(document.lastModified)}`;

Cookie values can’t contain semicolons, commas, or whitespace, which is why encodeURIComponent()/ decodeURIComponent() shows up on both ends above. Without a lifetime attribute, a cookie is session-only: it survives page loads but disappears when the browser is closed.

Attributes are appended to the same string used to set a cookie’s value, separated by semicolons:

// Store name/value as a cookie; daysToLive controls its max-age.
// Omit daysToLive (or pass null) for a session-only cookie; pass 0 to delete the cookie immediately.
function setCookie(name, value, daysToLive = null) {
  let cookie = `${name}=${encodeURIComponent(value)}`;
  if (daysToLive !== null) {
    cookie += `; max-age=${daysToLive * 60 * 60 * 24}`;
  }
  document.cookie = cookie;
}

max-age (in seconds) makes a cookie persistent across browser sessions instead of session-only. path and domain widen a cookie’s default scope — by default a cookie is visible only to the page that set it and pages below it in the same directory, but path=/ makes it visible sitewide, and domain=.example.com shares it across subdomains. secure restricts transmission to HTTPS connections. To delete a cookie, set it again with the same name/path/domain and max-age=0.

Browsers aren’t required to retain more than 300 cookies total, 20 per server, or 4 KB per cookie (name and value combined counting toward that 4 KB) — in practice browsers are more generous on count, but the 4 KB per-cookie ceiling is still commonly enforced.

Why the modern storage APIs largely replaced cookies

For data that’s only ever needed on the client, cookies have drawbacks that the modern APIs were designed to avoid:

  • Every request carries them. Because cookies ride along with every HTTP request to a matching URL, storing anything non-trivial in a cookie adds that much overhead to every network round trip, even for requests the server has no interest in that data for.

  • They’re tiny. The ~4 KB-per-cookie ceiling rules out anything beyond small, textual values.

  • The API is cryptic. There are no get/set/delete methods — just one property holding a semicolon-joined string that has to be parsed and rebuilt by hand, as shown above.

Cookies remain the right tool when the server needs to see the data (session identifiers, authentication tokens). For everything else — preferences, UI state, cached data — Web Storage or IndexedDB are simpler.

localStorage and sessionStorage (Web Storage)

The Web Storage API exposes two properties of Window — localStorage and sessionStorage — that each refer to a Storage object: a persistent, synchronous, string-keyed-to-string-valued map that behaves much like a plain JavaScript object.

Storage object basics

Properties can be read, written, and deleted using ordinary property syntax, for…​in/Object.keys(), or the equivalent getItem()/setItem()/removeItem() methods, and clear() wipes every property at once:

let name = localStorage.username;          // read a stored value
if (!name) {
  name = prompt("What is your name?");
  localStorage.username = name;             // write it back
}

localStorage.setItem("theme", "dark");      // equivalent to localStorage.theme = "dark"
localStorage.getItem("theme");              // "dark"
localStorage.removeItem("theme");
localStorage.clear();                       // remove everything

Storing non-string data

Every Storage value is a string — assigning a number or object coerces it to one, so anything else needs to be encoded on the way in and decoded on the way out. JSON.stringify()/JSON.parse() (see JSON) is the usual choice for structured data:

localStorage.x = 10;
let x = parseInt(localStorage.x);                     // numbers need parsing back

localStorage.data = JSON.stringify({ id: 1, tags: ["a", "b"] });
let data = JSON.parse(localStorage.data);              // objects/arrays round-trip through JSON

Lifetime and scope

Both objects are scoped to the document’s origin (protocol, host, and port) and, in practice, to the browser they were written in — data saved in one browser isn’t visible when the same site is opened in another. Beyond that, the two differ in how long their data lives:

localStorage sessionStorage

Lifetime

Permanent — survives reloads, tab closes, and browser restarts until explicitly deleted.

Tied to the top-level tab/window that wrote it — cleared when that tab is closed.

Sharing across tabs

Shared by every tab/window open to the same origin; each can read and overwrite the others' data.

Private to the tab/window that created it, even for another tab open to the exact same page.

Because localStorage is shared across every same-origin tab, it doubles as a cross-tab communication channel — one tab writing a preference can make every other open tab react to it, via the event described next.

The storage event

Whenever localStorage changes, the browser fires a storage event on every other Window that can see that data — never on the window that made the change. Registering a handler follows the usual pattern (see Events for addEventListener() details):

window.addEventListener("storage", (event) => {
  console.log(event.key, event.oldValue, "->", event.newValue);
});

The event carries key (the changed property, or null if clear() was called), newValue, oldValue, storageArea (the Storage object that changed), and url (the document that made the change). A common use is broadcasting a preference change — one tab stores it, and every other tab open to the same site reacts to the resulting storage event without any server round trip.

Size limits

There’s no standard-mandated quota, but browsers commonly cap each origin’s localStorage/sessionStorage at somewhere around 5-10 MB — generous next to a cookie’s 4 KB, but still not appropriate for large datasets, binary data, or anything that needs querying rather than key-based lookup. That’s the gap IndexedDB fills.

IndexedDB

IndexedDB is an asynchronous, transactional object database built into the browser. It’s origin-scoped like localStorage, but structurally very different: rather than flat string key/value pairs, it stores structured JavaScript values (via the structured clone algorithm, so objects can contain `Map`s, `Set`s, typed arrays, and so on — see Maps, Sets & WeakRefs) in named object stores, and it supports secondary indexes for querying by something other than the primary key. It comfortably handles far more data than Web Storage, at the cost of a considerably more involved, asynchronous API.

Databases, object stores, and transactions

An origin can have any number of IndexedDB databases, each with a name and a version number. A database holds one or more object stores; each stored object needs a key — either a property of the object designated as its keyPath, or one the database generates automatically — and keys must be unique and sortable within a store. All reads and writes happen inside a transaction, scoped to the object stores it needs and to either read-only or read-write access; IndexedDB commits a transaction automatically once every request on it has succeeded, with no explicit commit() call.

The API predates Promises, so it’s event-based rather than Promise-based: opening a database, and every read or write against a store, immediately returns a request object that later fires an onsuccess or onerror event (with the outcome, if any, on request.result). That doesn’t compose naturally with async/await — see Asynchronous JavaScript — so real code typically wraps each request in a new Promise((resolve, reject) ⇒ { …​ }) that resolves or rejects from those handlers, rather than juggling onsuccess/onerror callbacks directly.

Creating or upgrading a store’s schema can only happen in response to the special upgradeneeded event, fired the first time a database is opened, or when code opens it requesting a higher version than what’s stored.

A minimal worked example

The following opens (creating if necessary) a small database with one object store, then adds and retrieves a record:

function openDB() {
  return new Promise((resolve, reject) => {
    let request = indexedDB.open("notes-db", 1);   // name, version

    // Runs once, only when the database is first created or its version bumps.
    request.onupgradeneeded = () => {
      let db = request.result;
      db.createObjectStore("notes", { keyPath: "id" });
    };

    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

async function addNote(note) {
  let db = await openDB();
  let tx = db.transaction("notes", "readwrite");
  tx.objectStore("notes").add(note);          // { id: ..., text: ... }
  return new Promise((resolve, reject) => {
    tx.oncomplete = resolve;
    tx.onerror = () => reject(tx.error);
  });
}

async function getNote(id) {
  let db = await openDB();
  let tx = db.transaction("notes");           // read-only by default
  let request = tx.objectStore("notes").get(id);
  return new Promise((resolve, reject) => {
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

add() fails if a record with the same key already exists; put() is the same operation but overwrites silently. Beyond single-key lookups, an object store’s getAll()/openCursor() methods (and the equivalent methods on a named index created with createIndex()) support range queries and secondary-key lookups.

Choosing among the three

Cookies Web Storage IndexedDB

Access

Synchronous, one string property

Synchronous, key/value

Asynchronous, structured/transactional

Capacity

~4 KB per cookie

~5-10 MB per origin (typical)

Large — typically hundreds of MB or more, browser-dependent

Sent to server

Yes, with every matching request

No

No

Best for

Data the server needs (session/auth tokens)

Small key/value state — preferences, UI flags, simple caches

Large or structured client-side data, offline datasets, querying by index