Web Storage API
The Web Storage API provides a way for websites to store key-value pairs locally in a user's browser — persisting across page reloads and, in the case of localStorage, across browser sessions. Unlike cookies, data stored via the Web Storage API is never automatically sent to the server, making it a more efficient choice for client-side data that doesn't need server access.
The API exposes two storage mechanisms: localStorage (persistent, origin-scoped) and sessionStorage (tab-scoped, cleared when the tab closes). Both share the same interface — setItem(), getItem(), removeItem(), clear(), and key() — but differ in their lifetime and scope.
localStorage stores data with no expiration time. Data persists even after the browser is closed and reopened, remaining available until explicitly deleted via JavaScript or the user clears their browser data. It is scoped to the origin (protocol + hostname + port), meaning https://example.com and https://app.example.com have separate storage.
| 1 | <!-- localStorage persists across sessions --> |
| 2 | <script> |
| 3 | // Store data |
| 4 | localStorage.setItem('theme', 'dark'); |
| 5 | localStorage.setItem('fontSize', '16'); |
| 6 | |
| 7 | // Retrieve data |
| 8 | const theme = localStorage.getItem('theme'); |
| 9 | console.log(theme); // "dark" |
| 10 | |
| 11 | // Check if a key exists |
| 12 | if (localStorage.getItem('theme') !== null) { |
| 13 | console.log('Theme preference found'); |
| 14 | } |
| 15 | |
| 16 | // Get number of stored items |
| 17 | console.log(localStorage.length); // 2 |
| 18 | |
| 19 | // Remove a specific key |
| 20 | localStorage.removeItem('fontSize'); |
| 21 | |
| 22 | // Clear all data for this origin |
| 23 | localStorage.clear(); |
| 24 | </script> |
| 25 | |
| 26 | // Storage origin isolation examples: |
| 27 | // https://example.com → separate from |
| 28 | // https://app.example.com → separate from |
| 29 | // https://example.com:8080 → separate from |
| 30 | // http://example.com → separate (different protocol) |
| 31 | |
| 32 | // Data persists until explicitly removed: |
| 33 | // - JavaScript: localStorage.removeItem() or .clear() |
| 34 | // - User: "Clear browsing data" in browser settings |
| 35 | // - User: "Clear site data" from site permissions |
info
sessionStorage is scoped to the current browser tab or window. Data survives page reloads and restores (e.g., after a browser crash with session restore) but is cleared when the tab is closed. Opening the same page in a new tab or window creates a new, empty sessionStorage. This makes it ideal for temporary, tab-specific state.
| 1 | <!-- sessionStorage is tab-scoped --> |
| 2 | <script> |
| 3 | // Tab A: set data |
| 4 | sessionStorage.setItem('formDraft', '{"step": 3, "name": "Alice"}'); |
| 5 | |
| 6 | // Tab B (same origin, different tab): null |
| 7 | console.log(sessionStorage.getItem('formDraft')); |
| 8 | // → null — each tab has its own sessionStorage |
| 9 | |
| 10 | // Useful for multi-step form state |
| 11 | function saveDraft(step, data) { |
| 12 | sessionStorage.setItem('formStep', step); |
| 13 | sessionStorage.setItem('formData', JSON.stringify(data)); |
| 14 | } |
| 15 | |
| 16 | function restoreDraft() { |
| 17 | const step = sessionStorage.getItem('formStep'); |
| 18 | const data = JSON.parse(sessionStorage.getItem('formData')); |
| 19 | if (step && data) { |
| 20 | return { step: parseInt(step), data }; |
| 21 | } |
| 22 | return null; |
| 23 | } |
| 24 | |
| 25 | // Wizard form example |
| 26 | // Step 1 → saveDraft(1, { name: 'Alice' }) |
| 27 | // User navigates to Step 2 → saveDraft(2, { name: 'Alice', email: 'a@b.com' }) |
| 28 | // User accidentally refreshes → restoreDraft() picks up at step 2 |
| 29 | |
| 30 | // Tab-scoped authentication token (short-lived) |
| 31 | sessionStorage.setItem('sessionToken', 'eyJhbGciOi...'); |
| 32 | </script> |
| 33 | |
| 34 | // sessionStorage lifecycle: |
| 35 | // Tab opened → empty sessionStorage |
| 36 | // Page reload → data preserved |
| 37 | // Tab closed → data destroyed |
| 38 | // New tab → new empty sessionStorage |
pro tip
Both localStorage and sessionStorage share the same five methods on the Storage interface. Values are always stored as strings — non-string values are automatically converted via toString(). Use JSON.stringify() and JSON.parse() for complex data like objects and arrays.
| Method | Signature | Description |
|---|---|---|
| setItem | setItem(key, value) | Store a key-value pair (value is stringified) |
| getItem | getItem(key) | Retrieve value by key (null if missing) |
| removeItem | removeItem(key) | Delete a single key-value pair |
| clear | clear() | Remove all key-value pairs for the origin |
| key | key(index) | Get the key at the given numeric index |
| length | length (property) | Number of stored key-value pairs |
| 1 | <!-- Complete CRUD operations for Web Storage --> |
| 2 | <script> |
| 3 | // CREATE — store data |
| 4 | localStorage.setItem('username', 'alice'); |
| 5 | localStorage.setItem('preferences', JSON.stringify({ |
| 6 | theme: 'dark', |
| 7 | fontSize: 14, |
| 8 | notifications: true, |
| 9 | })); |
| 10 | |
| 11 | // READ — retrieve data |
| 12 | const username = localStorage.getItem('username'); |
| 13 | // "alice" |
| 14 | |
| 15 | const prefs = JSON.parse( |
| 16 | localStorage.getItem('preferences') |
| 17 | ); |
| 18 | // { theme: 'dark', fontSize: 14, notifications: true } |
| 19 | |
| 20 | // READ — iterate over all keys |
| 21 | for (let i = 0; i < localStorage.length; i++) { |
| 22 | const key = localStorage.key(i); |
| 23 | const value = localStorage.getItem(key); |
| 24 | console.log(`${key}: ${value}`); |
| 25 | } |
| 26 | |
| 27 | // READ — using Object.entries pattern |
| 28 | const allData = { ...localStorage }; |
| 29 | console.log(allData); |
| 30 | // { username: 'alice', preferences: '{"theme":"dark",...}' } |
| 31 | |
| 32 | // UPDATE — overwrite existing key |
| 33 | const updatedPrefs = { |
| 34 | ...JSON.parse(localStorage.getItem('preferences')), |
| 35 | fontSize: 16, // change font size |
| 36 | }; |
| 37 | localStorage.setItem('preferences', JSON.stringify(updatedPrefs)); |
| 38 | |
| 39 | // DELETE — remove one item |
| 40 | localStorage.removeItem('username'); |
| 41 | |
| 42 | // DELETE — remove all items |
| 43 | // localStorage.clear(); |
| 44 | |
| 45 | // Check existence |
| 46 | if (localStorage.getItem('preferences') !== null) { |
| 47 | console.log('Preferences exist'); |
| 48 | } |
| 49 | |
| 50 | // Safe retrieval with fallback |
| 51 | function getStorageItem(key, fallback = null) { |
| 52 | try { |
| 53 | const item = localStorage.getItem(key); |
| 54 | return item !== null ? JSON.parse(item) : fallback; |
| 55 | } catch { |
| 56 | return fallback; |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | const config = getStorageItem('config', { theme: 'light' }); |
| 61 | </script> |
The storage event fires on other window objects (tabs or iframes) of the same origin when storage changes. The event does not fire on the tab that made the change — it is specifically for cross-tab synchronization. This enables powerful patterns like sharing state between open tabs without a server.
| 1 | <!-- Cross-tab synchronization via storage event --> |
| 2 | <script> |
| 3 | // Listen for storage changes from other tabs |
| 4 | window.addEventListener('storage', (event) => { |
| 5 | console.log('Storage changed:', { |
| 6 | key: event.key, // The key that changed |
| 7 | oldValue: event.oldValue, // Previous value (null if new) |
| 8 | newValue: event.newValue, // New value (null if deleted) |
| 9 | url: event.url, // URL of the tab that made the change |
| 10 | storageArea: event.storageArea, // localStorage or sessionStorage |
| 11 | }); |
| 12 | |
| 13 | // React to specific changes |
| 14 | if (event.key === 'theme') { |
| 15 | applyTheme(event.newValue); |
| 16 | } |
| 17 | if (event.key === 'logout') { |
| 18 | window.location.reload(); |
| 19 | } |
| 20 | }); |
| 21 | |
| 22 | function applyTheme(theme) { |
| 23 | document.documentElement.setAttribute('data-theme', theme); |
| 24 | } |
| 25 | |
| 26 | // Tab A: changes theme |
| 27 | document.getElementById('themeToggle').addEventListener('click', () => { |
| 28 | const newTheme = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'; |
| 29 | localStorage.setItem('theme', newTheme); |
| 30 | applyTheme(newTheme); |
| 31 | // storage event fires in Tab B, C, D... but NOT in Tab A |
| 32 | }); |
| 33 | |
| 34 | // Cross-tab logout |
| 35 | function logoutAllTabs() { |
| 36 | localStorage.setItem('logout', Date.now().toString()); |
| 37 | // All other tabs will detect this and redirect |
| 38 | window.location.href = '/login'; |
| 39 | } |
| 40 | |
| 41 | // Broadcast channel alternative (modern browsers) |
| 42 | const channel = new BroadcastChannel('app-sync'); |
| 43 | channel.postMessage({ type: 'THEME_CHANGE', theme: 'dark' }); |
| 44 | channel.onmessage = (event) => { |
| 45 | if (event.data.type === 'THEME_CHANGE') { |
| 46 | applyTheme(event.data.theme); |
| 47 | } |
| 48 | }; |
| 49 | </script> |
best practice
Web Storage is limited to approximately 5 MB per origin across most browsers. The limit applies to the total size of all key-value pairs (including both key and value strings in UTF-16 encoding). Attempting to exceed this limit throws a QuotaExceededError (DOMException 22). The actual limit varies slightly between browsers and can change based on available disk space.
| Browser | Storage Limit | Scope |
|---|---|---|
| Chrome | ~5 MB | Per origin (shared across all tabs) |
| Firefox | ~5 MB | Per origin |
| Safari | ~5 MB | Per origin (Desktop); ~2 MB (iOS) |
| Edge | ~5 MB | Per origin |
| iOS Safari | ~2 MB | Per origin (limited storage) |
| 1 | <!-- Storage limit detection and error handling --> |
| 2 | <script> |
| 3 | function isStorageAvailable(type = 'localStorage') { |
| 4 | try { |
| 5 | const storage = window[type]; |
| 6 | const testKey = '__storage_test__'; |
| 7 | storage.setItem(testKey, 'test'); |
| 8 | storage.removeItem(testKey); |
| 9 | return true; |
| 10 | } catch (e) { |
| 11 | return false; |
| 12 | } |
| 13 | } |
| 14 | |
| 15 | function setWithQuotaCheck(key, value) { |
| 16 | try { |
| 17 | localStorage.setItem(key, value); |
| 18 | return true; |
| 19 | } catch (e) { |
| 20 | if (e.name === 'QuotaExceededError' || |
| 21 | e.code === 22) { |
| 22 | console.warn('Storage quota exceeded'); |
| 23 | // Clean up old/unused data |
| 24 | cleanupOldStorage(); |
| 25 | return false; |
| 26 | } |
| 27 | console.error('Storage error:', e); |
| 28 | return false; |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | function getStorageUsage() { |
| 33 | let total = 0; |
| 34 | for (let i = 0; i < localStorage.length; i++) { |
| 35 | const key = localStorage.key(i); |
| 36 | const value = localStorage.getItem(key); |
| 37 | // Estimate: 2 bytes per character (UTF-16) |
| 38 | total += (key.length + value.length) * 2; |
| 39 | } |
| 40 | return { |
| 41 | bytes: total, |
| 42 | kilobytes: (total / 1024).toFixed(1), |
| 43 | megabytes: (total / (1024 * 1024)).toFixed(3), |
| 44 | percent: ((total / (5 * 1024 * 1024)) * 100).toFixed(1), |
| 45 | }; |
| 46 | } |
| 47 | |
| 48 | // Monitor storage usage |
| 49 | console.log(getStorageUsage()); |
| 50 | // { bytes: 1402, kilobytes: "1.4", megabytes: "0.001", percent: "0.0" } |
| 51 | </script> |
warning
Web Storage only supports string values. Storing objects, arrays, numbers, or booleans requires explicit serialization with JSON.stringify() and deserialization with JSON.parse(). Without serialization, objects are converted to the string "[object Object]", and arrays are joined with commas.
| 1 | <!-- JSON serialization for complex data --> |
| 2 | <script> |
| 3 | // WRONG — silently corrupts data |
| 4 | localStorage.setItem('user', { name: 'Alice', age: 30 }); |
| 5 | console.log(localStorage.getItem('user')); |
| 6 | // → "[object Object]" ✗ |
| 7 | |
| 8 | // WRONG — array becomes comma-separated string |
| 9 | localStorage.setItem('tags', ['html', 'css', 'js']); |
| 10 | console.log(localStorage.getItem('tags')); |
| 11 | // → "html,css,js" ✗ (cannot distinguish from actual string) |
| 12 | |
| 13 | // CORRECT — serialize with JSON.stringify |
| 14 | const user = { |
| 15 | id: 42, |
| 16 | name: 'Alice', |
| 17 | age: 30, |
| 18 | roles: ['admin', 'editor'], |
| 19 | settings: { |
| 20 | theme: 'dark', |
| 21 | notifications: true, |
| 22 | }, |
| 23 | }; |
| 24 | localStorage.setItem('user', JSON.stringify(user)); |
| 25 | |
| 26 | // CORRECT — deserialize with JSON.parse |
| 27 | const stored = JSON.parse(localStorage.getItem('user')); |
| 28 | console.log(stored.name); // "Alice" |
| 29 | console.log(stored.roles); // ["admin", "editor"] |
| 30 | |
| 31 | // Safe deserialization with fallback |
| 32 | function safeParse(value, fallback = null) { |
| 33 | try { |
| 34 | return JSON.parse(value); |
| 35 | } catch { |
| 36 | return fallback; |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | // Storing primitive values (they stringify correctly) |
| 41 | localStorage.setItem('count', 42); |
| 42 | // Stored as "42" (string) |
| 43 | const count = parseInt(localStorage.getItem('count'), 10); |
| 44 | // 42 (number) |
| 45 | |
| 46 | // Boolean handling |
| 47 | localStorage.setItem('isActive', true); |
| 48 | // Stored as "true" (string) |
| 49 | const isActive = localStorage.getItem('isActive') === 'true'; |
| 50 | // true (boolean) |
| 51 | </script> |
Web Storage is not encrypted — data is stored in plain text within the browser's profile directory on disk. Any JavaScript running on the same origin can read, modify, or delete all storage data. This makes storage a prime target for XSS (Cross-Site Scripting) attacks. Never store sensitive data like authentication tokens, API keys, personal information, or credit card numbers in Web Storage.
| Threat | Risk | Mitigation |
|---|---|---|
| XSS attacks | Attacker reads all storage | Sanitize all inputs; use CSP headers |
| Shared devices | Next user can read storage | Clear storage on logout; use sessionStorage |
| Malicious extensions | Browser extensions read storage | Never store secrets; use httpOnly cookies for auth |
| Same-origin iframes | Embedded content can access storage | Verify iframe origin; use CSP frame-ancestors |
| Physical access | Storage read from disk | Never store sensitive data |
warning
Web Storage is best suited for non-sensitive, client-only data that improves user experience. Common use cases include theme preferences, form draft recovery, UI state persistence, feature flags for A/B testing, and caching computed data.
| 1 | <!-- Theme persistence with localStorage --> |
| 2 | <script> |
| 3 | // Apply saved theme on page load |
| 4 | (function initTheme() { |
| 5 | const savedTheme = localStorage.getItem('theme') || 'dark'; |
| 6 | document.documentElement.setAttribute('data-theme', savedTheme); |
| 7 | })(); |
| 8 | |
| 9 | // Toggle theme and persist |
| 10 | document.getElementById('themeToggle').addEventListener('click', () => { |
| 11 | const current = document.documentElement.getAttribute('data-theme'); |
| 12 | const next = current === 'dark' ? 'light' : 'dark'; |
| 13 | document.documentElement.setAttribute('data-theme', next); |
| 14 | localStorage.setItem('theme', next); |
| 15 | }); |
| 16 | |
| 17 | // Form draft auto-save |
| 18 | const form = document.getElementById('signupForm'); |
| 19 | const DRAFT_KEY = 'signupDraft'; |
| 20 | |
| 21 | // Auto-restore draft on page load |
| 22 | function restoreDraft() { |
| 23 | const draft = localStorage.getItem(DRAFT_KEY); |
| 24 | if (draft) { |
| 25 | const data = JSON.parse(draft); |
| 26 | Object.entries(data).forEach(([name, value]) => { |
| 27 | const input = form.elements[name]; |
| 28 | if (input) input.value = value; |
| 29 | }); |
| 30 | } |
| 31 | } |
| 32 | restoreDraft(); |
| 33 | |
| 34 | // Auto-save on input change (debounced) |
| 35 | let saveTimeout; |
| 36 | form.addEventListener('input', () => { |
| 37 | clearTimeout(saveTimeout); |
| 38 | saveTimeout = setTimeout(() => { |
| 39 | const data = new FormData(form); |
| 40 | const obj = Object.fromEntries(data.entries()); |
| 41 | localStorage.setItem(DRAFT_KEY, JSON.stringify(obj)); |
| 42 | }, 500); |
| 43 | }); |
| 44 | |
| 45 | // Clear draft on successful submit |
| 46 | form.addEventListener('submit', () => { |
| 47 | localStorage.removeItem(DRAFT_KEY); |
| 48 | }); |
| 49 | |
| 50 | // Feature flags for gradual rollout |
| 51 | const flags = { |
| 52 | newCheckout: true, |
| 53 | redesignedDashboard: false, |
| 54 | betaSearch: true, |
| 55 | }; |
| 56 | localStorage.setItem('featureFlags', JSON.stringify(flags)); |
| 57 | |
| 58 | function isFeatureEnabled(flag) { |
| 59 | const stored = localStorage.getItem('featureFlags'); |
| 60 | if (!stored) return false; |
| 61 | return JSON.parse(stored)[flag] === true; |
| 62 | } |
| 63 | |
| 64 | // UI state persistence (sidebar collapsed, panel order) |
| 65 | function saveSidebarState(collapsed) { |
| 66 | localStorage.setItem('sidebarCollapsed', collapsed); |
| 67 | } |
| 68 | function loadSidebarState() { |
| 69 | return localStorage.getItem('sidebarCollapsed') === 'true'; |
| 70 | } |
| 71 | </script> |
Live preview showing a persistent theme toggle using localStorage:
info
Choosing the right client-side storage mechanism depends on data size, lifetime, accessibility requirements, and performance needs. Each option has distinct trade-offs — Web Storage is simple and synchronous but limited to 5 MB of string data; IndexedDB is asynchronous and handles large structured data but has a more complex API; Cookies are automatically sent with requests but are limited to 4 KB.
| Feature | localStorage | sessionStorage | IndexedDB | Cookies |
|---|---|---|---|---|
| Storage Limit | ~5 MB | ~5 MB | Unlimited (usually ~50% of disk) | ~4 KB |
| Data Type | Strings only | Strings only | Any structured data (objects, blobs) | Strings only |
| Async | Synchronous (blocking) | Synchronous (blocking) | Asynchronous (non-blocking) | Synchronous |
| Auto-sent to server | No | No | No | Yes (with every request) |
| Scope | Origin | Tab / window | Origin | Origin + path (configurable) |
| Expiration | Manual clear only | On tab close | Manual clear only | Configurable (expires/max-age) |
| Accessible from | JavaScript (same-origin) | JavaScript (same-tab) | JavaScript (same-origin) | JavaScript + Server (httpOnly option) |
| API Complexity | Simple (synchronous) | Simple (synchronous) | Complex (event-based/promises) | Simple (document.cookie) |
| Indexes / Queries | No | No | Yes (indexes, range queries, cursors) | No |
| Use Case | Preferences, UI state | Form drafts, tab state | Offline data, large datasets | Auth tokens, tracking |
pro tip