Skip to content

6.3.3 — Data, Storage & Workers

This request succeeds, and the code below treats it as a failure that never happens:

js
try {
  const res = await fetch('/api/orders', { method: 'POST', body });
  const order = await res.json();     
  showConfirmation(order);
} catch (err) {
  showError();                        // never runs for a 500
}

The server returned 500 with an HTML error page. fetch did not reject, because from its point of view the request worked perfectly — a response came back. res.json() then threw a parse error on the HTML, so the user sees a generic failure with a console message about an unexpected <, and nobody can work out where it came from.

fetch only rejects on a network-level failure — DNS failure, connection refused, CORS blocked, request aborted. Any HTTP status, including 404 and 500, is a successful fetch. That single design decision is the most-hit trap in the API, and every correct usage starts by checking res.ok.

1. fetch, written correctly

ts
async function createOrder(payload: OrderDraft): Promise<Order> {
  // (1) A timeout — fetch has none by default and will wait indefinitely.
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 10_000);

  try {
    const res = await fetch('/api/orders', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },   // (2)
      body: JSON.stringify(payload),
      signal: controller.signal,                          // (3)
      credentials: 'same-origin',                         // (4)
    });

    // (5) The check the opening example was missing.
    if (!res.ok) {
      const problem = await res.json().catch(() => null); // (6)
      throw new ApiError(res.status, problem?.detail ?? res.statusText);
    }

    return await res.json() as Order;
  } finally {
    clearTimeout(timer);                                  // (7)
  }
}

Line (1) matters more than it looks. fetch has no default timeout. A request to a server that accepts the connection and then never answers hangs until the browser or the operating system gives up, which can be minutes. Every real application needs this.

Line (2) sets the content type explicitly because you are sending a JSON string. Note for later: you must not do this for FormData.

Line (3) wires the abort signal in. Aborting causes the promise to reject with an AbortError, which you should distinguish from a real failure — a cancelled request is usually not something to show the user.

Line (4) is the credentials mode, and the defaults are worth knowing exactly. same-origin (the default) sends cookies to your own origin only. include sends them cross-origin, which requires the server to allow it and cannot be used with a wildcard CORS origin (Chapter 6.10). omit sends none.

Line (5) is the fix. Line (6) reads the error body defensively — if the server sent HTML rather than JSON, .json() throws, and the .catch keeps you from losing the real status code behind a parse error.

Line (7) clears the timer in finally so a fast success does not leave a pending abort.

Two more things about the response object

The body can only be read once. res.json(), res.text(), res.blob() and res.arrayBuffer() each consume the stream, and calling a second one throws "body stream already read". If you need it twice — to log it and to parse it — call res.clone() first, before either read.

Reading the body is asynchronous for a real reason. res.json() returns a promise not because parsing is slow but because when the headers arrive, the body may not have. fetch resolves as soon as the status and headers are available, which is what lets you check the status before downloading a large payload, and what makes streaming responses possible at all.

2. FormData versus JSON

Two encodings, and the choice is decided by one question: is there a file?

JSON is right for structured application data. It has real types — numbers stay numbers, null stays null, nested objects stay nested — and it is what an API expects (Chapter 9.6.1).

FormData produces multipart/form-data, which is the only encoding that can carry binary file content alongside text fields. Everything in it is a string or a File; there are no numbers and no nesting.

ts
const form = document.querySelector('#upload') as HTMLFormElement;

// (1) Constructing from a <form> collects every named control automatically.
const data = new FormData(form);

// (2) Add or override fields.
data.append('uploadedAt', new Date().toISOString());
data.append('receipt', fileInput.files![0]);      // (3) a real File object

await fetch('/api/expenses', {
  method: 'POST',
  body: data,                                      // (4) NO Content-Type header
});

