Skip to main content

💾 Lesson 19: Local Storage

Everything you've built so far disappears the moment the user refreshes the page. Local Storage changes that — it lets you save data in the browser that persists across page loads, tab closes, and even browser restarts.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Store and retrieve data with localStorage.setItem and getItem
  • Remove individual items and clear all stored data
  • Use JSON.stringify and JSON.parse to store complex data (arrays, objects)
  • Understand the differences between localStorage and sessionStorage
  • Build common patterns: persisted settings, saved form drafts, and stateful apps
  • Handle storage errors and respect storage limits
  • Know when Local Storage is the right tool — and when it isn't

Estimated Time: 45 minutes

Project: Build a persistent to-do list

📑 In This Lesson

What Is Local Storage?

Local Storage is a simple key-value store built into every modern browser. It lets you save strings of data that persist even after the user closes the browser and comes back later. Think of it as a tiny database that lives inside the browser.

Feature Local Storage
Storage limit~5–10 MB per origin (domain)
Data formatStrings only (use JSON for objects)
PersistenceUntil explicitly deleted
ScopePer origin (protocol + domain + port)
Accessible fromJavaScript only (not sent to server)
Synchronous?Yes — blocks the main thread

You can see what's stored in Local Storage right now using DevTools. Open the Application tab (Chrome) or Storage tab (Firefox), then look under Local Storage in the sidebar. You'll see key-value pairs for the current site.

