Storing Complex Objects in JS LocalStorage Guide

A complete guide to storing complex objects in JavaScript LocalStorage. Covers JSON serialization, Date and RegExp reviving, Map and Set handling, circular reference detection, TTL-based expiry patterns, versioned storage with migrations, compression with LZString, and building a typed storage wrapper with schema validation.

JavaScriptintermediate
15 min read

LocalStorage only stores strings. Storing objects, arrays, Dates, Maps, Sets, and deeply nested structures requires serialization strategies that preserve type information and handle edge cases. This guide covers every pattern you need.

Basic JSON Serialization

javascriptjavascript
// Store an object
const user = {
  name: "Parth",
  age: 28,
  roles: ["admin", "editor"],
  active: true,
};
 
localStorage.setItem("user", JSON.stringify(user));
 
// Retrieve and parse
const stored = JSON.parse(localStorage.getItem("user"));
console.log(stored.name); // "Parth"
console.log(stored.roles); // ["admin", "editor"]

For the full localStorage API reference, see JS localStorage API guide: a complete tutorial.

Handling Special Types

Dates

JSON.stringify converts Dates to ISO strings but JSON.parse returns them as strings, not Date objects:

javascriptjavascript
const event = {
  title: "Conference",
  startDate: new Date("2026-06-15T09:00:00Z"),
  endDate: new Date("2026-06-15T17:00:00Z"),
};
 
localStorage.setItem("event", JSON.stringify(event));
 
// Without reviver: dates are strings
const raw = JSON.parse(localStorage.getItem("event"));
console.log(typeof raw.startDate); // "string"
 
// With reviver: dates are restored
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
 
function dateReviver(key, value) {
  if (typeof value === "string" && ISO_DATE_RE.test(value)) {
    return new Date(value);
  }
  return value;
}
 
const parsed = JSON.parse(localStorage.getItem("event"), dateReviver);
console.log(parsed.startDate instanceof Date); // true
console.log(parsed.startDate.toLocaleDateString()); // "6/15/2026"

Maps and Sets

javascriptjavascript
// Custom replacer and reviver for Map and Set
function advancedReplacer(key, value) {
  if (value instanceof Map) {
    return { __type: "Map", entries: [...value.entries()] };
  }
  if (value instanceof Set) {
    return { __type: "Set", values: [...value.values()] };
  }
  if (value instanceof Date) {
    return { __type: "Date", iso: value.toISOString() };
  }
  if (value instanceof RegExp) {
    return { __type: "RegExp", source: value.source, flags: value.flags };
  }
  return value;
}
 
function advancedReviver(key, value) {
  if (value && typeof value === "object" && value.__type) {
    switch (value.__type) {
      case "Map":
        return new Map(value.entries);
      case "Set":
        return new Set(value.values);
      case "Date":
        return new Date(value.iso);
      case "RegExp":
        return new RegExp(value.source, value.flags);
    }
  }
  return value;
}
 
// Usage
const data = {
  tags: new Set(["javascript", "tutorial", "storage"]),
  metadata: new Map([
    ["version", 2],
    ["author", "Parth"],
  ]),
  created: new Date(),
};
 
localStorage.setItem("data", JSON.stringify(data, advancedReplacer));
const restored = JSON.parse(localStorage.getItem("data"), advancedReviver);
 
console.log(restored.tags instanceof Set); // true
console.log(restored.metadata instanceof Map); // true
console.log(restored.created instanceof Date); // true

Serialization Support Matrix

TypeJSON.stringifyNeeds ReplacerNeeds Reviver
String, Number, BooleanYesNoNo
Array, Plain ObjectYesNoNo
DateISO stringOptionalYes
Map{} (lost)YesYes
Set{} (lost)YesYes
RegExp{} (lost)YesYes
FunctionOmittedN/AN/A
undefinedOmittedN/AN/A
SymbolOmittedN/AN/A

Circular Reference Detection

javascriptjavascript
function safeStringify(obj, replacer = null, space = 0) {
  const seen = new WeakSet();
 
  return JSON.stringify(obj, function (key, value) {
    if (typeof value === "object" && value !== null) {
      if (seen.has(value)) {
        return "[Circular]";
      }
      seen.add(value);
    }
 
    if (replacer) {
      return replacer.call(this, key, value);
    }
    return value;
  }, space);
}
 