Line (1) is the part that saves the most code: passing the form element collects every control that has a name (Chapter 6.2.1's rule again — no name, not included), applying the same rules the browser would on a native submit.

Line (3) appends the file itself, not a path. The browser will stream its bytes.

Line (4) is the rule people break. Do not set Content-Type yourself for FormData. The multipart encoding requires a boundary — a random string that separates the parts and must not appear in the data — and only the browser knows the one it generated. Writing 'Content-Type': 'multipart/form-data' by hand omits the boundary, and the server then cannot split the parts, producing a confusing empty-fields error. Leave the header off and the browser fills it in with the boundary included.

What actually goes on the wire looks like this, which is worth seeing once so the boundary rule stops being arbitrary:

Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryX9f2

------WebKitFormBoundaryX9f2
Content-Disposition: form-data; name="amount"

42.50
------WebKitFormBoundaryX9f2
Content-Disposition: form-data; name="receipt"; filename="lunch.jpg"
Content-Type: image/jpeg

<binary bytes>
------WebKitFormBoundaryX9f2--

A third encoding exists and is occasionally the right one: URLSearchParams produces application/x-www-form-urlencoded, the classic a=1&b=2 shape. It is compact, it is what a plain HTML form sends by default, and some older APIs require it.

fetch cannot report upload progress. This is a genuine gap: the request body is not observable. For a progress bar during a large upload you need XMLHttpRequest, which exposes upload.onprogress, or you need to chunk the upload yourself and count the chunks — which is what Chapter 6.8.2 does for resumable uploads, and which you want anyway for anything large.

3. Where the browser can put data

Five options, and picking wrongly causes either a data-loss bug or a security incident.

StoreSizeSync/asyncSent to serverSurvives
Cookies~4 KB eachsyncyes, every requestuntil expiry
localStorage~5–10 MBsyncnountil cleared
sessionStorage~5–10 MBsyncnotab close
IndexedDBlarge (quota)asyncnountil cleared
Cache Storagelarge (quota)asyncnountil cleared

Cookies are covered fully in Chapter 5.6.3. The one line to carry here: they are attached to every request to the origin, so anything stored in one costs bandwidth on every single call.

localStorage and sessionStorage share an API and differ only in lifetime — sessionStorage is per-tab and disappears when the tab closes, which makes it right for things like a multi-step form's in-progress state.

Three limitations of both, and the first one is the one people forget:

They are synchronous, on the main thread. Every read and write blocks everything (Chapter 6.1.1). For a few small values that is nothing. For a 2 MB JSON blob written on every keystroke it is a visible freeze, and it will not show up as a slow function in your own code — it shows up as a slow setItem.

They store strings only. JSON.stringify on the way in, JSON.parse on the way out, and undefined becomes the string "undefined" if you forget.

They can throw. Exceeding the quota throws QuotaExceededError, and in some private-browsing modes storage is unavailable or zero-quota. Wrap writes:

ts
function saveDraft(key: string, value: unknown): boolean {
  try {
    localStorage.setItem(key, JSON.stringify(value));
    return true;
  } catch {
    return false;    // quota exceeded, or storage disabled
  }
}

IndexedDB is the real database: asynchronous, transactional, indexed, and able to store structured values directly — objects, Dates, Blobs, ArrayBuffers, via the structured clone algorithm rather than JSON. Use it for anything genuinely large or genuinely structured: an offline copy of a dataset, queued actions to sync later, cached images. Its raw API is famously awkward — it predates promises and is built on request objects with event handlers — so most projects wrap it in a small promise-based helper.

Cache Storage holds Request/Response pairs and is what a service worker uses to serve pages offline (Chapter 6.8.2). It is the right place for responses; IndexedDB is the right place for data.

What not to store

Do not put an authentication token in localStorage. The reasoning is worked through in Chapter 5.6.3 and it comes down to this: any cross-site scripting bug on your origin can read all of localStorage and send it anywhere, whereas an HttpOnly cookie cannot be read by JavaScript at all. The counter-argument you will hear is that cookies are vulnerable to cross-site request forgery — true, and SameSite plus a token check answers it, while nothing answers "the attacker has your token".

Treat everything in storage as untrusted input. The user can edit it, and so can any script on your origin. Validate a stored value on read exactly as you would validate a request body.

Keeping tabs in sync

Two tabs of the same site are separate JavaScript worlds. Two mechanisms connect them:

js
// (1) Fires in OTHER tabs when localStorage changes. Not in the tab that wrote.
window.addEventListener('storage', (e) => {
  if (e.key === 'auth') location.reload();     // logged out elsewhere
});

// (2) A direct message channel between same-origin contexts.
const channel = new BroadcastChannel('basket');
channel.postMessage({ type: 'item-added', id: 'sku-8891' });
channel.onmessage = (e) => updateBasketBadge(e.data);

Line (1) is the older mechanism and its defining quirk is that it does not fire in the tab that made the change — which is exactly right for synchronisation and confusing the first time you test it in one tab.

Line (2) is the purpose-built version: any same-origin tab, iframe or worker can join a named channel and messages go to all of them except the sender. Use it for basket badges, "you have been logged out", or telling other tabs that data has been refreshed.

Sending data as the page closes

Analytics on unload used to be done with a synchronous request, which the browser is now free to cancel or block:

js
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    navigator.sendBeacon('/api/analytics', JSON.stringify(session));
  }
});

