Skip to content
aviral gupta

// I6.4 · ~36 min · Intermediate

localStorage, sessionStorage and IndexedDB

After this lesson you can keep settings and small lists in the browser with localStorage, store objects safely as JSON, and say when IndexedDB is the better choice.

Lesson 4 of 5 in I6 Network requests and client storage

You will be able to

  • Save, read and remove values with setItem, getItem, removeItem and clear, and expect null for a missing key
  • Store numbers, arrays and objects as strings with JSON, with a default for a missing or broken value
  • Choose between localStorage, sessionStorage and IndexedDB by origin, lifetime, size and speed
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson I6.1: the body of a POST request. What does this print?

    const body = JSON.stringify({title: "Dune", year: 1965});
    console.log(typeof body, body);
  2. Predict · Activity 2 of 7

    Predict before you read on. What does this print?

    localStorage.setItem("count", 3);
    const count = localStorage.getItem("count");
    console.log(count + 1);
  3. Practice · Activity 3 of 7

    Fill in the method that stores "dark" under the key "theme".

    localStorage.____("theme", "dark");
    localStorage.("theme", "dark");
  4. Practice · Activity 4 of 7

    The storage of this origin is still empty. What does this print?

    console.log(localStorage.getItem("nothing"), localStorage.length);
  5. Practice · Activity 5 of 7

    An object goes straight into localStorage. What does this print?

    localStorage.setItem("user", {name: "Ada"});
    console.log(localStorage.getItem("user"));
  6. Brain teaser · Activity 6 of 7

    Brain teaser. "cart" has never been saved. What does this print?

    const cart = JSON.parse(localStorage.getItem("cart")) ?? [];
    cart.push("Dune");
    localStorage.setItem("cart", JSON.stringify(cart));
    console.log(localStorage.getItem("cart"), typeof localStorage.getItem("cart"));
  7. Apply · Activity 7 of 7

    Mini-task: write loadSettings() and saveSettings(settings) for the settings {theme: "light", fontSize: 16}. loadSettings gives the saved settings, with the defaults for anything missing, and the defaults when nothing or something broken is saved. Try it in the browser console of a page served from a local web server: load, save the dark theme, load again, then break the value by hand.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

A visit counter and a saved list

The page counts its visits and keeps a reading list in localStorage. main.js stores a number and reads it back as a string, saves an array as JSON and loads it again, and shows what happens to a value that was saved without JSON.stringify. At the end it clears the storage, so every run starts the same.

main.js

// Numbers come back as strings.
const visits = Number(localStorage.getItem("visits") ?? 0) + 1;
localStorage.setItem("visits", visits);
console.log("visits:", localStorage.getItem("visits"), typeof localStorage.getItem("visits"));
console.log("missing key:", localStorage.getItem("nothing"));

// Arrays and objects go through JSON.
function loadBooks() {
  try {
    return JSON.parse(localStorage.getItem("books")) ?? [];
  } catch {
    return []; // broken data: start with an empty list
  }
}
function saveBooks(books) {
  localStorage.setItem("books", JSON.stringify(books));
}

saveBooks([...loadBooks(), "Dune", "Emma"]);
console.log("stored:", localStorage.getItem("books"));
const books = loadBooks();
console.log("loaded:", books, books.length);

localStorage.setItem("books", "Dune,Emma"); // saved without JSON.stringify
console.log("broken:", loadBooks());

document.querySelector("#visits").textContent = "Visit number " + visits;
const list = document.querySelector("#books");
for (const title of books) {
  const item = document.createElement("li");
  item.textContent = title;
  list.append(item);
}

localStorage.clear();
console.log("after clear:", localStorage.length);

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Reading list</title>
</head>
<body>
  <h1>My reading list</h1>
  <p id="visits"></p>
  <ul id="books"></ul>
  <script type="module" src="main.js"></script>
</body>
</html>

To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).

Output

