---
title: 'Storage APIs'
source: 'https://academia.sh/en/courses/browser-platform/storage-apis'
course: 'The Browser and the Web Platform'
language: en
updated: '2026-08-17T18:09:11+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.

```js
// 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.
