Mert Özen Further With Every Line
Frontend JavaScript Intermediate — #15

JavaScript Web Storage and Cookies: localStorage, sessionStorage and document.cookie

Mert Özen Nov 3, 2026 9 min 4 views
JavaScript Web Storage and Cookies: localStorage, sessionStorage and document.cookie

We cover localStorage vs sessionStorage, storing objects with JSON, the cross-tab storage event, quota and security limits, cookie attributes through document.cookie, and why HttpOnly cookies stay invisible.

Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).

Predict First

Open the console on any page and look at these lines. We store a number and an object, then read them back:

localStorage.setItem("count", 5);
const count = localStorage.getItem("count");
console.log(count + 1);

localStorage.setItem("user", { name: "Ada" });
console.log(localStorage.getItem("user"));

localStorage.removeItem("count");
localStorage.removeItem("user");

What do the two console.log calls print? Make a guess before you read on. Hint: if you said 6 and { name: "Ada" }, the third section of this post was written for you.

The Browser's Little Cupboards

When you reload the page, all your variables are reset. The theme the user picked, a half-written post, the items in the cart, all gone. That's why Web Storage exists: a small key-value store the browser sets aside for each site. It comes in two flavors, and both have exactly the same API.

Think of localStorage as a cupboard in your home. Whatever you put in stays there until you or the user take it out, even if you close the browser and open it again a week later. sessionStorage is more like the safe in a hotel room. It does its job while you're staying there, and its contents survive a page reload, but it's emptied when you close the tab. On top of that, every tab gets its own safe; open the same site in two tabs and you have two separate sessionStorage areas.

localStorage.setItem("theme", "dark");   // write
localStorage.getItem("theme");           // "dark"
localStorage.getItem("missing");         // null
localStorage.length;                     // number of entries
localStorage.key(0);                     // name of the key at index 0
localStorage.removeItem("theme");        // delete one entry
localStorage.clear();                    // delete every entry for this site

The cupboard belongs to an "origin": the combination of protocol, domain and port. https://example.com and http://example.com have different cupboards, and so does https://shop.example.com. One site can't open another site's cupboard. And a note on how the engine works: Web Storage is synchronous. Nothing else runs until a setItem line returns; you won't feel it with small data, but with large data it can clog the main thread.

Everything Becomes a String

Here's the answer to the opening question. Web Storage only stores strings. When you call setItem("count", 5), the number 5 is quietly turned into "5". Read it back and you're holding a string, and by the coercion rule we saw earlier "5" + 1 becomes "51". The object is even worse off: it's turned into a string with toString(), and all that's left is "[object Object]". The data is gone; only a label remains.

The fix is the JSON we saw earlier. JSON.stringify when writing, JSON.parse when reading. Wrapping the read side in a helper function is a good habit, because the entry might not exist at all, or someone might have mangled it by hand in DevTools:

function saveJSON(key, value) {
  localStorage.setItem(key, JSON.stringify(value));
}

function loadJSON(key, fallback) {
  const raw = localStorage.getItem(key);
  if (raw === null) return fallback;
  try {
    return JSON.parse(raw);
  } catch {
    return fallback;
  }
}

saveJSON("cart", [{ id: 7, qty: 2 }]);
console.log(loadJSON("cart", []));        // [{ id: 7, qty: 2 }]
console.log(loadJSON("nothing", []));     // []

Paste this into the console and try it. Remember JSON's limits too: a Date object comes back as a string, Map and Set turn into empty objects, and functions disappear. When storing a date, you usually store an ISO string or a timestamp and turn it back into a Date when you read it.

Example: A Draft That Doesn't Vanish

