The Web Storage API: Local vs Session Storage

A complete comparison of the Web Storage API covering localStorage vs sessionStorage. Covers persistence differences, storage events, quota limits, when to use each, migration patterns, combined storage strategies, typed wrappers, and building a unified storage manager.

JavaScriptintermediate
16 min read

The Web Storage API provides two storage mechanisms: localStorage for persistent data across browser sessions and sessionStorage for data tied to a single tab or window. Both share the same interface but differ in lifetime, scope, and cross-tab behavior.

For deep dives on each, see JS localStorage API Guide and JS sessionStorage API Guide.

Core Differences

FeaturelocalStoragesessionStorage
PersistenceUntil explicitly deletedUntil tab/window closes
ScopeShared across all tabs on same originIsolated to the specific tab
Storage eventFires in other tabsDoes not fire in other tabs
QuotaTypically 5-10 MB per originTypically 5-10 MB per origin
Survives page refreshYesYes
Survives browser restartYesNo
Duplicating a tabNot affectedNew tab gets a copy of the data

Shared API Surface

Both localStorage and sessionStorage implement the Storage interface:

javascriptjavascript
// These methods work identically on both
function demonstrateStorageAPI(storage) {
  // Set items
  storage.setItem("username", "alice");
  storage.setItem("theme", "dark");
 
  // Get items
  const username = storage.getItem("username"); // "alice"
  const missing = storage.getItem("nonexistent"); // null
 
  // Check length
  console.log(storage.length); // 2
 
  // Get key by index
  console.log(storage.key(0)); // "username" (order not guaranteed)
 
  // Remove specific item
  storage.removeItem("theme");
 
  // Clear everything
  storage.clear();
}
 
// Same API, different behavior
demonstrateStorageAPI(localStorage);
demonstrateStorageAPI(sessionStorage);

When to Use Each

javascriptjavascript
// localStorage: data that persists across sessions
localStorage.setItem("preferred-language", "en");
localStorage.setItem("cookie-consent", JSON.stringify({
  accepted: true,
  timestamp: Date.now(),
}));
 
// sessionStorage: data for the current workflow only
sessionStorage.setItem("checkout-step", "2");
sessionStorage.setItem("form-draft", JSON.stringify({
  name: "Alice",
  email: "alice@example.com",
}));
Use CaseBest StorageReason
User preferences (theme, language)localStorageMust survive browser restart
Authentication tokensNeither (use httpOnly cookies)Storage is accessible to XSS
Form drafts during checkoutsessionStorageDiscard after tab closes
Shopping cartlocalStoragePersist for returning users
Wizard/multi-step form progresssessionStorageScoped to the current flow
Feature flags cachelocalStorageAvoid re-fetching on every visit
Scroll position for back navigationsessionStorageOnly relevant for current session

Storage Events for Cross-Tab Sync

localStorage fires storage events in other tabs on the same origin. sessionStorage does not fire cross-tab events.

javascriptjavascript
// Tab A: write data
localStorage.setItem("sync-message", JSON.stringify({
  type: "theme-changed",
  value: "dark",
  timestamp: Date.now(),
}));
 
// Tab B: listen for changes
window.addEventListener("storage", (event) => {
  if (event.storageArea !== localStorage) return;
  if (event.key !== "sync-message") return;
 
  const message = JSON.parse(event.newValue);
  console.log("Received from another tab:", message);
 
  if (message.type === "theme-changed") {
    document.documentElement.setAttribute("data-theme", message.value);
  }
});

Cross-Tab Communication via localStorage

javascriptjavascript
class CrossTabChannel {
  constructor(channelName) {
    this.channelName = `channel:${channelName}`;
    this.listeners = new Set();
 
    window.addEventListener("storage", (event) => {
      if (event.key !== this.channelName) return;
      if (!event.newValue) return;
 
      try {
        const message = JSON.parse(event.newValue);
        for (const listener of this.listeners) {
          listener(message);
        }
      } catch {
        // Ignore malformed messages
      }
    });
  }
 
  send(data) {
    const message = {
      data,
      id: crypto.randomUUID(),
      timestamp: Date.now(),
    };
    localStorage.setItem(this.channelName, JSON.stringify(message));
    // Remove immediately so the same message can be sent again
    localStorage.removeItem(this.channelName);
  }
 
  onMessage(callback) {
    this.listeners.add(callback);
    return () => this.listeners.delete(callback);
  }
}
 
// Tab A
const channel = new CrossTabChannel("app-events");
channel.send({ type: "user-logout" });
 
// Tab B
const channel2 = new CrossTabChannel("app-events");
channel2.onMessage((msg) => {
  if (msg.data.type === "user-logout") {
    window.location.href = "/auth";
  }
});

