Web Storage APIs

Reviewed & published by Brayan K

Master localStorage, sessionStorage, and IndexedDB to build fast, offline-friendly applications with persistent client-side data.

Part of the free JavaScript course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

🎯 What You'll Learn

💡 Running Code Locally: While this online editor runs real JavaScript, some advanced examples may have limitations. For the best experience:

Understanding the Storage Landscape

JavaScript provides three powerful storage mechanisms, each designed for different needs:

localStorage

sessionStorage

IndexedDB

localStorage: Long-Term Persistent Storage

localStorage keeps data forever (or until the user clears it manually). It's ideal for preferences, themes, cached data, and recently viewed items.

// localStorage keeps data forever (until manually cleared)
// Perfect for: preferences, theme, cached data, recently viewed

localStorage.setItem("theme", "dark");
const theme = localStorage.getItem("theme");
console.log("Theme:", theme); // "dark"

localStorage.removeItem("theme");
localStorage.clear(); // removes all items

// Values are ALWAYS strings - must convert objects
localStorage.setItem("user", JSON.stringify({ id: 10, role: "admin" }));
const user = JSON.parse(localStorage.getItem("user"));
console.log("User:", user);

Common Mistakes to Avoid

// ❌ COMMON MISTAKE - string concatenation
localStorage.setItem("count", 1);
console.log(localStorage.getItem("count") + 1); // "11" (string!)

// ✔ CORRECT - convert to number first
localStorage.setItem("count", 1);
console.log(Number(localStorage.getItem("count")) + 1); // 2

// ❌ Another mistake - forgetting to stringify
localStorage.setItem("data", { name: "test" }); // stores "[object Object]"

// ✔ CORRECT
localStorage.setItem("data", JSON.stringify({ name: "test" }));

Worked Example: A Settings Store, Start to Finish

Those two snippets showed the pieces. This one is a complete program that stores a theme, a number and a whole settings object, then reads them all back — including the two ways a read can go wrong (nothing stored yet, and stored rubbish). Notice the first line: it deletes its own keys before starting, because storage that persists is exactly what makes a demo print something different the second time you run it.

// WORKED EXAMPLE - a settings store, and the one rule that trips everyone up:
// Web Storage only ever holds STRINGS.

// Start clean so the example prints the same thing every time you run it.
// (Storage survives reloads - that is the whole point of it - so a demo that
// does not reset itself gives different answers on the second run.)
["demo:theme", "demo:fontSize", "demo:settings"].forEach(k => localStorage.removeItem(k));

// ---------- 1. Strings go in and strings come out ----------
localStorage.setItem("demo:theme", "dark");
const theme = localStorage.getItem("demo:theme");
console.log("theme:", theme, "| typeof:", typeof theme);

// A number is silently converted to text on the way in.
localStorage.setItem("demo:fontSize", 16);
const rawSize = localStorage.getItem("demo:fontSize");
console.log("fontSize:", rawSize, "| typeof:", typeof rawSize);
console.log("raw + 1 :", rawSize + 1);              // string concatenation, not maths
console.log("Number() + 1:", Number(rawSize) + 1);  // what you actually wanted

// ---------- 2. Objects need JSON on the way in AND out ----------
const settings = { theme: "dark", fontSize: 16, notifications: false };
localStorage.setItem("demo:settings", JSON.stringify(settings));   // object -> text
const saved = JSON.parse(localStorage.getItem("demo:settings"));   // text -> object
console.log("saved.fontSize:", saved.fontSize, "| typeof:", typeof saved.fontSize);

// Skip JSON.stringify and the browser calls String() on your object for you,
// which turns every object into the same useless five words:
localStorage.setItem("demo:settings", settings);
console.log("without JSON.stringify:", localStorage.getItem("demo:settings"));

// ---------- 3. A read that cannot crash your page ----------
// getItem returns null for a key that was never set, and JSON.parse throws on
// anything that is not valid JSON - including that "[object Object]" above.
function read(key, fallback) {
  const raw = localStorage.getItem(key);
  if (raw === null) return fallback;      // nothing stored yet: first visit
  try {
    return JSON.parse(raw);
  } catch {
    return fallback;                      // stored value is corrupt: do not crash
  }
}

