JAVASCRIPT / THE BROWSER: DOM, EVENTS, AND STORAGE
Cookies, document.cookie, and their modern replacements
Read, write, and delete cookies with document.cookie, know why each write touches one cookie, and choose localStorage or cookieStore when it fits.
What you will learn
- Parse document.cookie into an object and decode percent-encoded names and values
- Set, replace, and expire a cookie using path, max-age, and samesite attributes
- Explain why every cookie byte is uploaded with every matching request
- Move client-only state to localStorage, JSON, or cookieStore's async API
Understanding Cookies, document.cookie, and their modern replacements
document.cookie looks like a string property, but it is a pair of accessors over the browser's cookie jar. Reading it returns only the name=value pairs of cookies matching the current document's origin and path that are not marked HttpOnly, joined with "; " and stripped of every attribute, so you cannot ask a cookie when it expires or which path it belongs to. Writing is not assignment either: the browser parses the string you assign for one name=value pair plus attributes and applies it as a single instruction, leaving every other cookie alone. That is also why there is no delete method; you re-set the same name with an expiry in the past and the jar drops it.
A cookie's identity is the triple name, domain, path, and that explains most of the confusion. Setting theme=light with path=/ replaces theme=dark only if the earlier cookie also had path=/; with a different path you end up with two cookies of the same name, both sent, and a read shows two theme entries with the longer path first. Deletion follows the same rule, so a max-age=0 write with the wrong path quietly creates and expires a different cookie while the real one survives and keeps returning stale data.
Cookies are a transport, not storage: every matching request, including images and fetch calls, carries them, which is why the per-cookie limit is about 4 KB and why a fat cookie taxes every asset load. Split the job by who needs the data. Session tokens belong in cookies the server sets with HttpOnly, Secure, and SameSite so that document.cookie, and therefore any injected script, cannot read them; purely client-side state belongs in localStorage or sessionStorage as JSON, and bulk structured data in IndexedDB. When JavaScript genuinely needs a cookie, the Cookie Store API offers promise-based get, set, and delete plus change events, and it works inside service workers where document does not exist.
// Run in a page served from http://localhost or https:// (not file://)
function setCookie(name, value, options = {}) {
let str = encodeURIComponent(name) + "=" + encodeURIComponent(value);
str += "; path=" + (options.path ?? "/");
if (options.maxAge !== undefined) str += "; max-age=" + options.maxAge;
str += "; samesite=lax";
document.cookie = str; // one assignment touches exactly one cookie
}
function readCookies() {
const jar = {};
for (const pair of document.cookie.split("; ")) {
if (!pair) continue;
const i = pair.indexOf("=");
jar[decodeURIComponent(pair.slice(0, i))] = decodeURIComponent(pair.slice(i + 1));
}
return jar;
}
setCookie("theme", "dark solarized", { maxAge: 600 });
setCookie("lastPage", "/docs?tab=api", { maxAge: 600 });
console.log(document.cookie.includes("theme=dark%20solarized"));
console.log(readCookies().theme);
console.log(readCookies().lastPage);
setCookie("theme", "light", { maxAge: 600 }); // same name + path replaces
console.log(readCookies().theme, readCookies().lastPage);
setCookie("lastPage", "", { maxAge: 0 }); // expire it, identical path
console.log("lastPage" in readCookies());document.cookie is a synchronous, string-shaped command interface to a network transport keyed by name plus domain plus path, not a storage API for client-only data.
Worked examples
The Cookie Store API
Shows the promise-based replacement for string concatenation and manual parsing.
// Chromium-based browsers, secure context; feature-detect with "cookieStore" in window
(async () => {
await cookieStore.set({
name: "cart",
value: "3-items",
path: "/",
sameSite: "lax",
expires: Date.now() + 10 * 60 * 1000,
});
const cookie = await cookieStore.get("cart");
console.log(cookie.name, cookie.value, cookie.path);
console.log(document.cookie.includes("cart=3-items"));
await cookieStore.delete("cart");
console.log(await cookieStore.get("cart"));
})();Example explained
Line 1cookieStore.set takes attributes as object fields, so nothing depends on getting semicolons and spaces right.
Line 2expires is a millisecond epoch timestamp; omit it and you get a session cookie that dies with the browser session.
Line 3cookieStore.get resolves to an object with the attributes document.cookie throws away, or to null when the cookie is absent.
Line 4The document.cookie check proves both APIs read the same jar; cookieStore is a different door, not a different store.
Client-only state in localStorage
Demonstrates storing preferences that the server never needs, and the string-only trap.
// Fresh page, no cookies set yet
localStorage.setItem("prefs", JSON.stringify({ theme: "dark", fontSize: 16 }));
console.log(localStorage.getItem("prefs"));
console.log(typeof localStorage.getItem("prefs"));
const prefs = JSON.parse(localStorage.getItem("prefs"));
console.log(prefs.fontSize + 2);
localStorage.setItem("visits", 1);
console.log(localStorage.getItem("visits") + 1);
console.log(Number(localStorage.getItem("visits")) + 1);
console.log(document.cookie.includes("prefs"));
localStorage.removeItem("prefs");
localStorage.removeItem("visits");
console.log(localStorage.getItem("prefs"));Example explained
Line 1setItem coerces its value to a string, so objects must go through JSON.stringify and come back through JSON.parse.
Line 2"1" + 1 concatenates to "11" because getItem always hands back a string; wrap it in Number before doing arithmetic.
Line 3The cookie check is false: localStorage lives in a separate per-origin store and is never attached to a request.
Line 4getItem returns null, not undefined, for a key that was removed or never written.
Why values need encoding
Shows a semicolon inside a cookie value silently truncating it, and the encoded version surviving.
document.cookie = "raw=a;b=c; path=/";
document.cookie = "safe=" + encodeURIComponent("a;b=c") + "; path=/";
const jar = Object.fromEntries(
document.cookie.split("; ").map((p) => {
const i = p.indexOf("=");
return [p.slice(0, i), decodeURIComponent(p.slice(i + 1))];
})
);
console.log(jar.raw);
console.log(jar.safe);
console.log(document.cookie.includes("safe=a%3Bb%3Dc"));
document.cookie = "raw=; max-age=0; path=/";
document.cookie = "safe=; max-age=0; path=/";
console.log(document.cookie.includes("raw="));Example explained
Line 1In the first write the browser stops the value at the semicolon and reads b=c as an unknown attribute, which it ignores, so only raw=a is stored.
Line 2encodeURIComponent turns ; and = into %3B and %3D, keeping the whole value inside one token.
Line 3Splitting at the first "=" matters, because an encoded value can still contain characters the naive split("=") would break on.
Line 4Removing both cookies needs a second write per name with max-age=0 and the same path=/ used to create them.
Important notes
HttpOnly cookies never appear in document.cookie or cookieStore, so an empty read does not mean the page has no cookies.
decodeURIComponent throws a URIError on a value that was not percent-encoded, such as one containing a stray %, and document.cookie does nothing on file:// or inside an opaque origin like a sandboxed iframe.
Common mistakes
Expecting document.cookie = "" or a fresh string to replace the jar; writes never remove anything, so old cookies remain and keep being uploaded.
Expiring a cookie with max-age=0 but a different path than it was created with, which creates and kills an unrelated cookie while the original keeps returning the stale value.
Writing a value that contains ; or = or a space without encoding it, so the browser parses the tail as attributes and the value comes back truncated.
Try it yourself
Change, predict, then run
On a page served from http://localhost, store a lang preference in a cookie with path=/ and max-age of one year, store a bannerDismissed flag in localStorage, then log document.cookie and confirm lang appears there while bannerDismissed does not. Finish by expiring the lang cookie and verifying it disappears from the read.
Open the JavaScript workspaceCheck your understanding
A page with no cookies runs document.cookie = "a=1; path=/", then document.cookie = "b=2; path=/", then document.cookie = "". What does the next read of document.cookie return?
- "a=1; b=2", because a write never removes cookies and an empty string names none
- "", because assigning a string replaces the entire cookie jar
- "b=2", because each write overwrites the cookie set before it
- A TypeError, because "" is not a valid cookie string
Show answer
The setter parses the assigned string for a single name=value pair plus attributes; an empty string contains no pair, so the write is ignored and both cookies survive. Option two is tempting because reading yields a string and the syntax looks like ordinary property assignment, but the getter serializes the jar while the setter applies one cookie instruction, and removal only happens through an expires or max-age attribute.