Local Storage vs Cookies vs IndexedDB: Where Should You Store Data?
Gone are the days when specific user settings required server-side databases. Today's browsers are powerful storage engines. Here is how to pick the right one.
Modern web applications — especially Single Page Applications (SPAs) and Progressive Web Apps — rely heavily on client-side storage. Whether it's caching API responses, saving a dark mode preference, or keeping a user logged in between sessions, you have three main choices: Cookies, LocalStorage, and IndexedDB.
Each mechanism exists for a different purpose. Using the wrong one can introduce subtle security vulnerabilities, performance bottlenecks, or data that vanishes at the worst possible time. This guide breaks down exactly how each one works, what it's designed for, and where developers commonly go wrong.
1. Cookies: The Old Guard
Cookies have been part of the web since 1994 — Netscape engineer Lou Montulli invented them to solve a stateless HTTP problem. They are small text files (4KB maximum) that the browser stores and automatically attaches to every HTTP request sent to your domain. That automatic sending is both their killer feature and their biggest liability.
Setting a cookie in JavaScript:
// Basic cookie — expires when the browser closes
document.cookie = "username=noah";
// Persistent cookie with expiry date
document.cookie = "theme=dark; expires=Fri, 31 Dec 2026 23:59:59 GMT; path=/";
// Secure, HttpOnly cookie (must be set server-side)
// Set-Cookie: sessionId=abc123; HttpOnly; Secure; SameSite=Strict
The HttpOnly flag is the most important security attribute for auth cookies. When
set, JavaScript cannot read the cookie at all — document.cookie will not include
it. This makes your session tokens immune to Cross-Site Scripting (XSS) attacks, because even
if an attacker injects a malicious script, it cannot exfiltrate the token.
The SameSite attribute protects against Cross-Site Request Forgery (CSRF) attacks
by controlling when cookies are sent to cross-origin requests. Use Strict for
maximum protection on authentication cookies, or Lax if your app needs cookies on
top-level navigations from external links.
Best for: Session tokens, authentication state, CSRF tokens — anything that needs to be automatically sent to the server.
Avoid for: Storing large data. Every cookie is transmitted with every request, so a 4KB cookie adds 4KB of overhead to every image, API call, and page load to your domain. That compounds quickly.
2. LocalStorage: Simple & Synchronous
LocalStorage arrived with HTML5 and solved a simple problem: developers needed a way to store
modest amounts of data client-side without the per-request overhead of cookies. It is a
synchronous key-value store, available as a global localStorage object, with a
capacity of roughly 5–10MB depending on the browser.
The core API:
// Write
localStorage.setItem('theme', 'dark');
localStorage.setItem('user', JSON.stringify({ id: 42, name: 'Noah' }));
// Read
const theme = localStorage.getItem('theme'); // "dark"
const user = JSON.parse(localStorage.getItem('user')); // { id: 42, name: 'Noah' }
// Delete one key
localStorage.removeItem('theme');
// Clear everything
localStorage.clear();
// Check how many keys are stored
console.log(localStorage.length);
LocalStorage only stores strings. That is the most common source of bugs: if you store a number,
it comes back as a string. Always use JSON.stringify and JSON.parse
when storing objects or arrays, and wrap reads in a try/catch — JSON.parse throws
if the stored value is malformed.
function getStoredUser() {
try {
const raw = localStorage.getItem('user');
return raw ? JSON.parse(raw) : null;
} catch (e) {
localStorage.removeItem('user'); // clean up corrupted data
return null;
}
}
The synchronous problem: Every call to getItem or
setItem blocks the main thread. For small strings this is imperceptible, but if
you store a large JSON object (tens of KB or more) and read it during page initialisation, you
will stutter the UI. This is not theoretical — it is a documented source of
Interaction to Next Paint
regressions in production apps.
What about sessionStorage? It has an identical API to LocalStorage but data is scoped to a single browser tab and is cleared when the tab closes. Use it for temporary in-progress state — a multi-step form, a checkout flow — where you do not want data persisting across sessions or leaking between tabs.
Best for: UI preferences (theme, language, sidebar state), small configuration objects, remembering where a user left off in a workflow.
Avoid for: Sensitive data (no encryption, accessible to any script on the page), large objects, or anything that should sync across tabs in real time.
3. IndexedDB: The Heavy Lifter
IndexedDB is a full transactional database built into the browser. Unlike LocalStorage, it is asynchronous — reads and writes do not block the main thread. It supports indexes for fast lookups, can store binary data (images, files, ArrayBuffers), and has a practical storage limit measured in hundreds of megabytes or more depending on available disk space.
The raw IndexedDB API is notoriously verbose. Here is the minimal code to open a database, write a record, and read it back:
// Open (or create) a database named "myApp" at version 1
const request = indexedDB.open('myApp', 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Create an object store (like a table) with an auto-incrementing key
db.createObjectStore('settings', { keyPath: 'key' });
};
request.onsuccess = (event) => {
const db = event.target.result;
// Write a record
const tx = db.transaction('settings', 'readwrite');
tx.objectStore('settings').put({ key: 'theme', value: 'dark' });
// Read a record
const readTx = db.transaction('settings', 'readonly');
const getReq = readTx.objectStore('settings').get('theme');
getReq.onsuccess = () => console.log(getReq.result); // { key: 'theme', value: 'dark' }
};
Most production apps use a wrapper library like idb (by Jake Archibald) to get a Promise-based API on top of IndexedDB without a heavy dependency. It exposes the full power of IndexedDB with a fraction of the boilerplate.
Best for: Offline-capable PWAs, caching API responses for network-independent use, storing user-generated content (images, documents), large structured datasets that need querying.
Avoid for: Simple key-value preferences (overkill), or anything that needs to be synchronously available at script startup.
Storage Limits by Browser
Browsers do not guarantee a fixed storage amount — they use a quota system based on available disk space. Here are the approximate practical limits:
| Browser | LocalStorage | IndexedDB | Notes |
|---|---|---|---|
| Chrome / Edge | ~5MB | Up to 60% of disk | Uses Storage API quota |
| Firefox | ~5–10MB | Up to 50% of disk | Prompts user above 50MB |
| Safari | ~5MB | ~1GB (desktop) | 7-day ITP eviction on mobile |
| Mobile browsers | ~2.5–5MB | Varies widely | Aggressively evicted under storage pressure |
The Safari row deserves special attention. Safari's Intelligent Tracking Prevention (ITP) policy will delete all script-writable storage — including LocalStorage and IndexedDB — for a domain if the user has not visited it in 7 days. If your app is used occasionally on iPhone, do not rely on LocalStorage for anything that must survive between sessions.
Security: What You Should Never Store Client-Side
Both LocalStorage and IndexedDB are accessible to any JavaScript running on your page. A single XSS vulnerability — a malicious ad, a compromised npm package, an unsanitised user input — can exfiltrate everything stored there. For that reason, never store:
- Authentication tokens (JWTs, OAuth tokens) — use
HttpOnlycookies instead - Passwords or password hashes — never, under any circumstance
- Credit card numbers or payment data
- Personally identifiable information (email addresses, phone numbers, government IDs) unless absolutely necessary and with full awareness of the risk
The common counter-argument is "but I encrypt the data before storing it." The problem is that the encryption key also has to live somewhere in the browser, which means it is vulnerable to the same XSS attack. There is no safe way to store truly sensitive secrets in client-side storage.
Summary: Choosing the Right Tool
| Storage | Capacity | Blocking | Best For |
|---|---|---|---|
| Cookies | 4KB | No | Auth tokens, server-read state |
| LocalStorage | ~5MB | Yes (sync) | UI preferences, small config |
| sessionStorage | ~5MB | Yes (sync) | Per-tab temporary state |
| IndexedDB | >500MB | No (async) | Offline data, binary files, large datasets |
Frequently Asked Questions
Should I use localStorage or sessionStorage?
Use sessionStorage when the data only makes sense for the current visit — a
partially filled form, a step in a wizard, a scroll position within a modal. Use
localStorage when you want the data to persist across sessions, like a user's
theme preference or their tool configuration. When in doubt, ask: "Would it be confusing or
annoying if this data was still here next week?" If yes, use sessionStorage.
What happens when a user clears their browser cache?
Clearing cache in most browsers (via Settings → Clear browsing data) will wipe LocalStorage,
sessionStorage, IndexedDB, and non-HttpOnly cookies simultaneously. Design your app to
degrade gracefully — if the stored preference is gone, fall back to a sensible default. Never
assume stored data exists; always check for null on reads.
Can I share data between tabs with LocalStorage?
Yes. LocalStorage is shared across all tabs and windows on the same origin. You can even listen
for changes made in other tabs using the storage event:
window.addEventListener('storage', (event) => {
if (event.key === 'theme') {
applyTheme(event.newValue); // sync theme change from another tab
}
});
This only fires in tabs that did not make the change, making it useful for cross-tab synchronisation. sessionStorage, by contrast, is completely isolated per tab — even if you duplicate a tab, changes in one do not propagate to the other.
At ToolBit, we use LocalStorage to remember your preferences (like staying in "Hex" mode on the color picker, or your last regex flags) because it's fast, simple, and privacy-friendly. No data ever leaves your device, and if you clear your cache, the tools simply reset to their defaults — nothing breaks.