console.log("missing key ->", read("demo:nothing-here", "default"));
console.log("corrupt value ->", read("demo:settings", "default"));

// ---------- 4. Clean up after yourself ----------
localStorage.removeItem("demo:theme");
console.log("after removeItem:", localStorage.getItem("demo:theme"));

// ✅ Expected output:
// theme: dark | typeof: string
// fontSize: 16 | typeof: string
// raw + 1 : 161
// Number() + 1: 17
// saved.fontSize: 16 | typeof: number
// without JSON.stringify: [object Object]
// missing key -> default
// corrupt value -> default
// after removeItem: null
//
// Line 3 is the bug that reaches production most often: "161" looks almost
// right, so nobody notices until a font size of 161 pixels turns up.

🎯 Your Turn: Remember the Visit Count

Now you write it. A page counts how many times it has been opened, and remembers a small profile object. Four blanks are marked ___ — two are about numbers versus text, two are about objects versus text. That is the entire Web Storage API in four lines.

// 🎯 YOUR TURN - fill in the four blanks marked ___
// A page that remembers how many times you have visited it.

localStorage.removeItem("demo:visits");    // reset, so the demo is repeatable
localStorage.removeItem("demo:profile");

// 1) Read the counter. On a first visit nothing is stored, so getItem returns
//    null - start from 0 in that case, and turn stored TEXT back into a number.
const stored = localStorage.getItem("demo:visits");
const visits = stored === null ? 0 : ___(stored);   // 👉 replace ___ with Number

// 2) Count this visit and save it back under the same key.
const next = visits + 1;
localStorage.___("demo:visits", next);              // 👉 replace ___ with setItem

console.log("visit number " + localStorage.getItem("demo:visits"));

// 3) Save a whole object. Storage cannot hold objects, only text.
const profile = { name: "Sam", pro: true };
localStorage.setItem("demo:profile", ___(profile));  // 👉 replace ___ with JSON.stringify

// 4) Read it back and turn the text into a real object again.
const back = ___(localStorage.getItem("demo:profile"));  // 👉 replace ___ with JSON.parse
console.log(back.name + " pro? " + back.pro);
console.log("typeof back:", typeof back);

// ✅ Expected output once the blanks are filled:
// visit number 1
// Sam pro? true
// typeof back: object
//
// If the last line says "string", blank 4 is still missing - you read the text
// but never parsed it. If it says "[object Object]" anywhere, blank 3 is missing.

sessionStorage: Tab-Specific, Temporary Storage

sessionStorage is deleted when the tab closes. It's perfect for multi-step forms, temporary shopping cart state, and tab-isolated workflows.

// sessionStorage is deleted when the tab closes
// Perfect for: multi-step forms, temp state, tab-specific data

sessionStorage.setItem("step", "payment");
console.log(sessionStorage.getItem("step")); // "payment"

// Each tab has SEPARATE storage!
// Open two tabs → each has its own sessionStorage

// Great for checkout flows
sessionStorage.setItem("cartDraft", JSON.stringify({
  items: [{ id: 1, qty: 2 }],
  step: 2
}));

// Prevents data bleed between tabs
sessionStorage.setItem("tabId", crypto.randomUUID());

localStorage vs sessionStorage

FeaturelocalStoragesessionStorage
PersistenceUntil clearedUntil tab closes
ScopeShared across tabsPer-tab isolation
Use casePreferences & saved stateIn-progress workflows

Building a Storage Service Layer

Production apps don't store values directly into localStorage. They create a Storage Service Layer that centralizes logic, handles parsing errors, and adds features like namespacing.

// Production apps use a Storage Service Layer
class StorageService {
  constructor(prefix = "app") {
    this.prefix = prefix;
  }

  key(name) {
    return `${this.prefix}:${name}`;
  }

  get(name, fallback = null) {
    try {
      const value = localStorage.getItem(this.key(name));
      return value ? JSON.parse(value) : fallback;
    } catch {
      return fallback;
    }
  }

