Programming Beginner 7 min

How to Persist Data in localStorage Safely

localStorage is the simplest way to persist data across page refreshes in the browser — no server, no database. It stores key-value pairs as strings, survives tab closes and browser restarts, and is synchronous to read and write.

But naive usage breaks in predictable ways: calling JSON.parse on a missing key, hitting the 5 MB size limit silently, or storing sensitive data that any JavaScript on the page can read. This guide covers safe read/write helpers, the edge cases that cause crashes, what to never store, and how to react to changes across browser tabs.

Step-by-step

  1. 1

    Understand the API basics

    localStorage stores everything as a string. To persist objects or arrays you must serialize them with JSON.stringify on write and deserialize with JSON.parse on read. Values that cannot be serialized (functions, undefined, circular references) are silently dropped or converted.

    javascript
    // Write
    localStorage.setItem('theme', 'dark');
    localStorage.setItem('user', JSON.stringify({ name: 'Alice', age: 30 }));
    
    // Read
    const theme = localStorage.getItem('theme');         // 'dark' or null
    const user  = JSON.parse(localStorage.getItem('user')); // object or null
    
    // Delete one key
    localStorage.removeItem('theme');
    
    // Wipe everything (careful in production)
    localStorage.clear();
  2. 2

    Know when JSON.parse crashes

    JSON.parse(null) returns null safely — getItem returns null for missing keys, and null coerces to the string "null" which parses fine. But JSON.parse(undefined) throws a SyntaxError, and so does parsing a corrupted string. Never read without guarding.

    javascript
    // Safe — null is valid input for JSON.parse
    JSON.parse(null);        // → null
    JSON.parse('null');      // → null
    
    // These crash:
    JSON.parse(undefined);   // SyntaxError: Unexpected token u
    JSON.parse('');          // SyntaxError: Unexpected end of JSON input
    JSON.parse('{bad json'); // SyntaxError
  3. 3

    Write a safe getItem helper

    Wrap reads in try/catch and provide a default value. This handles missing keys, corrupted data, and the rare case where localStorage is unavailable (private browsing in some browsers, or security policies).

    javascript
    function getItem(key, defaultValue = null) {
      try {
        const raw = localStorage.getItem(key);
        if (raw === null) return defaultValue;
        return JSON.parse(raw);
      } catch {
        return defaultValue;
      }
    }
    
    // Usage
    const prefs = getItem('user-prefs', { theme: 'light', lang: 'en' });
    const count = getItem('visit-count', 0);
  4. 4

    Write a safe setItem helper

    localStorage.setItem throws a QuotaExceededError when the 5 MB limit is hit. Without a guard, this crashes your app silently. Wrap in try/catch — at minimum log the failure so you know it happened.

    javascript
    function setItem(key, value) {
      try {
        localStorage.setItem(key, JSON.stringify(value));
      } catch (err) {
        if (err.name === 'QuotaExceededError') {
          console.warn('localStorage quota exceeded. Consider evicting old data.');
          // Optional: remove the oldest entry and retry
        } else {
          console.error('localStorage write failed:', err);
        }
      }
    }
    
    // Usage
    setItem('user-prefs', { theme: 'dark', lang: 'ar' });
    setItem('cart', [{ id: 1, qty: 2 }, { id: 5, qty: 1 }]);
  5. 5

    Respect the 5 MB size limit

    The limit is per origin and shared across all keys. Large JSON blobs (images encoded as base64, entire API responses) exhaust it quickly. Store only what you need across sessions — IDs, preferences, short strings. For larger offline storage, use IndexedDB.

    javascript
    // Rough size check before writing
    function estimatedSize(value) {
      return new Blob([JSON.stringify(value)]).size; // Bytes
    }
    
    // Check current total usage
    function storageUsed() {
      let total = 0;
      for (let i = 0; i < localStorage.length; i++) {
        const key = localStorage.key(i);
        total += (key.length + (localStorage.getItem(key) ?? '').length) * 2; // UTF-16
      }
      return total;
    }
    
    console.log(`Storage used: ${(storageUsed() / 1024).toFixed(1)} KB`);
  6. 6

    Never store secrets or tokens

    localStorage is accessible to any JavaScript running on the page — including third-party scripts, ads, and injected XSS. Never store authentication tokens, session cookies, passwords, API keys, or personal data there. Use HttpOnly cookies for auth tokens instead; the browser sends them automatically and JavaScript cannot read them.

  7. 7

    Listen for changes across tabs with the storage event

    The storage event fires in every other open tab when a key is changed — not in the tab that made the change. Use it to sync state (theme, auth, cart) across tabs without polling.

    javascript
    window.addEventListener('storage', (event) => {
      // event.key       — the key that changed (null if clear() was called)
      // event.oldValue  — previous value (string)
      // event.newValue  — new value (string, or null if removed)
      // event.storageArea — the storage object that changed
      // event.url       — URL of the page that made the change
    
      if (event.key === 'theme') {
        const theme = JSON.parse(event.newValue ?? 'null') ?? 'light';
        document.documentElement.setAttribute('data-theme', theme);
        console.log(`Theme synced from another tab: ${theme}`);
      }
    
      if (event.key === null) {
        // localStorage.clear() was called — reset all state
        resetAppState();
      }
    });

Tips & gotchas

  • Prefix your keys with an app namespace (e.g., <code>myapp:theme</code>) to avoid collisions with third-party scripts or other projects on the same origin.
  • <code>sessionStorage</code> has the same API but clears when the tab closes — use it for data that should not outlive the session.
  • Always version your stored data schema. Add a <code>_version</code> field and migrate or clear on a version mismatch rather than crashing on unexpected shapes.
  • In unit tests, mock <code>localStorage</code> with a simple object or the <code>jest-localstorage-mock</code> package — never rely on the real storage in tests.
  • The <code>storage</code> event does not fire in the same tab that made the change — only in other tabs. Do not use it for same-tab synchronisation.

Wrapping up

localStorage is useful precisely because it is simple — but that simplicity hides a few real failure modes. Use the safe getItem/setItem helpers, keep the data small and non-sensitive, and you get a reliable client-side persistence layer with zero dependencies and no setup.

#JavaScript #Storage #Browser
Back to all guides

Need Help With Your Project?

Book a free 30-minute consultation to discuss your technical challenges and explore solutions together.