Back to the question we asked at the end of the previous post: what happens if the user leaves a form half-filled and closes the tab? Save the file below, open it, type something, then reload the page or close the tab and open it again.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Draft</title>
</head>
<body>
  <form id="note">
    <p><input name="title" placeholder="Title"></p>
    <p><textarea name="body" rows="6" cols="40" placeholder="Your note"></textarea></p>
    <button>Send</button>
    <span id="saved"></span>
  </form>
  <script>
    const KEY = "draft:note";
    const form = document.querySelector("#note");
    const savedLabel = document.querySelector("#saved");

    // Restore the draft when the page opens
    try {
      const draft = JSON.parse(localStorage.getItem(KEY)) ?? {};
      for (const [name, value] of Object.entries(draft)) {
        if (form.elements[name]) form.elements[name].value = value;
      }
    } catch {
      localStorage.removeItem(KEY);
    }

    // Save on every change
    form.addEventListener("input", () => {
      const data = Object.fromEntries(new FormData(form));
      localStorage.setItem(KEY, JSON.stringify(data));
      savedLabel.textContent = `Draft saved (${new Date().toLocaleTimeString("en-US")})`;
    });

    // Clear the draft on submit
    form.addEventListener("submit", (event) => {
      event.preventDefault();
      console.log("Sent:", Object.fromEntries(new FormData(form)));
      localStorage.removeItem(KEY);
      form.reset();
      savedLabel.textContent = "";
    });
  </script>
</body>
</html>

A prefix like draft: in the key name keeps you from colliding with entries from other code running on the same origin. Also notice that JSON.parse(null) doesn't throw; it returns null, which is why ?? {} is there. Write sessionStorage instead of localStorage here and the draft survives a reload but disappears when the tab closes. Which one is right is a product decision, not a technical one.

The storage Event: News Between Tabs

