Store JSON in localStorage

localStorage only stores strings — but most real-world data is objects and arrays. Bridging the gap requires two functions: JSON.stringify() to serialize a value before writing, and JSON.parse() to deserialize it when reading back. This guide covers the full pattern, from the basic set/get cycle to error handling, expiry simulation, a React hook, and security boundaries you should never cross.

Validate your JSON before storing it in localStorage.

Open JSON Formatter

Why localStorage requires JSON.stringify and JSON.parse

The Web Storage API — both localStorage and sessionStorage — is a simple key/value store where every key and every value must be a string. If you pass a non-string value, the browser silently coerces it with .toString(), which produces "[object Object]" for plain objects and discards all your data. The only way to faithfully round-trip an object or array through localStorage is to serialize it with JSON.stringify() and deserialize it with JSON.parse():

// ❌ Wrong — stores "[object Object]"
localStorage.setItem('user', { name: 'Alice' });
localStorage.getItem('user'); // "[object Object]"

// ✅ Correct — serialize first, deserialize on read
localStorage.setItem('user', JSON.stringify({ name: 'Alice' }));
const user = JSON.parse(localStorage.getItem('user'));
console.log(user.name); // "Alice"

localStorage capacity is approximately 5 MB per origin in Chrome, Firefox, and Safari (the exact limit varies slightly by browser and platform). The storage is synchronous — reads and writes block the main thread, so avoid storing very large JSON blobs that would cause noticeable jank.

Store and retrieve an object (basic pattern)

The standard pattern wraps the raw localStorage API in two small helper functions — one that serializes on write, one that deserializes on read — so the rest of your code never touches raw strings:

// helpers.js

/** Serialize a value and write it to localStorage */
function setItem(key, value) {
  localStorage.setItem(key, JSON.stringify(value));
}

/** Read a value from localStorage and deserialize it */
function getItem(key, fallback = null) {
  const raw = localStorage.getItem(key);
  if (raw === null) return fallback; // key not found
  return JSON.parse(raw);
}

// Usage
const settings = {
  theme: 'dark',
  fontSize: 16,
  notifications: true,
};

setItem('settings', settings);

const loaded = getItem('settings', {});
console.log(loaded.theme);     // "dark"
console.log(loaded.fontSize);  // 16

// Remove a key
localStorage.removeItem('settings');

// Clear all keys for this origin
localStorage.clear();

Returning a fallback when getItem returns null prevents downstream code from having to null-check every access. Note that JSON.parse(null) safely returns null, but calling getItem on a missing key already returns the JavaScript null primitive — not the string "null".

Store and retrieve an array

Arrays are serialized and parsed exactly like objects. The only practical difference is the fallback default — use [] instead of {} so callers can safely call array methods on the result without an extra type check:

// Store a shopping cart as an array of objects
const cart = [
  { id: 1, name: 'Widget', qty: 2 },
  { id: 2, name: 'Gadget', qty: 1 },
];

localStorage.setItem('cart', JSON.stringify(cart));

// Retrieve and use immediately as an array
const loaded = JSON.parse(localStorage.getItem('cart') ?? '[]');
console.log(loaded.length);       // 2
console.log(loaded[0].name);      // "Widget"

// Add an item and save back
loaded.push({ id: 3, name: 'Doohickey', qty: 5 });
localStorage.setItem('cart', JSON.stringify(loaded));

// Store a simple array of strings
const tags = ['javascript', 'web', 'storage'];
localStorage.setItem('tags', JSON.stringify(tags));
const savedTags = JSON.parse(localStorage.getItem('tags') ?? '[]');
console.log(savedTags.includes('web')); // true

The nullish coalescing operator ?? guards against JSON.parse(null) when the key is absent — though JSON.parse(null) actually returns null rather than throwing, it is cleaner to provide the string fallback '[]' and parse that instead.

Error handling: QuotaExceededError and parse errors

Two distinct errors can occur when working with JSON and localStorage: a QuotaExceededError thrown by setItem when the ~5 MB origin quota is full, and a SyntaxError thrown by JSON.parse when the stored string is corrupted or was written by something other than JSON.stringify. A robust wrapper handles both:

/**
 * Write a JSON-serializable value to localStorage.
 * Returns true on success, false if the quota is exceeded.
 */
function safeSetItem(key, value) {
  try {
    localStorage.setItem(key, JSON.stringify(value));
    return true;
  } catch (err) {
    if (
      err instanceof DOMException &&
      (err.name === 'QuotaExceededError' ||
        err.name === 'NS_ERROR_DOM_QUOTA_REACHED')
    ) {
      console.warn('localStorage quota exceeded. Could not save:', key);
    } else {
      console.error('localStorage write error:', err);
    }
    return false;
  }
}

/**
 * Read and deserialize a value from localStorage.
 * Returns the fallback if the key is missing or the value is unparseable.
 */
function safeGetItem(key, fallback = null) {
  try {
    const raw = localStorage.getItem(key);
    if (raw === null) return fallback;
    return JSON.parse(raw);
  } catch (err) {
    // JSON.parse throws SyntaxError for malformed strings
    console.warn(`Could not parse localStorage key "${key}":`, err);
    return fallback;
  }
}

