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. // 1. Save, read, update and remove values
  2. localStorage.setItem("username", "jaypabs");
  3. localStorage.setItem("theme", "dark");
  4. console.log("username:", localStorage.getItem("username"));
  5. console.log("items stored:", localStorage.length);
  6.  
  7. localStorage.setItem("theme", "light");          // same key = update
  8. console.log("theme now:", localStorage.getItem("theme"));
  9.  
  10. localStorage.removeItem("username");
  11. console.log("after remove:", localStorage.getItem("username")); // null if missing
  12.  
  13. // 2. Everything is stored as a string
  14. localStorage.setItem("visits", 5);
  15. const visits = localStorage.getItem("visits");
  16. console.log(typeof visits, visits + 1);          // string "51", not 6
  17. console.log(Number(visits) + 1);                 // 6
  18.  
  19. // 3. Loop through all keys
  20. localStorage.setItem("lang", "en");
  21. for (let i = 0; i < localStorage.length; i++) {
  22.   const key = localStorage.key(i);
  23.   console.log("key:", key, "=", localStorage.getItem(key));
  24. }
  25. localStorage.clear();
  26. 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 setItem with an existing key replaces the old value.
  • getItem returns null when the key does not exist, so you can check for null to apply defaults.
  • The number 5 came back as the string "5", so visits + 1 gave "51". Convert with Number() 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:

  1. const user = { name: "Ana Reyes", course: "BSIT", year: 3, subjects: ["Web Dev", "Database"] };
  2.  
  3. // Wrong: objects turn into "[object Object]"
  4. localStorage.setItem("user", user);
  5. console.log("wrong:", localStorage.getItem("user"));
  6.  
  7. // Right: convert to JSON text first, then parse it back
  8. localStorage.setItem("user", JSON.stringify(user));
  9. console.log("stored text:", localStorage.getItem("user"));
  10. const saved = JSON.parse(localStorage.getItem("user"));
  11. console.log("parsed:", saved.name, "-", saved.subjects.length, "subjects");
  12. 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:

  1. // A small, safe wrapper you can reuse in any project
  2. const store = {
  3.   get(key, fallback = null) {
  4.     try {
  5.       const text = localStorage.getItem(key);
  6.       return text === null ? fallback : JSON.parse(text);
  7.     } catch (err) {
  8.       console.warn(`Could not read "${key}":`, err.name);
  9.       return fallback;                    // corrupted JSON or storage blocked
  10.     }
  11.   },
  12.   set(key, value) {
  13.     try {
  14.       localStorage.setItem(key, JSON.stringify(value));
  15.       return true;
  16.     } catch (err) {
  17.       console.warn(`Could not save "${key}":`, err.name);  // e.g. QuotaExceededError
  18.       return false;
  19.     }
  20.   },
  21.   remove(key) {
  22.     localStorage.removeItem(key);
  23.   },
  24. };

Let's test it, including corrupted data and filling the storage on purpose with 1 MB strings until the browser refuses:

  1. store.set("settings", { theme: "dark", fontSize: 16 });
  2. console.log("settings:", store.get("settings").theme, store.get("settings").fontSize);
  3. console.log("missing key:", JSON.stringify(store.get("cart", [])));  // fallback value
  4.  
  5. localStorage.setItem("broken", "{not valid json");      // simulate corrupted data
  6. console.log("broken key:", store.get("broken", "default"));
  7.  
  8. const big = "x".repeat(1024 * 1024);                     // 1 MB string
  9. let saved = 0;
  10. while (store.set("big" + saved, big)) saved++;
  11. console.log("saved", saved, "MB before the browser said no");
  12. 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:

  1. sessionStorage.setItem("step", "2");
  2. localStorage.setItem("remember", "yes");
  3. console.log("session step:", sessionStorage.getItem("step"));
  4. 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.

