Lesson 07 / 24
Storage APIs
Layers that hold data outliving the page; the distinction between session and origin lifetime, the string store's type and serialization losses, quota and eviction, cross-tab notification, schema migration, and data that should not get stored.
Contents
The previous lesson assumed a form ends with a submission. Most forms stay unfinished: the user enters a measurement value, closes the tab, comes back the next day. Once the page reloads, everything in memory has been erased — both the criterion written into the filter field and the half-finished form.
Saving these requires writing to a place in the browser that outlives the page. There are several such places, and the difference between them is not size but lifetime, access style, and visibility to the server.
Layers
Session storage is limited to the tab’s lifetime. When the same address gets opened in two tabs, two separate stores get seen; the content gets erased once the tab closes. A half-finished form draft, a multi-step flow’s intermediate state get written here.
Local storage persists for the origin’s lifetime. Whether the tab closes or the browser closes, the content stays; every tab of the same origin shares the same store. User preferences, the filter criterion, the last-viewed measurement get written here.
Structured storage (IndexedDB) holds more than a key-value pair: indexed records, transactions, range queries. Its access is asynchronous. A measurement log meant to work offline stays in this layer.
A cookie serves a different purpose than the others: as defined in the How the Internet Works curriculum, it goes to the server with every request. This is the wanted behavior for a session identifier; it is an unwanted cost for interface state. Data meant to stay client-only does not get kept in a cookie.
All four share the same boundary: origin. If the triple formed by scheme, host, and port is different, the store is different too; two different origins hosted on the same server cannot see each other’s data. A cookie’s boundary can also get narrowed with domain and path components.
The String Store’s Behavior
The first two layers hold only strings. This single sentence produces three separate consequences.
// store.mjs — three behaviors of a string-based key-value store class Store { constructor(quota) { this.quota = quota; this.records = new Map(); } get usage() { let t = 0; for (const [k, v] of this.records) t += k.length + v.length; return t; } write(key, value) { const text = String(value); // every value gets converted to a string const previous = this.records.get(key)?.length ?? 0; if (this.usage - previous + key.length + text.length > this.quota) throw new Error("QuotaExceeded"); this.records.set(key, text); } read(key) { return this.records.get(key) ?? null; } } const store = new Store(100); // quota is measured in code units // 1. Type loss: the written number is the read string. store.write("threshold", 12.5); console.log("read:", JSON.stringify(store.read("threshold")), "type:", typeof store.read("threshold")); console.log("missing key:", store.read("missing")); // 2. Serialization loss: JSON is not a type-preserving format. const measurement = { code: "T-01", at: new Date("2024-02-11T06:00:00Z"), note: undefined, tags: new Set(["night"]) }; store.write("measurement", JSON.stringify(measurement)); const restored = JSON.parse(store.read("measurement")); console.log("written:", { at: measurement.at.constructor.name, note: measurement.note, tags: measurement.tags }); console.log("read :", restored); // 3. Quota: the write throws, it does not fail silently. try { store.write("log", "x".repeat(200)); } catch (error) { console.log("error:", error.message, "| usage:", store.usage, "/", store.quota); } console.log("'threshold' after quota exceeded:", JSON.stringify(store.read("threshold")));
read: "12.5" type: string
missing key: null
written: { at: 'Date', note: undefined, tags: Set(1) { 'night' } }
read : { code: 'T-01', at: '2024-02-11T06:00:00.000Z', tags: {} }
error: QuotaExceeded | usage: 81 / 100
'threshold' after quota exceeded: "12.5"
Type loss. The number written comes back as a string when read. Code that compares without converting silently misbehaves. A key that does not exist returns null, not an empty string; the two need distinguishing, because the user may have deliberately left a field empty.
Serialization loss. JSON is not a type-preserving format. A date
object turns into a string, an undefined field drops entirely, a set
turns into an empty object. The reading side cannot settle for just
calling JSON.parse; a layer that converts the record back to the
expected types is needed. This is also why the browser uses a
separate copying mechanism instead of JSON when moving data between
threads; that mechanism gets taken up in the Web Workers lesson.
Quota. The store is not unlimited, and the write throws an error once the limit gets exceeded. The quota’s size is implementation-dependent; code has to adapt by catching the error, not by assuming a number for it. The output’s last line shows a second rule: a failed write does not corrupt the previous record. The write path therefore always stays inside a catch block and produces a behavior visible to the user on failure.
Data can get lost without exceeding the quota, too. The browser can evict an origin’s storage when space gets tight or when data has gone unused for a long time. The user can also clear the storage. Storage therefore gets treated like a cache: a page that also works without its content is correctly designed.
Cross-Tab Notification
Because local storage gets shared by every tab of the same origin, a write in one tab concerns the others. The browser produces an event for this; the event carries the old value, the new value, and the key. This way, a filter criterion changed in one tab can also get applied in another.
This event has two surprising properties. First, it does not get produced in the writing tab; only the other tabs get the notification. Updating its own interface is the writing tab’s own responsibility. Second, this notification is meaningless for session storage, because the store is already tab-specific.
The Cost of Synchronous Access
The first two layers’ access is synchronous: the read and the write complete the moment they get called, and the main thread cannot do other work during that time. This is invisible for small values. Code that writes a large record on every keystroke, though, makes the user interface wait; the queue delay mentioned in the Event Model lesson applies here too.
Three rules limit this cost. Writes get throttled — the delayed triggering from the Form Events lesson applies here too. Large data gets moved to structured storage; its asynchronous interface does not make the main thread wait. Reading gets done once, at page open, and kept in memory; it does not get re-read on every access.
Schema Change
Stored data outlives the code that wrote it. A record written in a six-month-old format can still sit in the user’s browser. This is why every record carries a version field, and the read path contains a chain that carries an old version to the current format.
// migrate.mjs — versioning a stored record and the forward-migration chain const CURRENT = 3; const MIGRATIONS = { 1: (record) => ({ ...record, unit: "C" }), // 1 -> 2: unit added 2: ({ value, ...record }) => ({ ...record, temperature: Number(value) }), // 2 -> 3: field name and type }; function read(raw) { let record; try { record = JSON.parse(raw); } catch { return { result: "unparseable", record: null }; } const version = record.version ?? 1; if (version > CURRENT) return { result: "future version, ignored", record: null }; let v = version; while (v < CURRENT) { record = { ...MIGRATIONS[v](record), version: v + 1 }; v += 1; } return { result: `version ${version} -> ${CURRENT}`, record }; } const samples = [ '{"code":"T-01","value":"-4.2"}', // no version field: counted as 1 '{"version":2,"code":"T-02","value":"3.5","unit":"C"}', '{"version":3,"code":"T-03","temperature":11.8,"unit":"C"}', '{"version":9,"code":"T-04"}', '{code:"T-05"}', ]; for (const raw of samples) { const { result, record } = read(raw); console.log(result.padEnd(26), JSON.stringify(record)); }
version 1 -> 3 {"code":"T-01","unit":"C","version":3,"temperature":-4.2}
version 2 -> 3 {"version":3,"code":"T-02","unit":"C","temperature":3.5}
version 3 -> 3 {"version":3,"code":"T-03","temperature":11.8,"unit":"C"}
future version, ignored null
unparseable null
The chain handles four cases at once. The oldest record, with no version field, counts as version one and gets carried to the current format in two steps. An intermediate version skips the remaining step. A current record passes through untouched.
The last two rows are the defensive side. A record from the future — one the user synced from another device, or one written with a newer version — cannot get carried backward; it does not get read and gets ignored. Unparseable content gets handled the same way. Everything read from storage is external input: it can be corrupted, hand-edited, or never written at all. Parsing always happens inside a catch block.
What Does Not Get Stored
Storage is a space that sits on the user’s device and under the user’s control. Three rules follow from this.
Every piece of code running on the page can read the same store; this is why identity tokens, passwords, and personal data do not get written there. A script injection accesses everything in the store.
The store is not the source of the record. It is secondary to the data on the server; in a conflict, which one wins gets decided explicitly.
The user can clear the store at any moment, and the browser can evict it. This is why stored data has to be reproducible; its loss should not permanently break a function.
Summary
- Session storage is limited to the tab’s lifetime, local storage to the origin’s lifetime; structured storage holds indexed records with an asynchronous interface, and a cookie goes to the server with every request.
- String stores convert the written value to a string, and JSON does not preserve types; the reading side needs a layer that converts the record back to the expected types.
- Quota overflow throws an error and does not corrupt the previous record; the quota’s size does not get assumed, the error gets caught.
- A change in local storage gets notified to other tabs with an event; the writing tab does not see this event and updates its own interface itself.
- Synchronous access makes the main thread wait; writes get throttled, large data gets moved to structured storage, reading gets done once and kept in memory.
- Records carry a version field and go through a migration chain on the read path; content with a future version or unparseable content gets ignored.
Next Step
This lesson saved state but did not touch state’s relationship to the address. When the filter criterion gets kept in storage, it comes back once the page reopens; but that state has no address. The user cannot share the filtered list, cannot bookmark it, cannot return to the previous filter with the back button. These are problems of navigation, not storage, and get solved through the browser’s address bar and history stack. The next lesson examines the address’s parts, how the history stack gets changed, and at which point client-side navigation has to come to terms with the server.
To keep your progress and take notes, Log in
My notes
Log in to take notes.