// Safe with circular references
const a = { name: "A" };
const b = { name: "B", ref: a };
a.ref = b; // circular
 
const json = safeStringify(a);
console.log(json); // {"name":"A","ref":{"name":"B","ref":"[Circular]"}}

TTL-Based Expiry Pattern

javascriptjavascript
class ExpiringStorage {
  static set(key, value, ttlMs) {
    const entry = {
      value,
      expiresAt: Date.now() + ttlMs,
      storedAt: Date.now(),
    };
    localStorage.setItem(key, JSON.stringify(entry, advancedReplacer));
  }
 
  static get(key) {
    const raw = localStorage.getItem(key);
    if (!raw) return null;
 
    try {
      const entry = JSON.parse(raw, advancedReviver);
 
      if (entry.expiresAt && Date.now() > entry.expiresAt) {
        localStorage.removeItem(key);
        return null;
      }
 
      return entry.value;
    } catch {
      return null;
    }
  }
 
  static getRemainingTTL(key) {
    const raw = localStorage.getItem(key);
    if (!raw) return 0;
 
    try {
      const entry = JSON.parse(raw);
      const remaining = entry.expiresAt - Date.now();
      return remaining > 0 ? remaining : 0;
    } catch {
      return 0;
    }
  }
 
  static clearExpired() {
    const keysToRemove = [];
    for (let i = 0; i < localStorage.length; i++) {
      const key = localStorage.key(i);
      try {
        const entry = JSON.parse(localStorage.getItem(key));
        if (entry.expiresAt && Date.now() > entry.expiresAt) {
          keysToRemove.push(key);
        }
      } catch {
        // Not an expiring entry, skip
      }
    }
    keysToRemove.forEach((k) => localStorage.removeItem(k));
    return keysToRemove.length;
  }
}
 
// Cache API responses for 5 minutes
ExpiringStorage.set("apiData", { results: [1, 2, 3] }, 5 * 60 * 1000);
 
// Later...
const cached = ExpiringStorage.get("apiData"); // null if expired

Versioned Storage With Migrations

javascriptjavascript
class VersionedStorage {
  constructor(key, currentVersion, migrations) {
    this.key = key;
    this.currentVersion = currentVersion;
    this.migrations = migrations;
  }
 
  save(data) {
    const envelope = {
      version: this.currentVersion,
      data,
      updatedAt: new Date().toISOString(),
    };
    localStorage.setItem(this.key, JSON.stringify(envelope, advancedReplacer));
  }
 
  load(defaultValue = null) {
    const raw = localStorage.getItem(this.key);
    if (!raw) return defaultValue;
 
    try {
      let envelope = JSON.parse(raw, advancedReviver);
      const storedVersion = envelope.version || 1;
 
      // Run migrations if needed
      if (storedVersion < this.currentVersion) {
        let data = envelope.data;
        for (let v = storedVersion; v < this.currentVersion; v++) {
          if (this.migrations[v]) {
            data = this.migrations[v](data);
          }
        }
        // Save migrated data
        this.save(data);
        return data;
      }
 
      return envelope.data;
    } catch {
      return defaultValue;
    }
  }
}
 
// Define migrations
const settingsStore = new VersionedStorage("settings", 3, {
  1: (data) => ({ ...data, theme: data.darkMode ? "dark" : "light" }),
  2: (data) => ({
    ...data,
    notifications: { email: true, push: data.notificationsEnabled ?? true },
  }),
});
 
// Save current version
settingsStore.save({
  theme: "dark",
  fontSize: 16,
  language: "en",
  notifications: { email: true, push: false },
});
 
// Load with automatic migration
const settings = settingsStore.load({ theme: "light", fontSize: 14 });

Typed Storage Wrapper

javascriptjavascript
class TypedStorage {
  constructor(prefix = "") {
    this.prefix = prefix;
  }
 
  getKey(key) {
    return this.prefix ? `${this.prefix}:${key}` : key;
  }
 
  getString(key, fallback = "") {
    return localStorage.getItem(this.getKey(key)) ?? fallback;
  }
 
  setString(key, value) {
    localStorage.setItem(this.getKey(key), value);
  }
 
