Cookies will definitely take you for a spin if you haven’t used them much. But before we get into the nitty-gritty, you need to know why you would reach for cookies in the first place.

Other Options

You have plenty:

  • localStorage
  • sessionStorage
  • IndexedDB
  • The Cache API

Or better still: this thing called the server.

If you can, first try storing what you need on the server. Do you have access to one? Do you control the whole stack? Then seriously ask: why can’t I store this on the server?

If you can reach for Cloudflare Workers, or a function on Netlify or Vercel, you have a server. You need no queue. You need no bucket. You need no managed anything. You need 1 function and a key.

Consider yourself warned.

When You Need One

3 cases. Everything else varies one of them.

1. The server has to see it on every request.

The big one, and the thing that makes a cookie a cookie rather than a slot in localStorage. A session token has to arrive with the request that renders the page, before any javascript runs. localStorage cannot manage that. Reading it needs a script, and by then the server already replied.

2. You integrate with a locked-down platform.

Somebody else’s widget. Somebody else’s checkout. Somebody else’s SSO. You get a cookie because they hand you one.

3. It has to survive a hard reload.

sessionStorage dies with the tab. A cookie with an Expires outlives it.

Notice what fell off that list: preferences. Your dark mode toggle needs no cookie unless the server renders the theme, and in 2025 it shouldn’t, because prefers-color-scheme and light-dark() handle it without asking anyone.

The API

Here you go:

document.cookie = "theme=dark; Path=/; Max-Age=31536000; SameSite=Lax";

An assignment that appends instead of replacing. Read it back and you get every cookie for the document as 1 string, semicolons and all, with no way to ask for a single value:

document.cookie; // "session=abc123; theme=dark; consent=1"

You get no delete either. To remove one you set it again with an expiry in the past, and the Path and Domain have to match exactly or you just made a second cookie instead of deleting the first:

document.cookie = "theme=; Path=/; Max-Age=0";

Which explains the most common cookie bug I see. 2 cookies, same name, different paths, one shadowing the other, and a delete that appears to do nothing.

A modern replacement exists: the Cookie Store API. Asynchronous, returns objects, gives you an actual delete():

/**
 * Reads a cookie by name, whichever API the browser offers.
 *
 * @param {string} name what the cookie goes by
 * @returns {Promise<string | undefined>} its value, when the browser holds one
 */
const readCookie = async function (name) {
	let value;
	if (globalThis.cookieStore === undefined) {
		const found = document.cookie.split("; ").find(function matchesName(pair) {
			return pair.startsWith(`${name}=`);
		});
		value = found?.slice(name.length + 1);
	} else {
		value = (await globalThis.cookieStore.get(name))?.value;
	}
	return value;
};

Chromium and Firefox ship it. Safari does not, as I write this. Feature-detect and keep the string version as your fallback.

The Attributes

Set-Cookie: session=abc123; Path=/; Max-Age=3600; Secure; HttpOnly; SameSite=Lax

HttpOnly keeps javascript from reading it. On a session token you cannot skip this. It separates an XSS bug that embarrasses you from an XSS bug that hands over accounts. It also means you cannot set the cookie from document.cookie. Only the server can.

Secure means HTTPS only. Put it on everything. localhost gets an exemption, so it stays out of your way while you work.

SameSite decides whether the cookie travels on requests coming from other sites. Lax now applies by default and suits almost everyone: along for a top-level navigation, absent from a form post or an image request on somebody else’s page. Strict suits the small set of things that should not survive arriving from an external link at all. None sends it everywhere and requires Secure. Browsers reject SameSite=None without it, silently.

Path scopes it. Almost always /. Anything else gives you the shadowing problem above.

Max-Age in seconds beats Expires as a date, because it does not care whether the client’s clock runs true. Neither one means “forever”. Safari caps script-set cookies at 7 days, and shorter in some cases.

Partitioned arrived most recently. Separate cookie jar per top-level site. If you build the embedded thing, a widget or a player or a support chat, this keeps you working in a browser that stopped handing out cross-site cookies.

Size Limits

Roughly 4KB per cookie. Somewhere around 180 per domain.

Both limits fail the same way: nothing happens. No exception. No rejected promise. You assign to document.cookie, read it back, and your value never arrived.

Worse, every cookie in scope goes out on every request to that domain. Every stylesheet. Every script. Every image. Put 3KB of JSON in a cookie and you added 3KB to a few 100 requests, invisible in every profiler that shows you response sizes.

About to store an object? Don’t. Store an id and keep the object on the server.

Third-Party Cookies

You have read that third-party cookies will die. The truth in 2025 reads messier. Safari and Firefox have blocked them by default for years, and Chrome, after announcing several deadlines, reversed course in 2024 and left them in place behind a user choice rather than removing them.

None of which changes the answer if you build an embedded thing. Depend on no cross-site cookie. Reach for Partitioned when you need per-site state, and design for getting nothing at all. 2 of the 3 engines have handed you nothing for years already.