Browsers give you four places to keep data on the visitor’s device: localStorage, sessionStorage, cookies and IndexedDB. They differ in size, lifetime, and whether the server sees them. Choosing correctly is mostly a matter of answering two questions — how long should this last, and does the server need it?
Choosing between them#
| localStorage | sessionStorage | Cookies | IndexedDB | |
|---|---|---|---|---|
| Size | about 5 MB | about 5 MB | 4 KB | hundreds of MB |
| Lifetime | until cleared | until tab closes | you choose | until cleared |
| Sent to the server | No | No | Every request | No |
| Stores | strings | strings | strings | objects, files |
| API | synchronous | synchronous | synchronous | asynchronous |
| Good for | preferences, drafts | one-visit state | auth, server needs | offline data |
localStorage#
localStorage.setItem("theme", "dark");
const theme = localStorage.getItem("theme"); // "dark"
localStorage.removeItem("theme");
localStorage.clear(); // everything for this origin
localStorage.getItem("missing"); // null, not undefined
console.log(localStorage.length);
Everything is a string. That is the source of most bugs with it:
localStorage.setItem("count", 5);
const count = localStorage.getItem("count");
console.log(count + 1); // "51" - string concatenation
console.log(Number(count) + 1); // 6
localStorage.setItem("enabled", false);
if (localStorage.getItem("enabled")) {
console.log("this runs"); // "false" is a non-empty string
}
Objects need JSON#
const settings = { theme: "dark", fontSize: 16 };
localStorage.setItem("settings", JSON.stringify(settings));
const stored = JSON.parse(localStorage.getItem("settings"));
console.log(stored.theme);
Forget the stringify and you store the text [object Object], which parses back to nothing useful.
Wrap both directions, because parsing can throw:
function save(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (error) {
console.warn("Could not save:", error.name);
return false;
}
}
function load(key, fallback = null) {
try {
const raw = localStorage.getItem(key);
return raw === null ? fallback : JSON.parse(raw);
} catch {
return fallback; // corrupt or hand-edited value
}
}
Note what JSON does not survive: Date objects become strings, undefined and functions disappear, and Map and Set become empty objects. Convert them yourself before saving.
When storage is unavailable#
function storageAvailable() {
try {
const key = "__test__";
localStorage.setItem(key, "1");
localStorage.removeItem(key);
return true;
} catch {
return false;
}
}
Reading or writing can throw, not just return null: some browsers block site data entirely, private windows may refuse writes, and the quota can be full. A page that assumes storage works breaks completely for those visitors.
try {
localStorage.setItem("big", hugeString);
} catch (error) {
if (error.name === "QuotaExceededError") {
// clear old entries and retry, or fall back to memory
}
}
sessionStorage#
sessionStorage.setItem("step", "3");
Identical API, different lifetime: it is cleared when the tab closes, and each tab has its own copy. Right for the current step of a multi-page form, a scroll position, or anything that should not persist to the next visit.
Reacting to changes in other tabs#
window.addEventListener("storage", (event) => {
if (event.key === "theme") {
applyTheme(event.newValue);
}
});
The event fires in other tabs on the same origin, not the one that made the change. It is a simple way to keep several open tabs in sync — a theme toggle, or signing out everywhere at once.
Cookies#
document.cookie = "theme=dark; path=/; max-age=31536000; SameSite=Lax; Secure";
function getCookie(name) {
return document.cookie
.split("; ")
.find(row => row.startsWith(name + "="))
?.split("=")[1];
}
// Delete by expiring it
document.cookie = "theme=; path=/; max-age=0";
The flags matter more than the syntax:
- Secure — only sent over HTTPS.
- HttpOnly — not readable from JavaScript. Set by the server; essential for session cookies.
- SameSite=Lax or Strict — limits sending on cross-site requests, which blocks most CSRF.
- Max-Age or Expires — without one, the cookie disappears when the browser closes.
Use cookies when the server needs the value on every request. For anything client-only, localStorage is better — cookies add weight to every single request you make.
IndexedDB#
npm install idb
import { openDB } from "idb";
const db = await openDB("notes-db", 1, {
upgrade(db) {
db.createObjectStore("notes", { keyPath: "id" });
},
});
await db.put("notes", { id: 1, text: "Hello", created: new Date() });
const note = await db.get("notes", 1);
const all = await db.getAll("notes");
The raw IndexedDB API is famously awkward, so a small wrapper is worth it. Reach for IndexedDB when you need more than a few megabytes, structured querying, or to store files and images — offline applications, in short.
What must never go in browser storage#
- Passwords, in any form.
- Payment details — card numbers, security codes.
- Anything from which access can be derived — API secrets, signing keys.
- Personal data you do not need on the device.
A practical example#
const form = document.querySelector("#contact");
const KEY = "contact-draft";
// Restore on load
const draft = load(KEY, {});
for (const [name, value] of Object.entries(draft)) {
const field = form.elements[name];
if (field) field.value = value;
}
// Save as they type
form.addEventListener("input", () => {
const data = Object.fromEntries(new FormData(form));
save(KEY, data);
});
// Clear on successful submit
form.addEventListener("submit", () => localStorage.removeItem(KEY));
Twelve lines that stop someone losing a long message to an accidental refresh — probably the single best return on effort in this whole area.
Questions people ask#
Does localStorage have an expiry?
No. Store a timestamp alongside the value and check it yourself if you need one.
Is it shared between subdomains?
No. Storage is per origin, so scheme, host and port must all match. Cookies can be shared across subdomains with the Domain attribute.
Do I need a cookie banner for localStorage?
It depends on what you store and where your users are. Necessary functional storage is generally treated differently from tracking. Take proper advice for anything commercial — the technology is not what decides it.
Why is my stored value null after a redirect?
Most likely a different origin — http versus https, or www versus bare domain. Print location.origin on both pages and compare.
Where to go next#
- JavaScript local storage — localStorage in more depth.
- Arrays and objects in JavaScript — what you are serialising.
- JavaScript security — why tokens do not belong here.