sendBeacon queues a small POST that the browser guarantees to send even after the page is gone, without delaying navigation. Use visibilitychange rather than unload — it fires reliably on mobile where a tab may be discarded without ever firing unload, and it keeps the page eligible for the back/forward cache (Chapter 6.1.1).

4. Web workers: getting off the main thread

Everything so far runs on the one thread that also does layout, paint and event handling. A worker is a second JavaScript environment, in the same process, with its own thread and its own global scope.

ts
// ---- main.ts ----
const worker = new Worker(new URL('./parse-csv.worker.ts', import.meta.url), {
  type: 'module',                                    // (1)
});

worker.postMessage({ file });                        // (2) structured clone
worker.onmessage = (e) => renderTable(e.data.rows);  // (3)
worker.onerror = (e) => reportError(e.message);      // (4)
// worker.terminate();                               // (5) when finished
ts
// ---- parse-csv.worker.ts ----
self.onmessage = async (e: MessageEvent<{ file: File }>) => {
  const text = await e.data.file.text();
  const rows = parseCsv(text);        // 400 ms of CPU — and the page stays smooth
  self.postMessage({ rows });
};

Line (1) new URL(..., import.meta.url) is the form bundlers recognise, so the worker file is emitted as a separate chunk with the right path. type: 'module' lets the worker use import.

Line (2) sends a message. The data is copied, not shared, using the structured clone algorithm — the same one behind structuredClone() (Chapter 3.6.11). It handles objects, arrays, Map, Set, Date, RegExp, Blob, File and typed arrays, and it cannot copy functions, DOM nodes, or class instances (you get a plain object with the same fields and no prototype).

Line (4) is not optional: an uncaught error inside a worker does not surface anywhere by default, and a silent worker is very hard to debug.

Line (5) terminates it. A worker occupies real memory and a real thread, so long-lived pages should either reuse one worker or terminate it when done.

What a worker cannot do: touch the DOM. There is no document, no window. It has fetch, timers, IndexedDB, WebSocket, crypto, OffscreenCanvas and most of the rest. That restriction is the point — the DOM is not thread-safe and making it so would be a far larger change than the platform was willing to make.

The copy cost, and how to avoid it

Structured cloning is not free. Sending a 50 MB ArrayBuffer copies 50 MB, and the copy happens on the sending thread — so a badly designed worker interface can cost more than the work it offloads.

Transferables move ownership instead of copying:

ts
// The buffer is MOVED. After this line it is detached and unusable here.
worker.postMessage({ buffer }, [buffer]);   // (1)
console.log(buffer.byteLength);             // 0 — it belongs to the worker now

Line (1)'s second argument is the transfer list. Transfer is effectively instant regardless of size, because only a pointer changes hands. ArrayBuffer, MessagePort, ImageBitmap and OffscreenCanvas are transferable.

SharedArrayBuffer goes further and gives both threads access to the same memory, with Atomics for coordination (Chapter 9.5.6 covers the concurrency side). It requires the page to be cross-origin isolated — the Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp headers — which was imposed after Spectre, for the reason Chapter 6.1.1 gave: shared memory plus a precise timer is what the attack needs. Those headers also break most third-party embeds, so enabling them is an architectural decision rather than a flag.

When a worker is actually worth it

The interface has a cost — message passing, a separate build entry, awkward debugging — so it must be paid for by real CPU work:

  • Parsing or transforming a large file (CSV, JSON over a few megabytes).
  • Image manipulation, compression, or generating thumbnails before upload.
  • Cryptography, hashing, or computing a checksum for a resumable upload.
  • Search or filtering over a large in-memory dataset.
  • Anything running a physics or layout simulation continuously.

