💾 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.setItemandgetItem - Remove individual items and clear all stored data
- Use
JSON.stringifyandJSON.parseto store complex data (arrays, objects) - Understand the differences between
localStorageandsessionStorage - 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 format | Strings only (use JSON for objects) |
| Persistence | Until explicitly deleted |
| Scope | Per origin (protocol + domain + port) |
| Accessible from | JavaScript 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.
(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 });
{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 |
| API | Identical | Identical |
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();
}
});
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 tokens | Accessible to any JS on the page (XSS risk) | HttpOnly cookies |
| Sensitive personal data | Not encrypted, easily readable | Server-side storage |
| Large datasets (>5 MB) | Exceeds quota, blocks main thread | IndexedDB |
| Data that must sync across devices | Local to this browser only | Server database |
| Frequently updated small data | Synchronous writes block UI | In-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 (ornull)removeItem(key)deletes one entry;clear()deletes everything for this origin- Local Storage only stores strings — use
JSON.stringifyto save andJSON.parseto load objects and arrays - Always wrap
JSON.parsein try/catch and provide a default fallback sessionStoragehas the same API but data is cleared when the tab closes- The
storageevent 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
- MDN — localStorage
- MDN — sessionStorage
- MDN — Web Storage API
- MDN — StorageEvent (cross-tab sync)
- javascript.info — LocalStorage & SessionStorage
🚀 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.