  set(name, value) {
    localStorage.setItem(this.key(name), JSON.stringify(value));
  }

  remove(name) {
    localStorage.removeItem(this.key(name));
  }

  clearAll() {
    Object.keys(localStorage)
      .filter(k => k.startsWith(this.prefix))
      .forEach(k => localStorage.removeItem(k));
  }
}

const storage = new StorageService("myapp");
storage.set("theme", "dark");
storage.set("user", { id: 1, name: "John" });
console.log(storage.get("theme")); // "dark"
console.log(storage.get("user")); // { id: 1, name: "John" }

TTL-Based Storage (Time-To-Live)

For caching API responses, you want data to expire automatically. This pattern prevents stale data while improving performance.

// TTL-Based Storage (Time-To-Live)
// Perfect for caching API responses

function setWithTTL(key, value, ttlMs) {
  const data = {
    value,
    expiry: Date.now() + ttlMs
  };
  localStorage.setItem(key, JSON.stringify(data));
}

function getWithTTL(key) {
  const item = localStorage.getItem(key);
  if (!item) return null;
  
  try {
    const data = JSON.parse(item);
    if (Date.now() > data.expiry) {
      localStorage.removeItem(key);
      return null; // expired
    }
    return data.value;
  } catch {
    return null;
  }
}

// Cache API response for 15 minutes
setWithTTL("weatherData", { temp: 72, city: "NYC" }, 15 * 60 * 1000);

// Later...
const cached = getWithTTL("weatherData");
if (cached) {
  console.log("Using cached data:", cached);
} else {
  console.log("Cache expired, fetch fresh data");
}

IndexedDB: The Browser's Real Database

IndexedDB is the production-grade solution for storing large datasets and offline application data. It supports images, audio files, documents, and complex object structures.

Opening a Database

// IndexedDB is async and event-based
// Perfect for: large datasets, offline apps, images, files

const request = indexedDB.open("MyAppDB", 1);

request.onupgradeneeded = (e) => {
  const db = e.target.result;
  
  // Create object stores (like tables)
  if (!db.objectStoreNames.contains("notes")) {
    db.createObjectStore("notes", { keyPath: "id" });
  }
  
  console.log("Database upgraded to version 1");
};

request.onsuccess = (e) => {
  const db = e.target.result;
  console.log("Database opened successfully:", db.name);
};

request.onerror = (e) => {
  console.error("Database error:", e.target.error);
};

CRUD Operations

// IndexedDB CRUD operations
// All operations must happen inside transactions

function openDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open("NotesDB", 1);
    
    request.onupgradeneeded = (e) => {
      const db = e.target.result;
      db.createObjectStore("notes", { keyPath: "id" });
    };
    
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