When it is not worth it: anything under about 50 ms, and anything dominated by waiting rather than computing. Network requests are already asynchronous and do not block the main thread, so moving fetch into a worker buys nothing.

The three related worker types, so the names stop being confusing: a dedicated worker belongs to one page (the one above). A shared worker can be reached by several same-origin tabs at once. A service worker is different in kind — it is a network proxy that runs without a page and is covered in Chapter 6.8.2.

What the interviewer will push on

"Does fetch reject on a 500?" No. It rejects only on network-level failure — DNS, connection, CORS, abort. You must check res.ok. This is the single most common fetch mistake and the answer they are listening for.

"How do you add a timeout to fetch?" AbortController with a setTimeout that calls abort(), cleared in a finally. Volunteer that there is no default timeout at all, which is why the omission is dangerous rather than merely untidy.

"When would you use FormData over JSON?" When a file is involved, because multipart is the only encoding that carries binary alongside fields. Then give the rule that proves you have done it: never set Content-Type manually, because the boundary is generated by the browser.

"localStorage or IndexedDB?" localStorage for a few small strings, accepting that it is synchronous and blocks the main thread. IndexedDB for anything large, structured or binary. Then note the quota exception and private mode, because a setItem that throws in production is a real incident.

"Where do you store an auth token?" An HttpOnly cookie, because a cross-site scripting bug can read everything in localStorage and cannot read an HttpOnly cookie. Acknowledge the CSRF trade honestly and name SameSite as the mitigation — a candidate who only knows one side of this has learned a slogan.

"What can a web worker not do, and when is one worth it?" No DOM. Worth it for real CPU work — parsing, images, crypto, large-dataset filtering — and pointless for anything I/O-bound, because fetch is already off the main thread. Mention the structured-clone copy cost and transferables, because a worker whose message cost exceeds its work is a common own-goal.

One thing to volunteer: explain why res.json() is a promise — fetch resolves when the headers arrive, not when the body does, which is what lets you check the status before downloading a large payload and what makes streaming possible. It reframes an API detail people memorise as a design decision they can reason about.

Recall

  • fetch does not reject on 4xx or 5xx — only on network failure, CORS blocking or abort. Always check res.ok. And it has no default timeout; use AbortController plus setTimeout.
  • A response body can be read once; use res.clone() if you need it twice. res.json() is async because fetch resolves at the headers, before the body has arrived.
  • FormData/multipart is for files; JSON is for structured data (and keeps real types). Never set Content-Type for FormData — the browser must add the generated boundary. URLSearchParams gives the classic urlencoded form. fetch cannot report upload progress; use XMLHttpRequest or chunk it yourself.
  • Storage: cookies are ~4 KB and sent on every request; localStorage/sessionStorage are synchronous, main-thread, string-only, ~5–10 MB and can throw QuotaExceededError; IndexedDB is async, transactional and stores structured values; Cache Storage holds request/response pairs for a service worker.
  • Auth tokens belong in an HttpOnly cookie, not localStorage — XSS reads all of localStorage, and SameSite answers the CSRF objection.
  • Cross-tab: the storage event fires in other tabs only; BroadcastChannel is the purpose-built channel. Use sendBeacon on visibilitychange for exit analytics, never unload.
  • A worker is a second thread with no DOM. Messages are copied by structured clone (no functions, no DOM nodes, no class prototypes); transferables move ownership instantly; SharedArrayBuffer needs cross-origin isolation because of Spectre.
  • Workers pay off for real CPU work — parsing, images, crypto, large-dataset filtering — and buy nothing for I/O, which is already asynchronous.

Self-test: Why did a 500 response reach the .json() call instead of the catch block? · Why must you leave Content-Type off a FormData request? · What exactly is slow about a big localStorage.setItem? · Which storage is safe for a session token, and what is the counter-argument? · When does a worker cost more than it saves?

Next: 6.4.1 moves up a level. Everything so far has been the platform; the next four pages are the abstraction almost everyone builds on top of it, starting with what React is actually doing to the DOM you have just learned to manipulate by hand.