// Usage
const ok = safeSetItem('prefs', { color: 'blue' });
if (!ok) {
  alert('Could not save preferences — storage is full.');
}

const prefs = safeGetItem('prefs', { color: 'default' });
console.log(prefs.color); // "blue"

// JSON.parse(null) → null (safe, no throw)
// JSON.parse(undefined) → SyntaxError (caught above)
// JSON.parse('bad json') → SyntaxError (caught above)

Firefox reports the quota error as NS_ERROR_DOM_QUOTA_REACHED rather than QuotaExceededError, so checking both names ensures cross-browser coverage. The DOMException instance check guards against accidentally catching unrelated runtime errors.

sessionStorage vs localStorage comparison

Both APIs share identical method signatures — setItem, getItem, removeItem, clear, and key(n) — and the same JSON.stringify/JSON.parse requirement. The critical difference is lifetime:

FeaturelocalStoragesessionStorage
Persists after browser closeYesNo — cleared when tab closes
Shared across tabs (same origin)YesNo — each tab has its own copy
Capacity~5 MB per origin~5 MB per origin
Data typeStrings onlyStrings only
APIlocalStorage.setItem()sessionStorage.setItem()
Accessible to JS on same originYesYes
Sent with HTTP requestsNoNo
Typical use caseUser preferences, cached dataMulti-step form state, one-session cart
// localStorage — persists across browser sessions
localStorage.setItem('theme', JSON.stringify({ mode: 'dark' }));

// sessionStorage — cleared when the tab is closed
sessionStorage.setItem('wizard', JSON.stringify({ step: 2, data: {} }));

// The API is identical — swap the prefix to switch storage type
const theme = JSON.parse(localStorage.getItem('theme') ?? 'null');
const wizard = JSON.parse(sessionStorage.getItem('wizard') ?? 'null');

Choose localStorage when the data should outlive the browser session — user preferences, a cached API response, or a partially completed form a user might return to later. Choose sessionStorage for transient state that should reset on every fresh visit, such as a checkout flow or a paginated query that should restart from page one.

Simulate expiry with a timestamp wrapper

localStorage has no built-in TTL (time to live) mechanism — items never expire on their own. The common workaround is to wrap the value in an envelope object that includes an expiry timestamp. On read, check whether the item has expired and delete it if so:

/**
 * Write a value with an expiry time.
 * @param {string} key
 * @param {*} value  Any JSON-serializable value.
 * @param {number} ttlMs  Time to live in milliseconds.
 */
function setWithTTL(key, value, ttlMs) {
  const envelope = {
    value,
    expiresAt: Date.now() + ttlMs,
  };
  localStorage.setItem(key, JSON.stringify(envelope));
}

/**
 * Read a value. Returns null if the key is missing or has expired.
 * Automatically removes expired keys.
 */
function getWithTTL(key) {
  const raw = localStorage.getItem(key);
  if (raw === null) return null;

  try {
    const envelope = JSON.parse(raw);
    if (Date.now() > envelope.expiresAt) {
      localStorage.removeItem(key); // clean up
      return null;
    }
    return envelope.value;
  } catch {
    return null;
  }
}

// Cache an API response for 10 minutes
const TEN_MINUTES = 10 * 60 * 1000;
setWithTTL('api:products', [{ id: 1, name: 'Widget' }], TEN_MINUTES);

// Later — returns the cached value or null if expired
const products = getWithTTL('api:products');
if (products === null) {
  // fetch fresh data from the API
}

This pattern is widely used for caching API responses or rate-limiting feature flags. The envelope object is transparent — the caller receives the original value directly, not the wrapper. Expired items are removed lazily on the next read, so there is no background timer or cleanup loop needed.

React pattern: useLocalStorage custom hook

In React applications, a useLocalStorage custom hook encapsulates the serialize/deserialize cycle and keeps component state in sync with the stored value. The hook behaves like useState but persists the value across page reloads:

import { useState, useEffect, useCallback } from 'react';

/**
 * A drop-in replacement for useState that persists to localStorage.
 * @param {string} key  The localStorage key.
 * @param {*} initialValue  Default value when the key is not yet stored.
 */
function useLocalStorage(key, initialValue) {
  // Initialize state from localStorage (or fall back to initialValue)
  const [storedValue, setStoredValue] = useState(() => {
    if (typeof window === 'undefined') return initialValue; // SSR guard
    try {
      const raw = window.localStorage.getItem(key);
      return raw !== null ? JSON.parse(raw) : initialValue;
    } catch {
      return initialValue;
    }
  });

  // Persist whenever the value changes
  useEffect(() => {
    if (typeof window === 'undefined') return;
    try {
      window.localStorage.setItem(key, JSON.stringify(storedValue));
    } catch (err) {
      console.warn('useLocalStorage: could not persist', key, err);
    }
  }, [key, storedValue]);

  // Expose a setter that mirrors the useState signature
  const setValue = useCallback((value) => {
    setStoredValue((prev) =>
      typeof value === 'function' ? value(prev) : value
    );
  }, []);

  // Expose a way to remove the key entirely
  const removeValue = useCallback(() => {
    window.localStorage.removeItem(key);
    setStoredValue(initialValue);
  }, [key, initialValue]);

  return [storedValue, setValue, removeValue];
}