visits: 1 string
missing key: null
stored: ["Dune","Emma"]
loaded: [ 'Dune', 'Emma' ] 2
broken: []
after clear: 0
<!-- #books -->
<ul id="books"><li>Dune</li><li>Emma</li></ul>
  • The visit count was saved as the number 1 and came back as the string "1".
  • The stored list is the JSON text; JSON.parse turned it back into an array.
  • "Dune,Emma" is not JSON, so loadBooks fell back to an empty list instead of crashing.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Exercises

Exercise 1 of 3

Remember the theme

The page shows the theme and has a Light and a Dark button. On load it already shows the saved theme, or light. A click shows the chosen theme, but the starter never saves it, so after a reload the page is light again. Save the choice under the key "theme".

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    Run the tests: after a click, localStorage has no theme. Where does the page store anything?

  2. Hint 2

    The click listener knows the chosen theme.

  3. Hint 3

    localStorage.setItem("theme", theme); inside the click listener

Show a solution

One way to solve it. Yours can look different and still pass the checks.

const label = document.querySelector("#theme");

function show(theme) {
  document.body.className = theme;
  label.textContent = "Theme: " + theme;
}

show(localStorage.getItem("theme") ?? "light");

for (const theme of ["light", "dark"]) {
  document.querySelector("#" + theme).addEventListener("click", () => {
    show(theme);
    localStorage.setItem("theme", theme);
  });
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

const label = document.querySelector("#theme");

function show(theme) {
  document.body.className = theme;
  label.textContent = "Theme: " + theme;
}

show(localStorage.getItem("theme") ?? "light");

for (const theme of ["light", "dark"]) {
  document.querySelector("#" + theme).addEventListener("click", () => {
    show(theme);
  });
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {loadPage} from './learnrun.js';

const text = (page, selector) => page.document.querySelector(selector).textContent;
const click = (page, selector) => page.document.querySelector(selector).click();

test('Without a saved theme the page shows light', async () => {
  const page = await loadPage();
  assert.equal(text(page, '#theme'), 'Theme: light', `the page shows ${text(page, '#theme')}`);
});

test('A saved theme is shown on load', async () => {
  const page = await loadPage({localStorage: {theme: 'dark'}});
  assert.equal(text(page, '#theme'), 'Theme: dark', `the page shows ${text(page, '#theme')}`);
});

test('The chosen theme is still there after a reload', async () => {
  const page = await loadPage();
  click(page, '#dark');
  assert.equal(page.window.localStorage.getItem('theme'), 'dark', `localStorage has theme ${page.window.localStorage.getItem('theme')}`);
  const reloaded = await loadPage({localStorage: page.window.localStorage});
  assert.equal(text(reloaded, '#theme'), 'Theme: dark', `after a reload the page shows ${text(reloaded, '#theme')}`);
});

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Theme</title>
</head>
<body>
  <p id="theme"></p>
  <button id="light" type="button">Light</button>
  <button id="dark" type="button">Dark</button>
  <script type="module" src="main.js"></script>
</body>
</html>

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).

Run the checks (needs learnrun.js in the same folder):

npm install jsdom@28.1.0
node --test
Download learnrun.js

Exercise 2 of 3

A reading list that survives a reload

The page keeps a reading list under the key "books". It already loads the list safely and adds a title on each click of Add. But the starter saves the array itself, which localStorage turns into "Dune,Emma", so after a reload the list cannot be parsed and is empty. Save it as JSON.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    Run the tests: localStorage has books = Dune,Emma. What does setItem do with an array?

  2. Hint 2

    Anything that is not a string is turned into one the default way. JSON keeps the brackets and quotes.

  3. Hint 3

    localStorage.setItem("books", JSON.stringify(books));

Show a solution

One way to solve it. Yours can look different and still pass the checks.

const list = document.querySelector("#books");
const input = document.querySelector("#title");

function load() {
  try {
    return JSON.parse(localStorage.getItem("books")) ?? [];
  } catch {
    return []; // a broken value: start again
  }
}

function render(books) {
  list.replaceChildren(...books.map((title) => {
    const item = document.createElement("li");
    item.textContent = title;
    return item;
  }));
}

const books = load();
render(books);

document.querySelector("#add").addEventListener("click", () => {
  books.push(input.value);
  localStorage.setItem("books", JSON.stringify(books));
  render(books);
  input.value = "";
});
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

const list = document.querySelector("#books");
const input = document.querySelector("#title");

function load() {
  try {
    return JSON.parse(localStorage.getItem("books")) ?? [];
  } catch {
    return []; // a broken value: start again
  }
}

function render(books) {
  list.replaceChildren(...books.map((title) => {
    const item = document.createElement("li");
    item.textContent = title;
    return item;
  }));
}

const books = load();
render(books);

document.querySelector("#add").addEventListener("click", () => {
  books.push(input.value);
  localStorage.setItem("books", books);
  render(books);
  input.value = "";
});

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {loadPage} from './learnrun.js';

const text = (page, selector) => page.document.querySelector(selector).textContent;
const click = (page, selector) => page.document.querySelector(selector).click();

const titles = (page) => [...page.document.querySelectorAll('#books li')].map((item) => item.textContent);
function add(page, title) {
  page.document.querySelector('#title').value = title;
  click(page, '#add');
}

test('Saved books are shown on load', async () => {
  const page = await loadPage({localStorage: {books: '["Dune","Emma"]'}});
  assert.deepEqual(titles(page), ['Dune', 'Emma'], `the list shows ${JSON.stringify(titles(page))}`);
});

test('A broken saved value gives an empty list', async () => {
  const page = await loadPage({localStorage: {books: '{oops'}});
  assert.deepEqual(titles(page), [], `the list shows ${JSON.stringify(titles(page))}`);
});

test('Added books are saved as JSON and survive a reload', async () => {
  const page = await loadPage();
  add(page, 'Dune');
  add(page, 'Emma');
  const saved = page.window.localStorage.getItem('books');
  assert.equal(saved, '["Dune","Emma"]', `localStorage has books = ${saved}`);
  const reloaded = await loadPage({localStorage: page.window.localStorage});
  assert.deepEqual(titles(reloaded), ['Dune', 'Emma'], `after a reload the list shows ${JSON.stringify(titles(reloaded))}`);
});

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Reading list</title>
</head>
<body>
  <input id="title" aria-label="Title">
  <button id="add" type="button">Add</button>
  <ul id="books"></ul>
  <script type="module" src="main.js"></script>
</body>
</html>

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).