graph LR A["JavaScript
(your code)"] -->|setItem| B["Local Storage
(browser)"] B -->|getItem| A B --> C["Persists across
page loads"] B --> D["Persists across
browser restarts"] B --> E["Scoped to
this origin"] style A fill:#eff6ff,stroke:#3b82f6,stroke-width:2px,color:#1e293b style B fill:#fef3c7,stroke:#f59e0b,stroke-width:2px,color:#1e293b style C fill:#ecfdf5,stroke:#22c55e,stroke-width:2px,color:#1e293b style D fill:#ecfdf5,stroke:#22c55e,stroke-width:2px,color:#1e293b style E fill:#f3e8ff,stroke:#a855f7,stroke-width:2px,color:#1e293b

Basic Operations

The Local Storage API is refreshingly simple — four methods cover everything you need.

setItem — Save Data


// Store a value (both key and value must be strings)
localStorage.setItem("username", "ray");
localStorage.setItem("theme", "dark");
localStorage.setItem("fontSize", "16");
                

getItem — Read Data


// Retrieve a value (returns a string, or null if key doesn't exist)
const username = localStorage.getItem("username");
console.log(username);  // "ray"

const missing = localStorage.getItem("nonexistent");
console.log(missing);   // null
                

removeItem — Delete One Entry


localStorage.removeItem("fontSize");
// The "fontSize" key is gone

// Removing a key that doesn't exist does nothing (no error)
localStorage.removeItem("something-that-was-never-set");
                

clear — Delete Everything


// Remove ALL data for this origin — use with caution!
localStorage.clear();
                

Other Useful Properties


// How many items are stored?
console.log(localStorage.length);  // e.g., 3

// Get the key name at a specific index
console.log(localStorage.key(0));  // e.g., "username"

// Loop through all stored items
for (let i = 0; i < localStorage.length; i++) {
    const key = localStorage.key(i);
    const value = localStorage.getItem(key);
    console.log(`${key}: ${value}`);
}
                

⚠️ Everything Is a String

Local Storage only stores strings. Numbers, booleans, and objects are automatically converted to strings — often not the way you'd expect.


// Numbers are stored as strings
localStorage.setItem("score", 42);
console.log(localStorage.getItem("score"));      // "42" (string!)
console.log(typeof localStorage.getItem("score")); // "string"

// Booleans become strings too
localStorage.setItem("loggedIn", true);
console.log(localStorage.getItem("loggedIn"));   // "true" (string!)

// Objects become "[object Object]" — NOT what you want!
localStorage.setItem("user", { name: "Ray" });
console.log(localStorage.getItem("user"));        // "[object Object]" ❌
                    

For anything other than simple strings, you need JSON — covered in the next section.

Storing Complex Data with JSON

Since Local Storage only handles strings, you need a way to convert arrays and objects to strings and back. That's exactly what JSON.stringify and JSON.parse do.

JSON.stringify — Object → String


const user = { name: "Ray", role: "developer", level: 42 };

// Convert to a JSON string
const jsonString = JSON.stringify(user);
console.log(jsonString);
// '{"name":"Ray","role":"developer","level":42}'

// Store it
localStorage.setItem("user", jsonString);
                

JSON.parse — String → Object


// Read the string back
const stored = localStorage.getItem("user");
console.log(stored);  // '{"name":"Ray","role":"developer","level":42}'

// Convert back to an object
const user = JSON.parse(stored);
console.log(user.name);  // "Ray"
console.log(user.level); // 42 (a real number, not a string!)
                

Storing Arrays


const todos = [
    { id: 1, text: "Learn JavaScript", done: true },
    { id: 2, text: "Build a project", done: false },
    { id: 3, text: "Deploy to Netlify", done: false }
];

// Save
localStorage.setItem("todos", JSON.stringify(todos));

// Load
const savedTodos = JSON.parse(localStorage.getItem("todos"));
console.log(savedTodos.length);  // 3
console.log(savedTodos[0].text); // "Learn JavaScript"
                

Safe Loading with a Fallback

What if the key doesn't exist yet (first visit)? Or the stored JSON is corrupted? Always handle these cases.


// Pattern: load with a default fallback
function loadData(key, defaultValue) {
    try {
        const stored = localStorage.getItem(key);
        return stored ? JSON.parse(stored) : defaultValue;
    } catch (error) {
        console.warn(`Error loading "${key}" from storage:`, error);
        return defaultValue;
    }
}

// Usage
const todos = loadData("todos", []);           // Default: empty array
const settings = loadData("settings", { theme: "light", fontSize: 16 });
                
graph LR A["JavaScript Object
{name: 'Ray'}"] -->|JSON.stringify| B["JSON String
'{\"name\":\"Ray\"}'"] B -->|setItem| C["Local Storage"] C -->|getItem| D["JSON String
'{\"name\":\"Ray\"}'"] D -->|JSON.parse| E["JavaScript Object
{name: 'Ray'}"] style A fill:#eff6ff,stroke:#3b82f6,stroke-width:2px,color:#1e293b style B fill:#fef3c7,stroke:#f59e0b,stroke-width:2px,color:#1e293b style C fill:#ecfdf5,stroke:#22c55e,stroke-width:2px,color:#1e293b style D fill:#fef3c7,stroke:#f59e0b,stroke-width:2px,color:#1e293b style E fill:#eff6ff,stroke:#3b82f6,stroke-width:2px,color:#1e293b

💡 The JSON Round-Trip Gotcha

Not everything survives the JSON round-trip. JSON.stringify drops undefined values, functions, Symbols, and converts Date objects to strings. If you store a Date, you'll get a string back — not a Date object.


// Dates become strings
const event = { name: "Launch", date: new Date() };
localStorage.setItem("event", JSON.stringify(event));

const loaded = JSON.parse(localStorage.getItem("event"));
console.log(loaded.date);         // "2026-04-16T..." (a string!)
console.log(new Date(loaded.date)); // Convert back to Date manually
                    

sessionStorage — The Short-Lived Sibling

sessionStorage works exactly like localStorage — same API, same methods — but the data only lasts for the current browser tab session. Close the tab, and it's gone.


// Exact same API as localStorage
sessionStorage.setItem("step", "3");
const step = sessionStorage.getItem("step");
sessionStorage.removeItem("step");
sessionStorage.clear();
                
Feature localStorage sessionStorage
Persists after closing tab✅ Yes❌ No
Persists after closing browser✅ Yes❌ No
Shared across tabs✅ Yes (same origin)❌ No (per tab)
Survives page refresh✅ Yes✅ Yes
Storage limit~5–10 MB~5–10 MB
APIIdenticalIdentical

When to Use Each


// localStorage — data that should persist
localStorage.setItem("theme", "dark");           // User preference
localStorage.setItem("todos", JSON.stringify([])); // App state

// sessionStorage — data for this visit only
sessionStorage.setItem("wizardStep", "2");       // Multi-step form progress
sessionStorage.setItem("scrollPos", "450");      // Restore scroll on back-nav
sessionStorage.setItem("authToken", token);      // Session-only auth
                

✅ Quick Rule

If the user would expect the data to still be there after reopening the browser tomorrow, use localStorage. If the data is only relevant to this particular browsing session, use sessionStorage.

Practical Patterns

Here are the most common ways you'll use Local Storage in real applications.

Pattern 1: Persisting User Preferences


// Save theme preference
function setTheme(theme) {
    document.body.dataset.theme = theme;
    localStorage.setItem("theme", theme);
}

// Load on page load
function loadTheme() {
    const saved = localStorage.getItem("theme") || "light";
    document.body.dataset.theme = saved;
    return saved;
}

// Toggle
const themeToggle = document.querySelector("#theme-toggle");
themeToggle.addEventListener("click", () => {
    const current = document.body.dataset.theme;
    setTheme(current === "dark" ? "light" : "dark");
});

// Apply saved theme immediately
loadTheme();
                

Pattern 2: Form Draft Auto-Save


const form = document.querySelector("#feedback-form");

// Auto-save on input (debounced)
let saveTimer;
form.addEventListener("input", () => {
    clearTimeout(saveTimer);
    saveTimer = setTimeout(() => {
        const data = Object.fromEntries(new FormData(form));
        localStorage.setItem("feedbackDraft", JSON.stringify(data));
        console.log("Draft saved");
    }, 500);
});

// Restore draft on page load
function restoreDraft() {
    const draft = loadData("feedbackDraft", null);
    if (!draft) return;

    for (const [name, value] of Object.entries(draft)) {
        const field = form.elements[name];
        if (field) field.value = value;
    }
    console.log("Draft restored");
}
restoreDraft();

// Clear draft after successful submit
form.addEventListener("submit", (event) => {
    event.preventDefault();
    // ... submit logic ...
    localStorage.removeItem("feedbackDraft");
});
                

Pattern 3: Syncing State to Storage

For stateful apps (to-do lists, note apps), the pattern is: load state from storage on start, update storage whenever state changes.


// App state
let todos = loadData("todos", []);

// Render from state
function render() {
    const list = document.querySelector("#todo-list");
    list.innerHTML = "";

    todos.forEach(todo => {
        const li = document.createElement("li");
        li.textContent = todo.text;
        if (todo.done) li.classList.add("done");
        list.append(li);
    });
}

// Update state AND save
function addTodo(text) {
    todos.push({ id: Date.now(), text, done: false });
    save();
    render();
}

function toggleTodo(id) {
    const todo = todos.find(t => t.id === id);
    if (todo) todo.done = !todo.done;
    save();
    render();
}

function save() {
    localStorage.setItem("todos", JSON.stringify(todos));
}

// Initial render
render();
                

Pattern 4: The storage Event — Cross-Tab Sync

When one tab changes Local Storage, other tabs for the same origin receive a storage event. This lets you sync data between tabs without any server.


// This fires in OTHER tabs (not the one that made the change)
window.addEventListener("storage", (event) => {
    console.log("Storage changed!");
    console.log("Key:", event.key);         // Which key changed
    console.log("Old:", event.oldValue);    // Previous value
    console.log("New:", event.newValue);    // New value
    console.log("URL:", event.url);         // Which tab made the change

    // Example: sync theme across tabs
    if (event.key === "theme") {
        document.body.dataset.theme = event.newValue;
    }

    // Example: sync todos across tabs
    if (event.key === "todos") {
        todos = JSON.parse(event.newValue) || [];
        render();
    }
});
                
graph TD A["Tab A
setItem('theme', 'dark')"] --> B["Local Storage
Updated"] B --> C["'storage' event fires
in Tab B"] B --> D["'storage' event fires
in Tab C"] C --> E["Tab B updates
its UI"] D --> F["Tab C updates
its UI"] style A fill:#eff6ff,stroke:#3b82f6,stroke-width:2px,color:#1e293b style B fill:#fef3c7,stroke:#f59e0b,stroke-width:2px,color:#1e293b style C fill:#ecfdf5,stroke:#22c55e,stroke-width:2px,color:#1e293b style D fill:#ecfdf5,stroke:#22c55e,stroke-width:2px,color:#1e293b style E fill:#f3e8ff,stroke:#a855f7,stroke-width:2px,color:#1e293b style F fill:#f3e8ff,stroke:#a855f7,stroke-width:2px,color:#1e293b

Limits, Errors & When Not to Use It

Storage Limits

Most browsers allow 5–10 MB per origin. If you exceed it, setItem throws a QuotaExceededError.


// Always wrap setItem in try/catch for production apps
function safeSave(key, value) {
    try {
        localStorage.setItem(key, value);
        return true;
    } catch (error) {
        if (error.name === "QuotaExceededError") {
            console.error("Storage is full! Cannot save.", error);
            // Maybe clear old data or warn the user
        }
        return false;
    }
}
                

Storage Might Be Unavailable

Local Storage can be disabled (private browsing in some browsers, corporate policies, user settings). Always check before using it.


function isStorageAvailable() {
    try {
        const test = "__storage_test__";
        localStorage.setItem(test, "1");
        localStorage.removeItem(test);
        return true;
    } catch (error) {
        return false;
    }
}

if (isStorageAvailable()) {
    // Safe to use localStorage
} else {
    console.warn("Local Storage is not available");
    // Fall back to in-memory storage
}
                

When NOT to Use Local Storage

Don't Store Why Use Instead
Passwords or tokensAccessible to any JS on the page (XSS risk)HttpOnly cookies
Sensitive personal dataNot encrypted, easily readableServer-side storage
Large datasets (>5 MB)Exceeds quota, blocks main threadIndexedDB
Data that must sync across devicesLocal to this browser onlyServer database
Frequently updated small dataSynchronous writes block UIIn-memory + periodic save

⚠️ Security: Local Storage Is Not Secure Storage

Any JavaScript running on your page can read Local Storage — including scripts injected via XSS attacks. Never store authentication tokens, passwords, credit card numbers, or other sensitive data in Local Storage. For auth tokens, use HttpOnly cookies that JavaScript can't access.

💡 What About IndexedDB?

For larger or more complex data (images, files, structured databases), browsers offer IndexedDB — an asynchronous, transactional database that can store megabytes of data without blocking the UI. It's more complex to use but far more powerful. Local Storage is the right choice for small, simple key-value data like preferences, settings, and modest app state.

Hands-on Exercise

🏋️ Exercise: Persistent To-Do List

Objective: Build a to-do list that saves to Local Storage. Tasks persist across page refreshes. Users can add tasks, toggle completion, delete individual tasks, and clear all completed tasks.


<!-- Save this as todo.html and open in a browser -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Persistent To-Do List</title>
    <style>
        * { box-sizing: border-box; margin: 0; }
        body { font-family: system-ui, sans-serif; background: #f1f5f9; padding: 2rem; }
        .app {
            max-width: 500px; margin: 0 auto; background: white;
            border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.08);
            overflow: hidden;
        }
        .app-header {
            background: #6366f1; color: white; padding: 1.5rem;
        }
        .app-header h1 { font-size: 1.3rem; margin-bottom: 1rem; }
        .input-row { display: flex; gap: 0.5rem; }
        .input-row input {
            flex: 1; padding: 0.6rem 0.75rem; border: none;
            border-radius: 6px; font-size: 1rem;
        }
        .input-row button {
            padding: 0.6rem 1.2rem; background: #4f46e5; color: white;
            border: none; border-radius: 6px; font-size: 1rem; cursor: pointer;
        }
        .input-row button:hover { background: #4338ca; }

        .todo-list { list-style: none; padding: 0; }
        .todo-item {
            display: flex; align-items: center; gap: 0.75rem;
            padding: 0.75rem 1.5rem; border-bottom: 1px solid #e2e8f0;
            transition: background 0.2s;
        }
        .todo-item:hover { background: #f8fafc; }
        .todo-item input[type="checkbox"] {
            width: 1.2rem; height: 1.2rem; cursor: pointer;
            accent-color: #6366f1;
        }
        .todo-item .todo-text {
            flex: 1; font-size: 1rem; color: #1e293b;
        }
        .todo-item.done .todo-text {
            text-decoration: line-through; color: #94a3b8;
        }
        .todo-item .delete-btn {
            background: none; border: none; color: #94a3b8;
            font-size: 1.2rem; cursor: pointer; padding: 0.25rem;
        }
        .todo-item .delete-btn:hover { color: #ef4444; }

        .app-footer {
            display: flex; justify-content: space-between;
            align-items: center; padding: 0.75rem 1.5rem;
            background: #f8fafc; border-top: 1px solid #e2e8f0;
            font-size: 0.85rem; color: #64748b;
        }
        .clear-btn {
            background: none; border: 1px solid #cbd5e1;
            border-radius: 6px; padding: 0.35rem 0.75rem;
            color: #64748b; cursor: pointer; font-size: 0.85rem;
        }
        .clear-btn:hover { border-color: #ef4444; color: #ef4444; }

        .empty-state {
            text-align: center; padding: 2rem; color: #94a3b8;
        }
    </style>
</head>
<body>
    <div class="app">
        <div class="app-header">
            <h1>✅ To-Do List</h1>
            <div class="input-row">
                <input type="text" id="todo-input" placeholder="What needs to be done?">
                <button id="add-btn">Add</button>
            </div>
        </div>

        <ul class="todo-list" id="todo-list"></ul>

        <div class="app-footer">
            <span id="count-display">0 items left</span>
            <button class="clear-btn" id="clear-done-btn">Clear completed</button>
        </div>
    </div>

    <script>
    // TODO: Complete each task

    // 1. Write loadTodos() and saveTodos(todos):
    //    - loadTodos: get "todos" from localStorage, JSON.parse,
    //      return [] if nothing stored. Use try/catch!
    //    - saveTodos: JSON.stringify the array and setItem

    // 2. Write render(todos):
    //    - Clear the #todo-list
    //    - If no todos, show an empty state message
    //    - For each todo, create an <li class="todo-item"> with:
    //      a checkbox, a span.todo-text, and a button.delete-btn ("×")
    //    - Add "done" class to the <li> if todo.done is true
    //    - Set checkbox.checked to todo.done

    // 3. Write addTodo(text):
    //    - Push a new object { id: Date.now(), text, done: false }
    //    - saveTodos, then render

    // 4. Write toggleTodo(id) and deleteTodo(id):
    //    - toggle: flip the done property of the matching todo
    //    - delete: filter out the matching todo
    //    - Both: saveTodos, then render

    // 5. Write updateCount(todos):
    //    - Count todos where done === false
    //    - Update #count-display text: "X items left"

    // 6. Hook up event listeners:
    //    - #add-btn click and Enter key: addTodo
    //    - Event delegation on #todo-list for checkbox change
    //      and delete button click
    //    - #clear-done-btn: filter out done todos, save, render

    // 7. Load and render on page start
    </script>
</body>
</html>
                    
💡 Hint

1: Wrap JSON.parse in a try/catch. If the stored string is corrupted, JSON.parse will throw — return [] in that case.

2: Use document.createElement for each list item. Set checkbox.checked = todo.done and add event listeners or use delegation.

4: For toggle: todos.map(t => t.id === id ? { ...t, done: !t.done } : t). For delete: todos.filter(t => t.id !== id).

6: For the add button, remember to trim the input and ignore empty strings. Clear the input after adding.

✅ Solution

const todoInput = document.querySelector("#todo-input");
const addBtn = document.querySelector("#add-btn");
const todoList = document.querySelector("#todo-list");
const countDisplay = document.querySelector("#count-display");
const clearDoneBtn = document.querySelector("#clear-done-btn");

// 1. Load and save
function loadTodos() {
    try {
        const stored = localStorage.getItem("todos");
        return stored ? JSON.parse(stored) : [];
    } catch (error) {
        console.warn("Error loading todos:", error);
        return [];
    }
}

function saveTodos(todos) {
    try {
        localStorage.setItem("todos", JSON.stringify(todos));
    } catch (error) {
        console.error("Error saving todos:", error);
    }
}

// State
let todos = loadTodos();

// 2. Render
function render() {
    todoList.innerHTML = "";

    if (todos.length === 0) {
        const empty = document.createElement("li");
        empty.classList.add("empty-state");
        empty.textContent = "Nothing to do! Add a task above.";
        todoList.append(empty);
    } else {
        todos.forEach(todo => {
            const li = document.createElement("li");
            li.classList.add("todo-item");
            if (todo.done) li.classList.add("done");
            li.dataset.id = todo.id;

            const checkbox = document.createElement("input");
            checkbox.type = "checkbox";
            checkbox.checked = todo.done;

            const text = document.createElement("span");
            text.classList.add("todo-text");
            text.textContent = todo.text;

            const deleteBtn = document.createElement("button");
            deleteBtn.classList.add("delete-btn");
            deleteBtn.textContent = "×";

            li.append(checkbox, text, deleteBtn);
            todoList.append(li);
        });
    }

    updateCount();
}

// 3. Add
function addTodo(text) {
    const trimmed = text.trim();
    if (!trimmed) return;

    todos.push({ id: Date.now(), text: trimmed, done: false });
    saveTodos(todos);
    render();
}

// 4. Toggle and delete
function toggleTodo(id) {
    todos = todos.map(t =>
        t.id === id ? { ...t, done: !t.done } : t
    );
    saveTodos(todos);
    render();
}

function deleteTodo(id) {
    todos = todos.filter(t => t.id !== id);
    saveTodos(todos);
    render();
}

// 5. Update count
function updateCount() {
    const remaining = todos.filter(t => !t.done).length;
    countDisplay.textContent = `${remaining} item${remaining !== 1 ? "s" : ""} left`;
}

// 6. Event listeners
addBtn.addEventListener("click", () => {
    addTodo(todoInput.value);
    todoInput.value = "";
    todoInput.focus();
});

todoInput.addEventListener("keydown", (event) => {
    if (event.key === "Enter") {
        addTodo(todoInput.value);
        todoInput.value = "";
    }
});

// Event delegation for checkboxes and delete buttons
todoList.addEventListener("click", (event) => {
    const li = event.target.closest(".todo-item");
    if (!li) return;
    const id = Number(li.dataset.id);

    if (event.target.type === "checkbox") {
        toggleTodo(id);
    } else if (event.target.matches(".delete-btn")) {
        deleteTodo(id);
    }
});

clearDoneBtn.addEventListener("click", () => {
    todos = todos.filter(t => !t.done);
    saveTodos(todos);
    render();
});

// 7. Initial render
render();
                        

🎯 Quick Quiz

Question 1: What does localStorage.getItem("key") return if the key doesn't exist?

Question 2: What happens when you store a JavaScript object directly with setItem?

Question 3: What is the correct way to store an array in Local Storage?

Question 4: How does sessionStorage differ from localStorage?

Question 5: Why should you never store passwords or auth tokens in Local Storage?

Summary

🎉 Key Takeaways

  • localStorage.setItem(key, value) saves a string; getItem(key) retrieves it (or null)
  • removeItem(key) deletes one entry; clear() deletes everything for this origin
  • Local Storage only stores strings — use JSON.stringify to save and JSON.parse to load objects and arrays
  • Always wrap JSON.parse in try/catch and provide a default fallback
  • sessionStorage has the same API but data is cleared when the tab closes
  • The storage event lets you sync data between tabs of the same origin
  • Common patterns: persisted preferences, form draft auto-save, stateful app sync
  • Local Storage is synchronous and limited to ~5–10 MB — use IndexedDB for larger data
  • Never store sensitive data (passwords, tokens) in Local Storage — it's vulnerable to XSS

📚 Additional Resources

🚀 What's Next?

Your apps can now persist data locally. But what about data from the outside world — weather, news, user accounts, anything on a server? In the next lesson, you'll learn to fetch data from APIs using Promises, async/await, and the Fetch API to build apps that talk to the internet.