You're building a tiny tool. Maybe a notepad. Maybe a settings panel. Maybe a draft autosave for a form. You don't want a database. You don't want a backend. You don't even want a login.
localStorage.setItem(key, value) solves the problem in one line and you go home. Until your tool grows up, the data outgrows 5MB, three browser tabs trip over each other, the user clears their cookies, and suddenly you have a support ticket that boils down to "I lost my work."
This is the localStorage post I wish I'd had three years ago. When it's the right tool, when it isn't, and the specific footguns that make production-grade local persistence harder than it looks.
What localStorage actually is
localStorage is a synchronous key-value store, scoped to an origin (protocol + domain + port), persisted to disk by the browser. Both keys and values are strings. That's the whole interface:
localStorage.setItem('username', 'jane')
const value = localStorage.getItem('username') // 'jane'
localStorage.removeItem('username')
localStorage.clear()
It's been in every browser since 2009. It has no expiration, survives reboots, and is shared across all tabs of the same origin. It's also the most misunderstood persistence API on the web.
When localStorage is actually the right tool
It's right for:
- User preferences — theme, language, layout choices, "I dismissed this banner."
- Draft state — a form you want to autosave, a half-written note, an unsaved edit.
- Auth-adjacent data — user ID, role, last-known username (NOT auth tokens; we'll come back to that).
- Small caches — a list of recent searches, a sidebar's collapsed/expanded state.
- Feature flags that the user controls — "I opted into the beta UI."
The pattern: small, simple, doesn't need querying, fits in tens of KB, and losing it is annoying but not catastrophic.
It's wrong for:
- Large datasets — anything past 1–2 MB starts to pinch
- Structured data you need to query — localStorage is dumb storage; no indexes, no transactions
- Anything sensitive — accessible by any JS on the page (XSS = full data theft)
- Data shared across origins — localStorage is origin-scoped
- High-write-frequency state — synchronous writes block the main thread
The footguns
Most production localStorage bugs come from one of these.
1. Everything is a string
setItem stringifies whatever you pass it, but not in a clever way. Numbers become strings. Booleans become strings. Objects become "[object Object]".
localStorage.setItem('count', 5)
localStorage.getItem('count') // '5' — string, not number
localStorage.setItem('enabled', true)
localStorage.getItem('enabled') // 'true' — string
localStorage.setItem('config', { dark: true })
localStorage.getItem('config') // '[object Object]' — useless
Always JSON.stringify and JSON.parse for non-string data:
localStorage.setItem('config', JSON.stringify({ dark: true }))
const config = JSON.parse(localStorage.getItem('config'))
The wrap functions:
function setJSON(key, value) {
localStorage.setItem(key, JSON.stringify(value))
}
function getJSON(key, fallback = null) {
const raw = localStorage.getItem(key)
if (raw === null) return fallback
try { return JSON.parse(raw) } catch { return fallback }
}
The try/catch is not optional. If the user (or a previous bug) wrote bad JSON, JSON.parse throws, your app breaks on load, and now you can't even fix it through normal flows because the broken data is the first thing you read.
2. The 5MB limit (which isn't really 5MB)
The spec says "the user agent should limit the total amount of space allowed for storage areas." Most browsers settled on 5MB per origin, but it's not enforced uniformly:
- Chrome: ~5MB per origin
- Firefox: 5MB per origin, configurable
- Safari: ~5MB; sometimes silently caps at less
-
Mobile Safari in private mode: 0 (writes throw
QuotaExceededError)
The 5MB is total — keys + values + a small overhead. Strings are stored as UTF-16, so each character is 2 bytes; a "5MB" budget is really 2.5 million characters.
When you exceed the limit, setItem throws a DOMException. You need to catch it:
try {
localStorage.setItem(key, bigValue)
} catch (e) {
if (e.name === 'QuotaExceededError') {
// Make space, or warn the user, or fall back to IndexedDB
} else {
throw e
}
}
If you're storing user-generated content, this happens in production eventually. Plan for it.
3. The synchronous API blocks the main thread
Every setItem call writes to disk synchronously. For small values, this is microseconds. For large values, especially on mobile, it can be tens of milliseconds — long enough to drop a frame.
If your app is autosaving every keystroke and saving 100KB of state each time, you've got a stutter problem. The fix: debounce.
let saveTimer = null
function scheduleSave(state) {
clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
setJSON('app-state', state)
}, 500)
}
For genuinely large state, this still won't be enough. That's the IndexedDB threshold.
4. Cross-tab synchronization
Two tabs of your app are open. The user changes a setting in tab A. Tab B doesn't know — it's still showing the old value.
The fix is the storage event:
window.addEventListener('storage', (e) => {
if (e.key === 'theme') {
applyTheme(e.newValue)
}
})
The catch: the storage event only fires in other tabs, not the one that did the write. So the writer needs to update its own UI separately, and the listeners pick up changes from elsewhere. This is correct behavior but easy to mishandle.
5. Versioning your stored data
You ship v1 of your app. It writes user state shaped like:
{ "name": "Jane", "color": "blue" }
Six months later, you ship v2. The state shape is now:
{ "user": { "name": "Jane", "preferences": { "color": "blue" } } }
Existing users have v1 data sitting in localStorage. When v2 loads it, things break.
The fix: version your stored data and migrate.
const CURRENT_VERSION = 2
function loadState() {
const raw = getJSON('app-state', null)
if (!raw) return defaultState()
if (raw.version === undefined || raw.version === 1) {
return migrate1to2(raw)
}
if (raw.version === 2) return raw
// Unknown future version — bail
return defaultState()
}
Migration is a one-way door. Once you ship v2, you can't easily walk it back without losing data. So design migrations carefully and test them with real v1 data before deploying.
6. Don't store auth tokens here
This is its own essay, but quickly: any XSS vulnerability on your site lets an attacker do localStorage.getItem('auth-token') and steal it. Cookies marked httpOnly aren't accessible to JavaScript, which is what you want for credentials. Use cookies for auth; use localStorage for non-sensitive state.
If you're inheriting a codebase that puts JWT tokens in localStorage, plan to migrate. The migration isn't trivial (CSRF protection, cross-origin handling) but it's worth doing.
When to graduate to IndexedDB
The threshold is roughly:
- Data above 5MB — localStorage will cap out
- You need querying — finding records by anything other than primary key
- Many writes per second — IndexedDB is async and won't block the main thread
- Binary data — Blobs, Files, ArrayBuffers store natively in IndexedDB; localStorage forces base64 encoding (33% size overhead)
IndexedDB is more complex (the API is famously verbose), but libraries like idb-keyval give you a localStorage-like wrapper for the simple cases:
import { get, set } from 'idb-keyval'
await set('user-state', complexObject)
const state = await get('user-state')
That's IndexedDB with the same ergonomics as localStorage, but without the 5MB limit and without blocking.
A real case study: a private notepad
A small browser-based notepad — text editor, multiple tabs, autosave, no signup, fully local — is the kind of app that's a perfect localStorage fit until it isn't.
The version that works for 95% of users:
const STORAGE_KEY = 'notepad-tabs-v1'
function loadTabs() {
return getJSON(STORAGE_KEY, [{ id: 'default', content: '' }])
}
function saveTabs(tabs) {
setJSON(STORAGE_KEY, tabs)
}
// Debounce on every keystroke
const debouncedSave = debounce(saveTabs, 300)
This works perfectly until a user pastes a 4MB log file into one tab. Then setItem throws, the tab data fails to save, and on reload everything is back to the previous state. The user thinks they lost their work.
The graduation path: when total size approaches 1MB, migrate that tab's content to IndexedDB and store just a pointer in localStorage. Most users never trip the migration; the heavy users get a more capable backend automatically.
This is what notepad.renderlog.in does — small notes stay in localStorage for instant load, larger ones move to IndexedDB transparently, nothing ever leaves the browser. The whole thing is single-page, no signup, works offline after the first load, just a working text editor that respects the user's privacy. Useful as a reference if you're designing similar offline-first state.
TL;DR
- localStorage is great for: preferences, drafts, small caches, non-sensitive flags. ~10–500KB of data.
- Always
JSON.stringify/JSON.parsenon-string values; alwaystry/catchthe parse. - The 5MB limit is real and hits in production. Catch
QuotaExceededError. - Synchronous writes block the main thread; debounce frequent saves.
- Use the
storageevent for cross-tab sync. - Version your stored data from day one — migrations cost you nothing now and save you everything later.
- Don't store auth tokens. Use
httpOnlycookies. - Graduate to IndexedDB (via
idb-keyvalor similar) for >5MB, querying, or binary data.
localStorage is one of those APIs that looks too simple to think about and turns out to deserve about a day of design. Spend the day; you'll save the support tickets.
If this was useful, I've also built a handful of other free, browser-based tools — no signup, no uploads, everything runs client-side:
- JSON Tools — https://json.renderlog.in (formatter, validator, JWT decoder, JSONPath tester, 40+ converters)
- Text Tools — https://text.renderlog.in (case converters, slug generator, HTML/markdown utilities, 70+ tools)
- PDF Tools — https://pdftools.renderlog.in (merge, split, OCR, compress to exact size, 40+ tools)
- Image Tools — https://imagetools.renderlog.in (compress, convert, resize, background remover, 50+ tools)
- QR Tools — https://qrtools.renderlog.in (WiFi, vCard, UPI, bulk QR codes with logos)
- Calc Tools — https://calctool.renderlog.in (60+ calculators for finance, health, math, dates)
- Notepad — https://notepad.renderlog.in (private, offline-first notes, no signup)
Top comments (0)