Run the checks (needs learnrun.js in the same folder):

npm install jsdom@28.1.0
node --test
Download learnrun.js

Exercise 3 of 3

A counter that goes on

The page shows a count and a +1 button, and saves the count under the key "count". On a new page it counts 0, 1, 2. But with a saved count of 4, one click shows 41 instead of 5: the starter adds 1 to the string it read. Turn the saved value into a number when the page loads.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    Run the tests: a saved 4 becomes 41. What type does getItem give back?

  2. Hint 2

    "4" + 1 joins two strings. Turn the value into a number once, when the page loads.

  3. Hint 3

    let count = Number(localStorage.getItem("count") ?? 0);

Show a solution

One way to solve it. Yours can look different and still pass the checks.

const output = document.querySelector("#count");
let count = Number(localStorage.getItem("count") ?? 0);
output.textContent = count;

document.querySelector("#more").addEventListener("click", () => {
  count = count + 1;
  localStorage.setItem("count", count);
  output.textContent = count;
});
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

const output = document.querySelector("#count");
let count = localStorage.getItem("count") ?? 0;
output.textContent = count;

document.querySelector("#more").addEventListener("click", () => {
  count = count + 1;
  localStorage.setItem("count", count);
  output.textContent = count;
});

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {loadPage} from './learnrun.js';