Typed Storage Wrapper

Both storage APIs only store strings. A typed wrapper handles serialization:

javascriptjavascript
class TypedStorage {
  constructor(storage) {
    this.storage = storage;
  }
 
  get(key, fallback = null) {
    const raw = this.storage.getItem(key);
    if (raw === null) return fallback;
 
    try {
      return JSON.parse(raw);
    } catch {
      return raw; // Return raw string if not valid JSON
    }
  }
 
  set(key, value) {
    if (value === undefined) {
      this.storage.removeItem(key);
      return;
    }
    this.storage.setItem(key, JSON.stringify(value));
  }
 
  remove(key) {
    this.storage.removeItem(key);
  }
 
  has(key) {
    return this.storage.getItem(key) !== null;
  }
 
  keys() {
    const result = [];
    for (let i = 0; i < this.storage.length; i++) {
      result.push(this.storage.key(i));
    }
    return result;
  }
 
  clear() {
    this.storage.clear();
  }
 
  getUsedBytes() {
    let total = 0;
    for (let i = 0; i < this.storage.length; i++) {
      const key = this.storage.key(i);
      total += key.length + this.storage.getItem(key).length;
    }
    return total * 2; // UTF-16
  }
}
 
// Create typed instances for each storage
const local = new TypedStorage(localStorage);
const session = new TypedStorage(sessionStorage);
 
// Store and retrieve typed data
local.set("settings", { theme: "dark", fontSize: 16 });
const settings = local.get("settings"); // { theme: "dark", fontSize: 16 }
 
session.set("cart", [{ id: 1, qty: 2 }]);
const cart = session.get("cart"); // [{ id: 1, qty: 2 }]

Migration Between Storage Types

javascriptjavascript
function migrateToLocal(keys) {
  const migrated = [];
 
  keys.forEach((key) => {
    const value = sessionStorage.getItem(key);
    if (value !== null) {
      localStorage.setItem(key, value);
      sessionStorage.removeItem(key);
      migrated.push(key);
    }
  });
 
  return migrated;
}
 
function migrateToSession(keys) {
  const migrated = [];
 
  keys.forEach((key) => {
    const value = localStorage.getItem(key);
    if (value !== null) {
      sessionStorage.setItem(key, value);
      localStorage.removeItem(key);
      migrated.push(key);
    }
  });
 
  return migrated;
}
 
// Migrate cart to localStorage when user creates an account
function onUserSignup() {
  migrateToLocal(["cart", "wishlist"]);
}
 
// Move sensitive data to sessionStorage after page load
function onPageLoad() {
  migrateToSession(["temp-token", "csrf-nonce"]);
}

Unified Storage Manager

javascriptjavascript
class StorageManager {
  constructor(namespace = "app") {
    this.namespace = namespace;
    this.local = new TypedStorage(localStorage);
    this.session = new TypedStorage(sessionStorage);
    this.schema = new Map();
  }
 
  define(key, options = {}) {
    this.schema.set(key, {
      storage: options.persistent ? "local" : "session",
      default: options.default !== undefined ? options.default : null,
      ttl: options.ttl || 0, // milliseconds, 0 = no expiry
      validate: options.validate || null,
    });
    return this;
  }
 
  getStore(key) {
    const config = this.schema.get(key);
    if (!config) throw new Error(`Unknown storage key: ${key}`);
    return config.storage === "local" ? this.local : this.session;
  }
 
  prefixedKey(key) {
    return `${this.namespace}:${key}`;
  }
 
  get(key) {
    const config = this.schema.get(key);
    if (!config) throw new Error(`Unknown storage key: ${key}`);
 
    const store = this.getStore(key);
    const fullKey = this.prefixedKey(key);
    const entry = store.get(fullKey);
 
    if (entry === null) return config.default;
 
    // Check TTL
    if (config.ttl > 0 && entry.storedAt) {
      if (Date.now() - entry.storedAt > config.ttl) {
        store.remove(fullKey);
        return config.default;
      }
    }
 
    return entry.value !== undefined ? entry.value : entry;
  }
 
  set(key, value) {
    const config = this.schema.get(key);
    if (!config) throw new Error(`Unknown storage key: ${key}`);
 
    if (config.validate && !config.validate(value)) {
      throw new Error(`Validation failed for key: ${key}`);
    }
 
    const store = this.getStore(key);
    const fullKey = this.prefixedKey(key);
 
    store.set(fullKey, {
      value,
      storedAt: Date.now(),
    });
  }
 
  remove(key) {
    const store = this.getStore(key);
    store.remove(this.prefixedKey(key));
  }
 
  clearNamespace() {
    for (const key of this.schema.keys()) {
      this.remove(key);
    }
  }
 
