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.
Cookie attributes: lifetime and scope
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/deletemethods — 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 |