const text = (page, selector) => page.document.querySelector(selector).textContent;
const click = (page, selector) => page.document.querySelector(selector).click();

test('A new page starts at 0 and counts to 1', async () => {
  const page = await loadPage();
  assert.equal(text(page, '#count'), '0', `at the start the page shows ${text(page, '#count')}`);
  click(page, '#more');
  assert.equal(text(page, '#count'), '1', `after one click the page shows ${text(page, '#count')}`);
});

test('A saved count of 4 goes on with 5', async () => {
  const page = await loadPage({localStorage: {count: '4'}});
  click(page, '#more');
  assert.equal(text(page, '#count'), '5', `after one click the page shows ${text(page, '#count')}`);
});

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Counter</title>
</head>
<body>
  <p id="count"></p>
  <button id="more" type="button">+1</button>
  <script type="module" src="main.js"></script>
</body>
</html>

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).

Run the checks (needs learnrun.js in the same folder):

npm install jsdom@28.1.0
node --test
Download learnrun.js

Common mistakes

Parsing a key that was never saved

const books = JSON.parse(localStorage.getItem("books"));
document.querySelector("#out").textContent = books.length + " books";

What Node.js prints

TypeError: Cannot read properties of null (reading 'length')

Why, and the fix

On a first visit nothing is saved yet. getItem gives null, and JSON.parse(null) is null too, without an error, so the crash comes one line later. Give a default: const books = JSON.parse(localStorage.getItem("books")) ?? [];.

Saving an object without JSON.stringify

localStorage.setItem("user", {name: "Ada"});
const user = JSON.parse(localStorage.getItem("user"));
document.querySelector("#out").textContent = user.name;

What Node.js prints

SyntaxError: "[object Object]" is not valid JSON

Why, and the fix

setItem stores strings only, so the object became the text "[object Object]", which is not JSON. Save it with localStorage.setItem("user", JSON.stringify(user)), and keep JSON.parse for loading.

localStorage in a Node.js program

localStorage.setItem("theme", "dark");
console.log(localStorage.getItem("theme"));

What Node.js prints

ReferenceError: localStorage is not defined

Why, and the fix

localStorage belongs to the browser. In Node.js 24 it exists only when node is started with --experimental-webstorage, so node main.js does not know it. Run storage code in a page (index.html with main.js, from a local web server); in Node.js, keep data in a file with node:fs instead.

JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Strings under keys, per origin

localStorage.setItem("theme", "dark") stores a value under a key; getItem("theme") reads it, removeItem("theme") deletes it and clear() deletes everything. Keys and values are strings: setItem("count", 3) stores "3". getItem gives null for a key that does not exist. Every origin (scheme, host and port) has its own storage, which other sites cannot read, and it stays after the browser is closed. sessionStorage has the same methods, but belongs to one tab and is gone when the tab is closed.

Objects through JSON, with a default

setItem("user", {name: "Ada"}) stores the text "[object Object]", and an array becomes "Dune,Emma". Save with JSON.stringify and load with JSON.parse. A missing key gives null, and JSON.parse(null) is null, so add a default: JSON.parse(localStorage.getItem("books")) ?? []. A value can also be broken, saved by an older version of your code or changed by hand, so wrap JSON.parse in try...catch. Numbers come back as strings: Number(localStorage.getItem("count") ?? 0).

Limits, other tabs and IndexedDB

Storage per origin is limited: when it is full, setItem throws a QuotaExceededError. Web Storage is synchronous, so every call blocks the page: keep it for small things such as settings. When another tab of the same origin changes localStorage, the storage event fires in your tab, not in the tab that made the change. For many records, files, or searching by a field, use IndexedDB: an asynchronous database in the browser with object stores, indexes and transactions. It is more work to use, so choose it only when you need it.

Sources

Last reviewed October 5, 2026