// ── Usage in a component ──────────────────────────────────────────
function ThemeToggle() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');

  return (
    <button onClick={() => setTheme((t) => (t === 'light' ? 'dark' : 'light'))}>
      Current theme: {theme}
    </button>
  );
}

function UserProfile() {
  const [profile, setProfile] = useLocalStorage('profile', { name: '', age: 0 });

  return (
    <input
      value={profile.name}
      onChange={(e) => setProfile((p) => ({ ...p, name: e.target.value }))}
    />
  );
}

The typeof window === 'undefined' guard prevents crashes during server-side rendering (Next.js, Remix) where localStorage does not exist. The functional updater form — setValue(prev => ...) — mirrors React's own setState API and avoids stale closure bugs.

What NOT to store in localStorage (security)

localStorage is accessible to every JavaScript snippet running on the same origin — including third-party analytics, ad scripts, and any code injected via a Cross-Site Scripting (XSS) vulnerability. There is no access control beyond the browser's same-origin policy. As a result, certain categories of data must never be stored in localStorage:

// ❌ NEVER store these in localStorage

// Authentication tokens — readable by any JS on the page
localStorage.setItem('access_token', jwt);           // vulnerable to XSS
localStorage.setItem('refresh_token', refreshJwt);   // same risk

// Passwords or password hashes
localStorage.setItem('password', hashedPassword);    // never do this

// Sensitive personal data
localStorage.setItem('ssn', '123-45-6789');          // PII — serious risk
localStorage.setItem('credit_card', '4111...');      // payment data

// ✅ Safe to store in localStorage

// User preferences (non-sensitive)
localStorage.setItem('theme', JSON.stringify('dark'));
localStorage.setItem('lang', JSON.stringify('en'));

// Cached public API data
localStorage.setItem('products', JSON.stringify(publicProducts));

// UI state
localStorage.setItem('sidebarOpen', JSON.stringify(true));

Store authentication tokens in HttpOnly cookies instead — the browser sends them automatically with requests, but JavaScript cannot read them at all, eliminating the XSS attack surface. For sensitive user data that must live client-side (rare), consider the Web Crypto API to encrypt before storing, though this only raises the bar and is not a complete solution without a server-managed key.

Frequently asked questions

Can I store objects directly in localStorage without JSON.stringify?

No. localStorage only stores strings. If you pass an object directly to localStorage.setItem(), JavaScript automatically calls .toString() on it, producing the useless string "[object Object]". You must use JSON.stringify() to serialize the object first, then JSON.parse() when reading it back.

How do I check if localStorage is available?

Wrap a test write in a try/catch. This covers private browsing modes (where localStorage may be present but throws on write), storage disabled by browser settings, and server-side rendering environments where window is not defined:

function isLocalStorageAvailable() {
  try {
    localStorage.setItem('__test__', '1');
    localStorage.removeItem('__test__');
    return true;
  } catch {
    return false;
  }
}

if (isLocalStorageAvailable()) {
  // safe to use localStorage
}

What happens when localStorage is full?

When the ~5 MB origin quota is exceeded, localStorage.setItem() throws a QuotaExceededError (also called NS_ERROR_DOM_QUOTA_REACHED in Firefox). The write fails and the existing data remains unchanged. Catch this error specifically and either clear stale keys or inform the user that the operation could not be saved.

Is localStorage safe for storing JWT tokens?

No. localStorage is accessible to any JavaScript running on the same origin, making tokens stored there vulnerable to Cross-Site Scripting (XSS) attacks. A malicious script injected into the page can read and exfiltrate the token. Store authentication tokens in HttpOnly cookies instead, which are invisible to JavaScript.

How do I store and retrieve a JavaScript array in localStorage?

Use JSON.stringify() to serialize the array before storing, and JSON.parse() to deserialize it when reading:

// Store
const ids = [1, 2, 3, 4];
localStorage.setItem('ids', JSON.stringify(ids));

// Retrieve — fall back to empty array if key is missing
const saved = JSON.parse(localStorage.getItem('ids') ?? '[]');
console.log(saved); // [1, 2, 3, 4]

The nullish coalescing fallback '[]' prevents JSON.parse from receiving null when the key does not exist. Note that JSON.parse(null) returns null rather than throwing, but providing an explicit fallback string makes the intent clearer and avoids null-propagation bugs downstream.

What is the difference between localStorage and sessionStorage?

Both use the same string-only key/value API and share a ~5 MB per-origin quota. The key difference is lifetime: localStorage persists indefinitely after the browser is closed and reopened. sessionStorage is cleared automatically when the browser tab is closed. Use localStorage for user preferences and cached data that should survive sessions; use sessionStorage for temporary state like a multi-step form that should not persist between visits.

Validate your JSON before storing

Paste your JSON into Jsonic to catch syntax errors before calling JSON.stringify — invalid JSON silently stored and later parsed can crash your app.

Open JSON Formatter