Search
JavaScript localStorage Tutorial: Save, Read and Delete Data with JSON (To-Do List Example)
Have you ever filled out half a form, refreshed the page by accident, and lost everything? Or built a to-do list in JavaScript that forgets every task the moment you reload? The fix is built into every modern browser: localStorage. It lets your JavaScript save data in the user's browser and read it back later, even after the browser is closed and opened again, with no database and no server code.
In this JavaScript localStorage tutorial you will learn how to save, read, update and delete data, store objects and arrays with JSON, handle errors safely, understand the difference between localStorage and sessionStorage, and sync data between open tabs. Then you will build a complete to-do list app that remembers its tasks. Every snippet was run in Google Chrome 156, and the output shown is the real console output. The full code is available as a download at the end.
What Is localStorage?
localStorage is part of the Web Storage API. It is a simple key-value store that belongs to one website (more precisely, one origin: the protocol, domain and port, such as https://example.com). Data saved there:
- stays until you delete it, even after the browser is restarted,
- is only visible to pages from the same origin,
- is stored as strings only,
- is not sent to the server with each request (unlike cookies),
- is limited in size, typically around 5 MB per origin.
Good uses: user preferences (dark mode, language, font size), a shopping cart before login, form drafts, recently viewed items, and small offline apps like a to-do list or notes. You can see what a site has stored in Chrome DevTools under Application > Storage > Local storage.
Basic Methods: setItem, getItem, removeItem and clear
The whole API fits in a few methods. Open any page, press F12 to open the Console, and try this:
- // 1. Save, read, update and remove values
- localStorage.setItem("username", "jaypabs");
- localStorage.setItem("theme", "dark");
- console.log("username:", localStorage.getItem("username"));
- console.log("items stored:", localStorage.length);
- localStorage.setItem("theme", "light"); // same key = update
- console.log("theme now:", localStorage.getItem("theme"));
- localStorage.removeItem("username");
- console.log("after remove:", localStorage.getItem("username")); // null if missing
- // 2. Everything is stored as a string
- localStorage.setItem("visits", 5);
- const visits = localStorage.getItem("visits");
- console.log(typeof visits, visits + 1); // string "51", not 6
- console.log(Number(visits) + 1); // 6
- // 3. Loop through all keys
- localStorage.setItem("lang", "en");
- for (let i = 0; i < localStorage.length; i++) {
- const key = localStorage.key(i);
- console.log("key:", key, "=", localStorage.getItem(key));
- }
- localStorage.clear();
- console.log("after clear:", localStorage.length);
Output:
username: jaypabs items stored: 2 theme now: light after remove: null string 51 6 key: lang = en key: theme = light key: visits = 5 after clear: 0
Things to notice:
- Calling
setItemwith an existing key replaces the old value. getItemreturnsnullwhen the key does not exist, so you can check fornullto apply defaults.- The number
5came back as the string"5", sovisits + 1gave"51". Convert withNumber()before doing math. localStorage.key(i)lets you loop over every key. Do not rely on the order of the keys; the specification leaves it up to the browser.
Storing Objects and Arrays with JSON
Because localStorage only stores strings, saving an object directly does not work the way beginners expect. Use JSON.stringify() to turn it into text and JSON.parse() to turn it back:
- const user = { name: "Ana Reyes", course: "BSIT", year: 3, subjects: ["Web Dev", "Database"] };
- // Wrong: objects turn into "[object Object]"
- localStorage.setItem("user", user);
- console.log("wrong:", localStorage.getItem("user"));
- // Right: convert to JSON text first, then parse it back
- localStorage.setItem("user", JSON.stringify(user));
- console.log("stored text:", localStorage.getItem("user"));
- const saved = JSON.parse(localStorage.getItem("user"));
- console.log("parsed:", saved.name, "-", saved.subjects.length, "subjects");
- localStorage.clear();
Output:
wrong: [object Object]
stored text: {"name":"Ana Reyes","course":"BSIT","year":3,"subjects":["Web Dev","Database"]}
parsed: Ana Reyes - 2 subjects
The first attempt saved the useless text [object Object]. With JSON, the whole object, including the nested array, comes back intact. Keep in mind that JSON cannot store functions, and Date objects come back as strings, so convert them again with new Date(text) after parsing.
A Safe localStorage Helper with Error Handling
In real projects, localStorage can fail. The saved JSON might be corrupted, storage can be full, and some browsers or privacy settings block it entirely, which makes the calls throw an error. A tiny wrapper keeps those problems from crashing your page:
- // A small, safe wrapper you can reuse in any project
- const store = {
- get(key, fallback = null) {
- try {
- const text = localStorage.getItem(key);
- return text === null ? fallback : JSON.parse(text);
- } catch (err) {
- console.warn(`Could not read "${key}":`, err.name);
- return fallback; // corrupted JSON or storage blocked
- }
- },
- set(key, value) {
- try {
- localStorage.setItem(key, JSON.stringify(value));
- return true;
- } catch (err) {
- console.warn(`Could not save "${key}":`, err.name); // e.g. QuotaExceededError
- return false;
- }
- },
- remove(key) {
- localStorage.removeItem(key);
- },
- };
Let's test it, including corrupted data and filling the storage on purpose with 1 MB strings until the browser refuses:
- store.set("settings", { theme: "dark", fontSize: 16 });
- console.log("settings:", store.get("settings").theme, store.get("settings").fontSize);
- console.log("missing key:", JSON.stringify(store.get("cart", []))); // fallback value
- localStorage.setItem("broken", "{not valid json"); // simulate corrupted data
- console.log("broken key:", store.get("broken", "default"));
- const big = "x".repeat(1024 * 1024); // 1 MB string
- let saved = 0;
- while (store.set("big" + saved, big)) saved++;
- console.log("saved", saved, "MB before the browser said no");
- localStorage.clear();
Output:
settings: dark 16 missing key: [] Warning: Could not read "broken": SyntaxError broken key: default Warning: Could not save "big4": QuotaExceededError saved 4 MB before the browser said no
The helper returned the fallback instead of throwing on bad JSON, and it caught the QuotaExceededError. In this Chrome test, four 1 MB strings fit and the fifth did not, which matches the usual limit of about 5 MB per origin. That is plenty for settings and small lists, but it is not the place for images or large datasets; use IndexedDB or a server database for those.
localStorage vs sessionStorage
sessionStorage has exactly the same methods, but its data only lives as long as the browser tab. We saved one value in each, then opened a new tab on the same site and read them again:
- sessionStorage.setItem("step", "2");
- localStorage.setItem("remember", "yes");
- console.log("session step:", sessionStorage.getItem("step"));
- console.log("local remember:", localStorage.getItem("remember"));
Output in the original tab, then in the new tab:
session step: 2 local remember: yes new tab -> session step: null new tab -> local remember: yes
The new tab could read the localStorage value but not the sessionStorage one. Use sessionStorage for things that should not outlive the tab, such as the current step of a multi-page form, and localStorage for things the user expects to be remembered.
| localStorage | sessionStorage | Cookies | |
|---|---|---|---|
| Lifetime | Until deleted | Until the tab closes | Until the expiry date |
| Shared between tabs | Yes (same origin) | No | Yes |
| Sent to the server | No | No | Yes, with every request |
| Typical size | About 5 MB | About 5 MB | About 4 KB each |
Project: A To-Do List That Remembers Its Tasks
Now let's put it together. The app lets you add tasks, mark them as done, delete them, filter by All, Active or Done, and clear completed tasks. Everything is saved to localStorage, so the list is still there after a reload. Here is the HTML:
- <!doctype html>
- <html lang="en">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <link rel="stylesheet" href="style.css">
- </head>
- <body>
- <main class="app">
- <form id="todo-form">
- <input id="todo-input" placeholder="What do you need to do?" maxlength="120" autocomplete="off" required>
- </form>
- <div class="filters">
- </div>
- </main>
- </body>
- </html>
Loading and saving the tasks
All tasks live in one array, saved under a single key. Prefixing the key with the app name (todo-app.tasks) avoids clashes with other scripts on the same site. Loading is defensive: if the data is missing, corrupted, or not an array, the app starts with an empty list instead of crashing.
- function loadTasks() {
- try {
- const tasks = JSON.parse(localStorage.getItem(STORAGE_KEY));
- return Array.isArray(tasks) ? tasks : [];
- } catch {
- return []; // corrupted data: start fresh instead of crashing
- }
- }
- function saveTasks() {
- try {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
- } catch (err) {
- alert("Could not save your tasks: " + err.name);
- }
- }
Rendering the list safely
The list is rebuilt from the array every time something changes. Notice that the task text is inserted with textContent, not innerHTML. Data in localStorage can be edited by anyone with DevTools, so always treat it as untrusted input; with textContent, a task like <b>bold?</b> is shown as plain text instead of being turned into HTML.
- function render() {
- list.innerHTML = "";
- const visible = tasks.filter((t) => filter === "all" || (filter === "done" ? t.done : !t.done));
- for (const task of visible) {
- const li = document.createElement("li");
- li.className = task.done ? "done" : "";
- li.dataset.id = task.id;
- const checkbox = document.createElement("input");
- checkbox.type = "checkbox";
- checkbox.checked = task.done;
- const text = document.createElement("span");
- text.textContent = task.text; // textContent, not innerHTML: safe from HTML injection
- const del = document.createElement("button");
- del.className = "delete";
- del.textContent = "×";
- del.title = "Delete task";
- li.append(checkbox, text, del);
- list.append(li);
- }
- const left = tasks.filter((t) => !t.done).length;
- count.textContent = `${left} task${left === 1 ? "" : "s"} left`;
- }
Adding, completing and deleting tasks
Each change follows the same three steps: update the array, call saveTasks(), then call render(). One click listener on the list handles both the checkboxes and the delete buttons (event delegation):
- form.addEventListener("submit", (e) => {
- e.preventDefault();
- const text = input.value.trim();
- if (!text) return;
- tasks.push({ id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6), text, done: false });
- saveTasks();
- render();
- input.value = "";
- });
- list.addEventListener("click", (e) => {
- const li = e.target.closest("li");
- if (!li) return;
- const task = tasks.find((t) => t.id === li.dataset.id);
- if (e.target.matches("input[type=checkbox]")) task.done = e.target.checked;
- if (e.target.matches(".delete")) tasks = tasks.filter((t) => t !== task);
- saveTasks();
- render();
- });
Syncing between open tabs with the storage event
If the app is open in two tabs, the browser fires a storage event in the other tabs whenever localStorage changes. Listening for it keeps every tab up to date:
- // Keep several open tabs in sync: "storage" fires in the OTHER tabs of the same site
- window.addEventListener("storage", (e) => {
- if (e.key === STORAGE_KEY) {
- tasks = loadTasks();
- render();
- }
- });
- render();
Testing the app
The download includes a small test script that drives the app in headless Chrome: it adds tasks, reloads the page, checks the filters, opens a second tab to test syncing, and loads corrupted data on purpose. Here is the result:
PASS 4 tasks added PASS HTML in a task is shown as text, not rendered PASS counter shows 2 tasks left PASS tasks saved as JSON in localStorage PASS tasks survive a page reload PASS done state survives reload PASS Done filter PASS Active filter PASS other open tab updates via storage event PASS corrupted storage -> empty list, no crash 10 passed, 0 failed
Security Tips: What Not to Store
- Never store passwords, credit card numbers, or other secrets. localStorage is plain text on the user's computer.
- Be careful with login tokens. Any JavaScript running on your page can read localStorage, so a single cross-site scripting (XSS) bug lets an attacker steal everything in it. For sessions, an
HttpOnlycookie set by the server is safer because JavaScript cannot read it. - Treat stored data as untrusted. Users can change it in DevTools. Validate it when you read it, and never use it with
innerHTMLoreval. - Keep the server as the source of truth for anything important, such as prices, grades or account balances.
Download the Example Code
The zip file below contains the to-do app (index.html, app.js, style.css), all snippets from this tutorial, and the test scripts. You can open todo-app/index.html directly in your browser, but serving the folder is closer to a real site, for example with python -m http.server 8000 or the VS Code Live Server extension, then opening http://localhost:8000/todo-app/.
Conclusion
localStorage is one of the easiest ways to make a JavaScript app feel polished: preferences are remembered, drafts are not lost, and small apps work without a backend. Remember the essentials: use setItem and getItem, store objects with JSON.stringify and JSON.parse, wrap access in try/catch, choose sessionStorage for tab-only data, listen for the storage event to sync tabs, and keep secrets out. Try adding due dates or an edit button to the to-do app next; both only need small changes to the task objects and the render() function.