  getNumber(key, fallback = 0) {
    const raw = localStorage.getItem(this.getKey(key));
    if (raw === null) return fallback;
    const num = Number(raw);
    return Number.isNaN(num) ? fallback : num;
  }
 
  setNumber(key, value) {
    localStorage.setItem(this.getKey(key), String(value));
  }
 
  getBoolean(key, fallback = false) {
    const raw = localStorage.getItem(this.getKey(key));
    if (raw === null) return fallback;
    return raw === "true";
  }
 
  setBoolean(key, value) {
    localStorage.setItem(this.getKey(key), String(value));
  }
 
  getObject(key, fallback = null) {
    const raw = localStorage.getItem(this.getKey(key));
    if (raw === null) return fallback;
    try {
      return JSON.parse(raw, advancedReviver);
    } catch {
      return fallback;
    }
  }
 
  setObject(key, value) {
    localStorage.setItem(
      this.getKey(key),
      JSON.stringify(value, advancedReplacer)
    );
  }
 
  remove(key) {
    localStorage.removeItem(this.getKey(key));
  }
}
 
// Usage
const store = new TypedStorage("myapp");
store.setNumber("visitCount", 42);
store.setBoolean("darkMode", true);
store.setObject("profile", { name: "Parth", tags: new Set(["js", "ts"]) });
 
console.log(store.getNumber("visitCount")); // 42
console.log(store.getBoolean("darkMode")); // true
console.log(store.getObject("profile").tags); // Set {"js", "ts"}

Common Pitfalls

PitfallProblemSolution
Storing objects directlyBecomes "[object Object]"Use JSON.stringify
Not parsing on readReturns a string, not objectUse JSON.parse
Date deserializationDates become stringsUse a reviver function
Circular referencesJSON.stringify throwsUse a WeakSet-based replacer
Quota exceededWrites silently fail or throwWrap in try/catch, evict old data
No versioningSchema changes break readsUse versioned envelopes with migrations
Rune AI

Rune AI

Key Insights

  • Always serialize with JSON.stringify: Direct storage of objects produces "[object Object]"; parse back with JSON.parse on every read
  • Custom replacer/reviver for special types: Dates, Maps, Sets, and RegExp lose their type through standard JSON serialization; tag them with __type markers
  • TTL expiry for caching: Store expiresAt timestamps alongside values to implement automatic cache invalidation without server-side logic
  • Versioned envelopes for schema changes: Wrap stored data with a version number and run migrations on read to handle evolving data structures
  • Circular reference safety: Use a WeakSet in a custom replacer to detect and handle circular references instead of letting JSON.stringify throw
RunePowered by Rune AI

Frequently Asked Questions

How do I store an array in localStorage?

Use `JSON.stringify` to serialize the array and `JSON.parse` to restore it. Arrays of primitives work directly. Arrays of objects with Dates or Maps need the custom replacer/reviver patterns shown above.

Can I store class instances in localStorage?

Not directly. `JSON.stringify` strips methods and prototype chain. Serialize the instance data as a plain object, then reconstruct the instance on read. Implement `toJSON()` on your class for custom serialization.

How do I sync localStorage objects across tabs?

Use the `storage` event. It fires in other tabs when localStorage changes. Parse the `event.newValue` to get the updated object. See [JS localStorage API guide: a complete tutorial](/tutorials/programming-languages/javascript/js-localstorage-api-guide-a-complete-tutorial) for the storage event details.

What happens if localStorage is full and I try to store an object?

The browser throws a `QuotaExceededError`. Always wrap `setItem` in a `try/catch`. Implement eviction strategies (LRU, TTL-based) to free space. Consider compressing large objects or moving to IndexedDB.

Is there a size limit per key or only per origin?

The ~5MB limit applies to the entire origin (all keys combined). Individual keys can be any size up to the total limit. Large values in a single key are as valid as many small keys.

Conclusion

Storing complex objects in localStorage requires thoughtful serialization. Use JSON.stringify/JSON.parse with custom replacer and reviver functions for Dates, Maps, Sets, and RegExp. Add circular reference detection for recursive structures, TTL patterns for cache expiry, and versioned envelopes for schema evolution. For session-scoped storage, see JS sessionStorage API guide complete tutorial. For cookie-based storage, see how to manage cookies in JS complete tutorial.