async function addNote(note) {
  const db = await openDB();
  const tx = db.transaction("notes", "readwrite");
  const store = tx.objectStore("notes");
  
  store.add({
    id: Date.now(),
    content: note,
    createdAt: new Date().toISOString()
  });
  
  return new Promise((resolve, reject) => {
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

async function getAllNotes() {
  const db = await openDB();
  const tx = db.transaction("notes", "readonly");
  const store = tx.objectStore("notes");
  const request = store.getAll();
  
  return new Promise((resolve, reject) => {
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

// Usage
await addNote("Learn IndexedDB");
await addNote("Build offline app");
const notes = await getAllNotes();
console.log("All notes:", notes);

Storing Files and Blobs

IndexedDB can store images, audio, PDFs, and binary data — essential for PWAs and offline apps.

// Storing files and blobs in IndexedDB
// Perfect for: offline image editors, file managers, PWAs

async function saveImage(id, file) {
  const db = await openDB();
  const tx = db.transaction("images", "readwrite");
  const store = tx.objectStore("images");
  
  store.put({
    id: id,
    blob: file,
    name: file.name,
    type: file.type,
    savedAt: new Date().toISOString()
  });
  
  return new Promise((resolve, reject) => {
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

async function loadImage(id) {
  const db = await openDB();
  const tx = db.transaction("images", "readonly");
  const store = tx.objectStore("images");
  const request = store.get(id);
  
  return new Promise((resolve, reject) => {
    request.onsuccess = () => {
      const data = request.result;
      if (data && data.blob) {
        // Create URL for display
        const url = URL.createObjectURL(data.blob);
        resolve(url);
      } else {
        resolve(null);
      }
    };
    request.onerror = () => reject(request.error);
  });
}

// Usage with file input
// const file = fileInput.files[0];
// await saveImage("avatar", file);
// const imageUrl = await loadImage("avatar");
// document.querySelector("img").src = imageUrl;

Checking Storage Availability

Important: Safari Private Mode often breaks IndexedDB entirely. Always build with fallbacks!

// Always check storage availability
// Safari Private Mode often breaks IndexedDB!

function isStorageAvailable(type) {
  try {
    const storage = window[type];
    const test = "__storage_test__";
    storage.setItem(test, "1");
    storage.removeItem(test);
    return true;
  } catch (e) {
    return false;
  }
}

// Check before using
if (isStorageAvailable("localStorage")) {
  localStorage.setItem("key", "value");
} else {
  console.warn("localStorage not available");
  // Use in-memory fallback
}

// Quota error handling
try {
  localStorage.setItem("bigData", hugeString);
} catch (e) {
  if (e.name === "QuotaExceededError") {
    console.warn("Storage full! Clearing old data...");
    localStorage.clear();
  }
}

Cross-Tab Communication

localStorage can act as a lightweight messaging system between tabs. Changes trigger events in other tabs automatically.

// Cross-Tab Communication with Storage Events
// localStorage changes trigger events in OTHER tabs

// In Tab 1: Broadcast a logout
function broadcastLogout() {
  localStorage.setItem("globalLogout", Date.now().toString());
}

// In Tab 2: Listen for logout
window.addEventListener("storage", (e) => {
  if (e.key === "globalLogout") {
    console.log("Logout detected in another tab!");
    window.location.href = "/login";
  }
  
  if (e.key === "userData") {
    console.log("User data changed:", e.newValue);
    // Refresh UI with new data
  }
});

// Real-time sync between tabs
function syncData(key, value) {
  localStorage.setItem(key, JSON.stringify(value));
}

// Usage: User updates profile in Tab 1
// → Tab 2 automatically reflects the change

Offline-First Architecture

This is the architecture behind modern productivity apps like Notion, Slack, and Linear. Save locally first, sync when online.

// Offline-First Sync Architecture
// Used by Notion, Slack, Linear, etc.

class OfflineSync {
  constructor() {
    this.queue = [];
    this.isOnline = navigator.onLine;
    
    window.addEventListener("online", () => this.processQueue());
    window.addEventListener("offline", () => this.isOnline = false);
  }
  
  async save(action) {
    // 1. Save to IndexedDB immediately (instant UI)
    await this.saveToIndexedDB(action.data);
    
    // 2. Queue sync job
    this.queue.push({
      id: crypto.randomUUID(),
      type: action.type,
      data: action.data,
      timestamp: Date.now(),
      retries: 0
    });
    
    // 3. Try to sync if online
    if (this.isOnline) {
      await this.processQueue();
    }
  }
  
  async processQueue() {
    while (this.queue.length > 0) {
      const job = this.queue[0];
      
      try {
        await this.sendToServer(job);
        this.queue.shift(); // Success - remove from queue
      } catch (error) {
        job.retries++;
        if (job.retries > 3) {
          this.queue.shift(); // Give up after 3 retries
          console.error("Sync failed:", job);
        }
        break; // Wait before retrying
      }
    }
  }
  
  async saveToIndexedDB(data) { /* ... */ }
  async sendToServer(job) { /* ... */ }
}

const sync = new OfflineSync();
sync.save({ type: "CREATE_NOTE", data: { title: "New note" } });

Hybrid Storage Architecture

Professional apps combine all three storage types based on data characteristics:

// Production Storage Architecture Example
// Dashboard app with offline support

const storageMap = {
  // localStorage: small, fast settings
  theme: "localStorage",
  language: "localStorage",
  sidebarCollapsed: "localStorage",
  recentSearches: "localStorage",
  
  // sessionStorage: tab-specific state
  currentStep: "sessionStorage",
  tabId: "sessionStorage",
  unsavedDraft: "sessionStorage",
  
  // IndexedDB: large data & offline
  tasks: "IndexedDB",
  messages: "IndexedDB",
  cachedReports: "IndexedDB",
  attachments: "IndexedDB",
  offlineQueue: "IndexedDB"
};

class HybridStorage {
  async get(key) {
    const type = storageMap[key];
    
    switch(type) {
      case "localStorage":
        return JSON.parse(localStorage.getItem(key));
      case "sessionStorage":
        return JSON.parse(sessionStorage.getItem(key));
      case "IndexedDB":
        return await this.getFromIndexedDB(key);
      default:
        throw new Error("Unknown storage type for " + key);
    }
  }
  
  async set(key, value) {
    const type = storageMap[key];
    
    switch(type) {
      case "localStorage":
        localStorage.setItem(key, JSON.stringify(value));
        break;
      case "sessionStorage":
        sessionStorage.setItem(key, JSON.stringify(value));
        break;
      case "IndexedDB":
        await this.saveToIndexedDB(key, value);
        break;
    }
  }
  
  async getFromIndexedDB(key) { /* ... */ }
  async saveToIndexedDB(key, value) { /* ... */ }
}

// Clean, unified API for entire app
const storage = new HybridStorage();
await storage.set("theme", "dark");
await storage.set("tasks", [{ id: 1, title: "Learn storage" }]);

Performance Best Practices

// Performance Best Practices

// ❌ BAD: Blocking the main thread on load
const config = JSON.parse(localStorage.getItem("config"));
heavyFunction(config); // UI freezes

// ✔ GOOD: Defer to idle time
requestIdleCallback(() => {
  const config = JSON.parse(localStorage.getItem("config"));
  heavyFunction(config);
});

// ❌ BAD: Writing on every keystroke
input.addEventListener("input", (e) => {
  localStorage.setItem("draft", e.target.value);
});

// ✔ GOOD: Debounced writes
let timeout;
input.addEventListener("input", (e) => {
  clearTimeout(timeout);
  timeout = setTimeout(() => {
    localStorage.setItem("draft", e.target.value);
  }, 500);
});

// ❌ BAD: Large arrays in localStorage
localStorage.setItem("logs", JSON.stringify(hugeArray)); // Blocks UI

// ✔ GOOD: Use IndexedDB for large data
await db.transaction("logs", "readwrite")
  .objectStore("logs")
  .add(hugeArray);

Safe JSON Parsing

Always wrap JSON.parse in try-catch to prevent app crashes from corrupted data.

// Safe JSON Parse (prevents crashes)
function safeParse(key, fallback = null) {
  try {
    const value = localStorage.getItem(key);
    return value ? JSON.parse(value) : fallback;
  } catch {
    console.warn("Invalid JSON for key:", key);
    localStorage.removeItem(key); // Clean up corrupted data
    return fallback;
  }
}

// Usage
const user = safeParse("user", { name: "Guest" });
const settings = safeParse("settings", {});
const items = safeParse("cartItems", []);

// Safe storage with validation
function safeSet(key, value, validator) {
  if (validator && !validator(value)) {
    throw new Error("Invalid value for " + key);
  }
  localStorage.setItem(key, JSON.stringify(value));
}

// Only accept valid themes
safeSet("theme", "dark", (v) => ["light", "dark", "system"].includes(v));

🎯 Mini-Challenge: Recently Viewed

This is a real feature you will be asked to build, and it uses every rule in this lesson at once: JSON on the way in and out, a fallback for the very first visit, and a safe parse in case what is stored is rubbish. No starter logic this time — just the brief and the calls that test it.

Read the expected output before you start writing. It is the specification, and it is not quite what a first guess produces.

// 🎯 MINI-CHALLENGE: a "recently viewed" list
//
// Nearly every shop has one. The rules are:
//   - newest first
//   - no duplicates: viewing something again moves it to the front
//   - never longer than 5 entries
//   - it survives a reload, so it lives in localStorage as JSON
//
// Write these two functions:
//
// 1. readList()      -> returns the stored array, or [] when nothing is stored
//                       or the stored text is not valid JSON (use try/catch).
//
// 2. addRecent(id)   -> read the list, remove any existing copy of id
//                       (Array.filter), put id at the front (Array.unshift),
//                       cut it back to 5 (Array.slice), then save it with
//                       JSON.stringify.
//
// Then run the calls at the bottom, which are already written.

localStorage.removeItem("demo:recent");   // reset, so the demo is repeatable

// your code here

["p1", "p2", "p3", "p1", "p4", "p5", "p6"].forEach(addRecent);
console.log(readList().join(", "));
console.log("length: " + readList().length);

// ✅ Expected output:
// p6, p5, p4, p1, p3
// length: 5
//
// Read that first line carefully before you start - it is the whole spec.
// p1 appears third from the end because it was viewed twice and jumped the
// queue the second time, and p2 is gone because the cap of 5 pushed it off.

Common Mistakes Developers Make

❌ Security Mistakes

❌ Performance Mistakes

❌ Logic Mistakes

✔ Best Practices

Practice Exercises

Build these projects to master Web Storage:

What You Learned

🎉 Lesson Complete!

You now understand how to store data client-side using localStorage, sessionStorage, and IndexedDB.

Practice quiz

How long does data in localStorage persist?

  • Until the tab closes
  • For one hour
  • Forever, until manually cleared
  • Until the page reloads

Answer: Forever, until manually cleared. localStorage persists indefinitely until the user or code clears it; sessionStorage clears on tab close.

When is sessionStorage data deleted?

  • When the tab closes
  • After 24 hours
  • Never automatically
  • On every reload

Answer: When the tab closes. sessionStorage is wiped when the tab closes, making it ideal for tab-specific, temporary state.

How is sessionStorage scoped across browser tabs?

  • Shared across all tabs
  • Shared only within the same domain
  • Shared with incognito tabs
  • Isolated per tab

Answer: Isolated per tab. Each tab has its own separate sessionStorage; localStorage is shared across tabs of the same origin.

What type are values returned by localStorage.getItem?

  • Always the original object
  • Always strings
  • Numbers when numeric
  • null for objects

Answer: Always strings. Web Storage values are always strings; you must JSON.parse objects and Number() numeric values.

What is logged? localStorage.setItem('count', 1); console.log(localStorage.getItem('count') + 1);

  • '11'
  • 2
  • 11
  • It throws

Answer: '11'. getItem returns the string '1', so '1' + 1 is string concatenation giving '11'. Convert with Number() first.

How should you store an object in localStorage?

  • Pass it directly to setItem
  • Use localStorage.setObject
  • JSON.stringify it before setItem
  • Spread it into setItem

Answer: JSON.stringify it before setItem. Storing an object directly yields '[object Object]'; use JSON.stringify on save and JSON.parse on read.

Which storage type is async, event-based, and handles large data and blobs?

  • localStorage
  • IndexedDB
  • sessionStorage
  • Cookies

Answer: IndexedDB. IndexedDB is asynchronous and built for large datasets, objects, and files; Web Storage is synchronous and small.

Roughly how much can localStorage typically hold?

  • About 4 KB
  • Several GB
  • Unlimited
  • About 5-10 MB

Answer: About 5-10 MB. localStorage and sessionStorage are limited to roughly 5-10 MB; cookies are ~4 KB; IndexedDB can hold GBs.

Which event lets one tab detect a localStorage change made in another tab?

  • The 'change' event
  • The 'storage' event
  • The 'message' event
  • The 'sync' event

Answer: The 'storage' event. Writing to localStorage fires a 'storage' event in other tabs, enabling lightweight cross-tab communication.

Why should JSON.parse on stored data be wrapped in try/catch?

  • To speed it up
  • Because parse is async
  • To prevent crashes from corrupted or invalid JSON
  • To stringify the result

Answer: To prevent crashes from corrupted or invalid JSON. Corrupted stored data can make JSON.parse throw; a safe parser catches it and returns a fallback.

Continue this course