localStoragesessionStorageCookies
LifetimeUntil deletedUntil the tab closesUntil the expiry date
Shared between tabsYes (same origin)NoYes
Sent to the serverNoNoYes, with every request
Typical sizeAbout 5 MBAbout 5 MBAbout 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:

  1. <!doctype html>
  2. <html lang="en">
  3. <meta charset="utf-8">
  4. <meta name="viewport" content="width=device-width, initial-scale=1">
  5. <title>To-Do List with localStorage</title>
  6. <link rel="stylesheet" href="style.css">
  7. </head>
  8. <main class="app">
  9.   <h1>My To-Do List</h1>
  10.   <form id="todo-form">
  11.     <input id="todo-input" placeholder="What do you need to do?" maxlength="120" autocomplete="off" required>
  12.     <button>Add</button>
  13.   </form>
  14.   <div class="filters">
  15.     <button data-filter="all" class="active">All</button>
  16.     <button data-filter="active">Active</button>
  17.     <button data-filter="done">Done</button>
  18.   </div>
  19.   <ul id="todo-list"></ul>
  20.   <p class="footer"><span id="count"></span> <button id="clear-done" class="link">Clear completed</button></p>
  21.   <p class="hint">Your tasks are saved in this browser with localStorage. Reload the page and they are still here.</p>
  22. </main>
  23. <script src="app.js"></script>
  24. </body>
  25. </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.

  1. function loadTasks() {
  2.   try {
  3.     const tasks = JSON.parse(localStorage.getItem(STORAGE_KEY));
  4.     return Array.isArray(tasks) ? tasks : [];
  5.   } catch {
  6.     return [];                      // corrupted data: start fresh instead of crashing
  7.   }
  8. }
  9.  
  10. function saveTasks() {
  11.   try {
  12.     localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
  13.   } catch (err) {
  14.     alert("Could not save your tasks: " + err.name);
  15.   }
  16. }

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.

  1. function render() {
  2.   list.innerHTML = "";
  3.   const visible = tasks.filter((t) => filter === "all" || (filter === "done" ? t.done : !t.done));
  4.   for (const task of visible) {
  5.     const li = document.createElement("li");
  6.     li.className = task.done ? "done" : "";
  7.     li.dataset.id = task.id;
  8.  
  9.     const checkbox = document.createElement("input");
  10.     checkbox.type = "checkbox";
  11.     checkbox.checked = task.done;
  12.  
  13.     const text = document.createElement("span");
  14.     text.textContent = task.text;   // textContent, not innerHTML: safe from HTML injection
  15.  
  16.     const del = document.createElement("button");
  17.     del.className = "delete";
  18.     del.textContent = "×";
  19.     del.title = "Delete task";
  20.  
  21.     li.append(checkbox, text, del);
  22.     list.append(li);
  23.   }
  24.   const left = tasks.filter((t) => !t.done).length;
  25.   count.textContent = `${left} task${left === 1 ? "" : "s"} left`;
  26. }

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):

  1. form.addEventListener("submit", (e) => {
  2.   e.preventDefault();
  3.   const text = input.value.trim();
  4.   if (!text) return;
  5.   tasks.push({ id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6), text, done: false });
  6.   saveTasks();
  7.   render();
  8.   input.value = "";
  9. });
  10.  
  11. list.addEventListener("click", (e) => {
  12.   const li = e.target.closest("li");
  13.   if (!li) return;
  14.   const task = tasks.find((t) => t.id === li.dataset.id);
  15.   if (e.target.matches("input[type=checkbox]")) task.done = e.target.checked;
  16.   if (e.target.matches(".delete")) tasks = tasks.filter((t) => t !== task);
  17.   saveTasks();
  18.   render();
  19. });

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:

  1. // Keep several open tabs in sync: "storage" fires in the OTHER tabs of the same site
  2. window.addEventListener("storage", (e) => {
  3.   if (e.key === STORAGE_KEY) {
  4.     tasks = loadTasks();
  5.     render();
  6.   }
  7. });
  8.  
  9. 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 HttpOnly cookie 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 innerHTML or eval.
  • 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.