  getStats() {
    return {
      localUsed: this.local.getUsedBytes(),
      sessionUsed: this.session.getUsedBytes(),
      definedKeys: this.schema.size,
    };
  }
}
 
// Usage
const storage = new StorageManager("myapp");
 
storage
  .define("theme", { persistent: true, default: "system" })
  .define("language", { persistent: true, default: "en" })
  .define("checkout-step", { persistent: false, default: 1 })
  .define("api-cache", {
    persistent: true,
    ttl: 5 * 60 * 1000, // 5 minutes
    default: null,
  })
  .define("font-size", {
    persistent: true,
    default: 16,
    validate: (v) => typeof v === "number" && v >= 10 && v <= 32,
  });
 
storage.set("theme", "dark");
storage.set("checkout-step", 3);
 
console.log(storage.get("theme")); // "dark" (from localStorage)
console.log(storage.get("checkout-step")); // 3 (from sessionStorage)

Quota and Error Handling

javascriptjavascript
function safeSetItem(storage, key, value) {
  try {
    storage.setItem(key, value);
    return true;
  } catch (error) {
    if (
      error instanceof DOMException &&
      (error.name === "QuotaExceededError" ||
       error.code === 22 ||
       error.code === 1014) // Firefox
    ) {
      console.warn("Storage quota exceeded for key:", key);
      return false;
    }
    throw error;
  }
}
 
function getStorageQuotaEstimate(storage) {
  let used = 0;
  for (let i = 0; i < storage.length; i++) {
    const key = storage.key(i);
    used += (key.length + storage.getItem(key).length) * 2;
  }
 
  return {
    usedBytes: used,
    usedKB: (used / 1024).toFixed(2),
    usedMB: (used / (1024 * 1024)).toFixed(4),
  };
}
 
console.log("localStorage:", getStorageQuotaEstimate(localStorage));
console.log("sessionStorage:", getStorageQuotaEstimate(sessionStorage));
Rune AI

Rune AI

Key Insights

  • Persistence is the key difference: localStorage survives browser restarts while sessionStorage is cleared when the tab closes, making each suited to different use cases
  • Storage events enable cross-tab sync: Only localStorage fires storage events in other tabs, enabling real-time theme sync, logout propagation, and messaging
  • Typed wrappers eliminate serialization bugs: Always use JSON.parse/JSON.stringify wrappers since both storage types only store strings natively
  • Quota errors must be caught: Wrap all setItem calls in try/catch to handle QuotaExceededError gracefully with cleanup or fallback strategies
  • Security requires caution: Never store authentication tokens or sensitive data in Web Storage since it is fully accessible to any script running on the page
RunePowered by Rune AI

Frequently Asked Questions

Can sessionStorage data leak to other tabs?

No. Each tab has its own isolated `sessionStorage` instance, even for the same origin. When you duplicate a tab (right-click tab > Duplicate), the new tab receives a snapshot copy, but subsequent changes in either tab are independent.

Is localStorage data accessible to XSS attacks?

Yes. Any JavaScript running on your page can read `localStorage` and `sessionStorage`. Never store sensitive tokens, passwords, or personal data in Web Storage. Use httpOnly cookies for authentication tokens. For storing non-sensitive preferences, see [JS localStorage API Guide](/tutorials/programming-languages/javascript/js-localstorage-api-guide-a-complete-tutorial).

What happens when storage quota is exceeded?

The browser throws a `DOMException` with the name `QuotaExceededError` (or code 22/1014 in older browsers). Always wrap `setItem` calls in try/catch blocks. When quota is exceeded, consider clearing old data, compressing values, or using IndexedDB for larger datasets.

Do storage events fire in the same tab that made the change?

No. The `storage` event only fires in other tabs/windows on the same origin. The tab that called `setItem` does not receive the event. If you need in-tab notifications, implement a custom event system alongside the storage write.

Should I use Web Storage or IndexedDB?

Use Web Storage for small key-value data under 5 MB (preferences, feature flags, small caches). Use IndexedDB for larger structured data, binary files, or when you need indexes and querying. Web Storage is synchronous and simpler; IndexedDB is asynchronous and more powerful. For cookie-based storage, see [How to Manage Cookies in JS](/tutorials/programming-languages/javascript/how-to-manage-cookies-in-js-complete-tutorial).

Conclusion

The Web Storage API provides two mechanisms suited to different lifetimes: localStorage for persistent cross-session data and sessionStorage for tab-scoped temporary data. Use typed wrappers for consistent serialization, storage events for cross-tab communication, and always handle quota errors. For detailed coverage, see JS localStorage API Guide and JS sessionStorage API Guide.