Imagine you have the same site open in two tabs. You switch to the dark theme in one; how does the other find out? When localStorage changes, the browser sends a storage event to the other tabs and windows on the same origin. The key detail: the event does not fire in the tab that made the change. The news only goes to the others.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Theme sync</title>
  <style>
    body.dark { background: #222; color: #eee; }
  </style>
</head>
<body>
  <button id="toggle">Toggle theme</button>
  <script>
    function applyTheme(theme) {
      document.body.classList.toggle("dark", theme === "dark");
    }

    applyTheme(localStorage.getItem("theme"));

    document.querySelector("#toggle").addEventListener("click", () => {
      const next = localStorage.getItem("theme") === "dark" ? "light" : "dark";
      localStorage.setItem("theme", next);
      applyTheme(next); // no storage event in this tab, so we apply it ourselves
    });

    window.addEventListener("storage", (event) => {
      console.log(event.key, event.oldValue, "→", event.newValue, event.url);
      if (event.key === "theme" || event.key === null) {
        applyTheme(localStorage.getItem("theme"));
      }
    });
  </script>
</body>
</html>

Open this page in two tabs and click the button in one; the other tab changes color on its own. The event object tells you which key changed (key), the old and new values (oldValue, newValue) and which page the change came from (url). If someone calls clear(), key arrives as null; that's why the code handles that case separately. Because sessionStorage is tied to its tab, its changes never reach other tabs.

Quota, Errors and Security

The cupboard is small. Browsers give each origin about 5 MiB for localStorage and the same for sessionStorage. When it's full, setItem throws a QuotaExceededError. Catching it with try/catch wherever you write user data keeps the app from suddenly falling over:

try {
  localStorage.setItem("cache:products", JSON.stringify(bigList));
} catch (error) {
  if (error.name === "QuotaExceededError") {
    console.warn("Storage is full, skipping the cache");
  } else {
    throw error;
  }
}

In a private window, storage is wiped when the window closes. The user can also clear site data whenever they like. So put things in Web Storage that you can afford to lose: preferences, drafts, caches. For large, structured data, know that the browser has a separate, asynchronous database called IndexedDB.

The security side matters even more. Every piece of JavaScript running on the page, including an ad or analytics script you didn't write, can read all of localStorage. So can code that sneaks onto your page through an XSS hole. That's why passwords, authentication tokens, credit card numbers and personal data don't go in there. Open the Application tab in DevTools (Storage in Firefox) and look at any site's cupboard, and you'll see with your own eyes how exposed that data is.

Cookies: A Note Shared with the Server

Cookies are much older than Web Storage and work with one fundamental difference: the browser automatically attaches the cookies for a domain to every HTTP request made to that domain. Think of a club wristband. They put it on at the entrance, and at every door the staff check your wrist. That's how a login stays alive and how your language preference reaches the server. There's a price: because they travel with every request, they need to stay small, about 4 KB per cookie.

From JavaScript you reach cookies through document.cookie, and this property behaves unusually. Read it and you get every cookie the page can see as one string. Write to it and it doesn't replace that string; it only adds or updates the single cookie you name.

document.cookie = "lang=en; path=/; max-age=2592000; SameSite=Lax; Secure";
document.cookie = "banner=closed; path=/; max-age=86400; SameSite=Lax; Secure";

console.log(document.cookie); // "lang=en; banner=closed" (attributes aren't shown)

function getCookie(name) {
  const pair = document.cookie
    .split("; ")
    .find((item) => item.startsWith(`${name}=`));
  return pair ? decodeURIComponent(pair.slice(name.length + 1)) : null;
}

console.log(getCookie("lang")); // "en"

// Deleting: reset its lifetime with the same path
document.cookie = "banner=; path=/; max-age=0";

When reading, you can't see any of the attributes; you only get name and value pairs. If a value might contain spaces, semicolons or non-ASCII characters, use encodeURIComponent when writing and decodeURIComponent when reading. There's no separate delete method; you write the cookie again with the same name, the same path and the same domain, and max-age=0. If the path differs, the browser treats it as another cookie and the original stays put. Since the example uses Secure, try it in the console of a page opened over HTTPS; on plain HTTP the browser won't store a Secure cookie (most browsers make an exception for localhost).

A cookie's behavior is set by the attributes next to it. Let's go through them one by one:

path: which paths the cookie is sent to. If you leave it out, the directory of the page that created the cookie is used; usually you open it to the whole site with path=/. domain: leave it out and the cookie belongs only to the current host; say domain=example.com and subdomains are included. max-age is a lifetime in seconds and expires is an end date in UTC format; if both are present, max-age wins, and if neither is, the cookie is deleted when the browser session ends.

Secure: the cookie is only sent over HTTPS connections. SameSite: decides whether the cookie goes along on requests that start from another site. Strict never sends it, Lax sends it only on top-level navigations such as the user following a link to your site, and None sends it in every case and must be paired with Secure. When you leave SameSite out, some browsers apply Lax by default, but that isn't the same everywhere, so write it explicitly.

There's one more attribute, and you can't set it from JavaScript: HttpOnly. It's set only by the Set-Cookie header the server sends:

Set-Cookie: session=a8f3c2; Path=/; Secure; HttpOnly; SameSite=Lax

The browser sends an HttpOnly cookie to the server with every request, but it never shows up in document.cookie. If you type document.cookie in the console and can't see your session cookie, that's not a bug; it's exactly the point. Even if code gets onto your page through XSS, it can't read and steal the session cookie. That's also the reason behind the "don't put tokens in localStorage" warning in the previous section: localStorage has no protection like HttpOnly.

So which one, and when? If the server needs a small piece of information on every request, a cookie. For preferences, drafts and caches that stay in the browser, localStorage. For state that should live only as long as that tab, sessionStorage. And for something sensitive like a session, an HttpOnly cookie set by the server.

Common Pitfalls

Reading a number or boolean as-is. localStorage.getItem("isAdmin") gives you the string "false", and like every non-empty string it's truthy. Inside an if, the opposite of what you expected runs. Either use JSON or compare with === "true".

Expecting a storage event in the same tab. The tab that makes the change doesn't get the event; you have to update the UI there yourself.

Trying to delete all cookies with document.cookie = "". That deletes nothing. You have to expire each cookie one by one, with its own name and path.

Treating Web Storage as a safe. The user, and every script on the page, can read and change what's inside. Treat values you read from it with the same suspicion as any data coming from outside.

A Small Experiment

★☆☆ Write a page visit counter: on every reload, increment the number in localStorage and show it on the page. Then try the same code with sessionStorage and see what happens when you close and reopen the tab.

★★☆ Predict the output, then try it: after running localStorage.setItem("done", false), does if (localStorage.getItem("done")) console.log("done") print anything? Why?

★★★ Add a "theme for this tab only" option, kept in sessionStorage, to the theme sync example. If a tab-specific theme is selected, a storage event from another tab shouldn't change that tab's colors.

There's one more way to remember things, and it lives in the address bar. If the filter the user picked, the open tab or the page number sits in the URL, it can be shared as a link and comes back with the Back button. In the next post we'll look at URL, URLSearchParams and the History API for managing the address without reloading the page.