DEV Community

Cover image for Stop Regex-Parsing document.cookie. Use CookieStore
Parsa Jiravand
Parsa Jiravand

Posted on Originally published at bestpractic.org

Stop Regex-Parsing document.cookie. Use CookieStore

Open any codebase old enough to have cookies in it and grep for document.cookie. You will find a function that looks like this, written by someone who is no longer at the company:

function getCookie(name) {
  const match = document.cookie.match(
    new RegExp("(^| )" + name + "=([^;]+)")
  );
  return match ? decodeURIComponent(match[2]) : null;
}
Enter fullscreen mode Exit fullscreen mode

It works. It has worked since 2011. It also has a small, well-known list of ways to get it wrong — cookie names that are prefixes of each other, values with unescaped = or ;, whitespace after the semicolon depending on which browser wrote the header. Everyone's seen at least one of these bugs. Nobody rewrites the function, because it's not broken today.

Here's the harder problem that regex can't fix at all: you have no way to know when a cookie changes. Not from another tab. Not from a Set-Cookie header on a fetch() response. Not even from a second script on your own page calling document.cookie = ... a moment after yours did. document.cookie is a plain string property. Reading it tells you the current state. It has never told you when the state moved.

What everyone reaches for instead

Once the "I need to react to cookie changes" requirement shows up — a login cookie set by an API call, a consent banner another tab just dismissed — the usual fixes are:

  • Poll document.cookie on an interval. It works, in the sense that a setInterval checking a string every 500ms will eventually notice a change. It also means every tab of every user is now diffing a string forever for an event that might happen once a session.
  • Have the server tell the client via a WebSocket or SSE. Real infrastructure for a problem that's purely local — the cookie already changed on this machine, in this browser, you just don't have a hook for it.
  • Wrap every single place that writes a cookie in your own pub/sub. This can work, right up until a third-party script, a Set-Cookie response header, or literally the browser's own cookie-jar expiry logic changes a cookie your pub/sub doesn't know about.

All three treat "the browser won't tell me" as something to engineer around. It's worth asking why the browser won't tell you in the first place — and it turns out, more recently, it will.

The API that actually does this: cookieStore

Chrome and Edge ship window.cookieStore (and self.cookieStore inside a service worker) — a promise-based Cookie Store API that treats cookies as structured objects instead of one string you serialize by hand.

Reading is no longer a regex:

const session = await cookieStore.get("session_id");
// { name: "session_id", value: "abc123", domain: null, path: "/", ... } or null

const all = await cookieStore.getAll();
// array of every cookie visible to this document, already parsed
Enter fullscreen mode Exit fullscreen mode

Writing takes an object instead of a hand-built key=value; path=...; expires=... string:

await cookieStore.set({
  name: "theme",
  value: "dark",
  expires: Date.now() + 1000 * 60 * 60 * 24 * 30, // 30 days, in ms
  path: "/",
});

await cookieStore.delete("theme");
Enter fullscreen mode Exit fullscreen mode

And the part document.cookie could never do — a real event:

cookieStore.addEventListener("change", (event) => {
  for (const cookie of event.changed) {
    console.log("set:", cookie.name, cookie.value);
  }
  for (const cookie of event.deleted) {
    console.log("deleted:", cookie.name);
  }
});
Enter fullscreen mode Exit fullscreen mode

That listener fires for cookies your page sets, cookies a fetch() response set via Set-Cookie, and cookies removed by expiry — no polling, no pub/sub you wrote yourself. A consent banner that gets dismissed in one tab can now update every other open tab of the same origin the moment it happens.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

The default that's stricter than you're used to

Here's the thing that actually catches people moving existing code over, and it's not a bug — it's a deliberate spec choice that reads like a footnote until it breaks something.

When you write a cookie the old way, through a Set-Cookie header or document.cookie, and you don't specify SameSite, browsers default it to Lax. That's been true for years — it's why a cookie set on your site still rides along when a user clicks a plain link to your site from somewhere else, but doesn't get sent on a cross-site POST.

cookieStore.set() doesn't inherit that default. Per the spec, if you don't pass sameSite explicitly, it defaults to "strict" — stricter than what document.cookie gives you for free. A Strict cookie is withheld on any cross-site navigation, top-level link clicks included.

So the failure mode looks like this: you migrate a cookie-setting line from document.cookie = "..." to cookieStore.set({...}), run your test suite, ship it. Everything that happens inside your own site keeps working, because same-site requests don't care about SameSite at all. Weeks later, someone clicks a link to your site from an email or a partner site, lands on a page that expects that cookie to already be there, and it isn't. No error. No console warning. The cookie you set is simply not attached to that request, because Strict said not to.

The fix is one keyword, once you know to look for it:

await cookieStore.set({
  name: "session_id",
  value: token,
  sameSite: "lax", // match what document.cookie would have given you
});
Enter fullscreen mode Exit fullscreen mode

Two things worth knowing before you reach for it

  • It's Chromium-only right now. Chrome and Edge support cookieStore; Firefox and Safari don't ship it as of this writing. Check the current numbers on caniuse before you rely on it for anything that isn't wrapped in a feature check — if ("cookieStore" in window) — with a document.cookie fallback.
  • It requires a secure context. Like most newer, more capable browser APIs, cookieStore simply isn't there on plain http:// origins outside localhost. If it's undefined in production but present when you test locally, that's almost certainly why.

The takeaway

document.cookie was never designed to be parsed — it's a string interface bolted onto a feature that predates JSON.parse existing. cookieStore treats cookies as the structured, awaitable, observable data they actually are, and the change event alone is worth the migration for anything that needs to react to a cookie set outside your own code. Just don't let sameSite default silently to something stricter than the behavior you were relying on.

Does your codebase still have a hand-rolled cookie parser in it? How old is it, and does anyone remember writing it?

🧠 Test yourself

Think it clicked? Take the 7-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Top comments (1)

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen

The expiry branch of that listener claim did not hold when I measured it in Chrome 152. On a 127.0.0.1 origin I set a cookie with expires: Date.now() + 3000, attached only the change listener, and left the page idle: 25 seconds later, zero events. In a separate run on the same origin the deleted event for the expired cookie did arrive, but at 10.68 seconds, in the same moment as the next cookieStore.get and roughly six seconds after the cookie was already unreadable, so the jar looks like it is reconciled on access rather than on a timer and the notification is a side effect of the next read. The other two branches held exactly as written: a plain document.cookie = ... write fired change with the parsed value, and cookieStore.set with no sameSite came back from get as sameSite: "strict" alongside secure: true. That leaves expiry as the one transition you still cannot wait for, so a page reacting to a session cookie ageing out needs its own timer keyed off the expires field from get, which is at least a field the regex version never had.