Skip to content
Chapter 15Lesson 1

The universal HTTP client

The fetch API is the shared network primitive across every runtime in this course, taught as the hardened call shape you write everywhere from the browser to a Server Action.

A user clicks “New invoice”. Your code POSTs a JSON body to /api/invoices, takes the row the server sends back, and renders it. Three lines, until you put it under load, where it breaks four ways. The server is slow, so the request hangs and the button spins forever. The user navigates away mid-flight, but your code keeps running, about to write into a screen that’s gone. The server rejects the body with 422 Unprocessable Entity, and your three lines render that error payload as an invoice. Or the API ships a breaking change, the JSON comes back the wrong shape, and invoice.total is undefined three components deep.

One button, four ways to be wrong. This lesson answers one question: what call shape survives all four, with no retry library, no axios, no wrapper of any kind?

The answer is built on fetch, the network primitive for every runtime this course touches: Chrome and Safari, Node, the Edge runtime, a Server Component, a Server Action. It is one async function you await, and it hands back a typed Response. The same fetch(input, init) covers all of it, so the shape you learn here is the shape you write everywhere. The reflexes fetch leaves to you, not the syntax, are where most of its bugs live.

fetch resolves for every response, even 404 and 500

Section titled “fetch resolves for every response, even 404 and 500”

The most common fetch bug comes from one misconception: you wrap a fetch in try/catch, assume catch means “the request failed,” and never check the response. That model is wrong, and it fails quietly, corrupting data instead of throwing.

fetch does not throw when the server returns an error status. The promise resolves for any HTTP response the server sends: 200, 404, 422, 500, all of them. To fetch, a 500 is a success, because the request left, the server answered, and the round-trip closed cleanly. That the answer was “I crashed” is information inside the resolved response, not a reason to reject.

The promise rejects for one category only: when there was no usable response at all. That covers a missing DNS record, a refused connection, a dropped network, an aborted request, a fired deadline, or a CORS preflight that said no. The transport itself failed, and that is what lands in catch.

So you have two completely different failure surfaces, and you keep them strictly apart.

This is why response.ok, true for any status in 200–299 and false otherwise, is the load-bearing branch of every call you write. It separates “the server answered with an error” from “there was no answer.”

await fetch(url) request leaves the client
resolves
Response 200 · 404 · 422 · 500
response.ok === true parse the body
response.ok === false read the error body
rejects
catch (error) no response at all

The request fires. await fetch(url) leaves the client and we wait. Nothing has come back yet. It can end only two ways, the two arrows leaving the call: it resolves into a Response, or it rejects into catch.

await fetch(url) request leaves the client
resolves
Response 200 · 404 · 422 · 500
response.ok === true parse the body
response.ok === false read the error body
rejects
catch (error) no response at all

The server answers 200. The promise resolves into a Response, response.ok is true, and we take the parse path. The happy case, but watch what the next step shares with it.

await fetch(url) request leaves the client
resolves
Response 200 · 404 · 422 · 500
response.ok === true parse the body
response.ok === false read the error body
rejects
catch (error) no response at all

The server answers 422 or 500. The promise still resolves, the surprise. It travels the same arrow into the same Response box as the 200 did. The only difference: response.ok is now false, so we take the error-body path. The catch is never entered.

await fetch(url) request leaves the client
resolves
Response 200 · 404 · 422 · 500
response.ok === true parse the body
response.ok === false read the error body
rejects
catch (error) no response at all

The connection drops, DNS fails, or a deadline fires. Now there is no response at all, so the promise rejects and lands in catch. This is the only step that gets here.

Assume `/missing` returns a `404`. What does this print? Predict what this program prints, then press Check.

const response = await fetch('/missing');
console.log('ok:', response.ok);
console.log('status:', response.status);
console.log('reached');

If you predicted a thrown error, that’s the misconception this section corrects. A 404 is data: you read it, you don’t catch it.

Back to the invoice button. The “Naive” tab is the optimistic code with every guard skipped; the “Hardened” tab is the shape we’re about to build. Flip between them.

async function createInvoice(input: InvoiceInput) {
const res = await fetch('/api/invoices', {
method: 'POST',
body: JSON.stringify(input),
});
const invoice = await res.json();
return invoice;
}

Four failures in six lines. It assumes success, so a 422 sails into res.json() and returns as an invoice. It sets no deadline, so a slow server hangs the call forever. It can’t be cancelled, so it keeps running after the user navigates away. And it trusts the wire: whatever JSON comes back is returned unchecked, any all the way to the UI.

The rest of the lesson adds these fixes one at a time, in the order they appear in the call.

The five seams of every fetch call: build, send, ok-branch, parse, catch

Section titled “The five seams of every fetch call: build, send, ok-branch, parse, catch”

Every fetch call, in the browser or on the server, is a pipeline of five seams:

  1. build: assemble the request.
  2. send: await fetch, get a Response.
  3. ok-branch: check response.ok, handle the error status.
  4. parse: read and validate the success body.
  5. catch: handle the transport failures.

Learn these five names. Reading any fetch call means scanning for all five and noticing which one is missing, and a missing seam is its own class of bug. The walkthrough below steps through the hardened createInvoice one seam at a time.

async function createInvoice(input: InvoiceInput): Promise<Result<Invoice>> {
try {
const response = await fetch('/api/invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) {
return err('validation', 'The invoice could not be saved.');
}
return ok(invoiceSchema.parse(await response.json()));
} catch (error) {
if (error instanceof Error && error.name === 'TimeoutError') {
return err('internal', 'The request timed out. Try again.');
}
return err('internal', 'Could not reach the server.');
}
}

Build. Assemble the request. The second argument to fetch is the init object: the method, the headers describing the body, the body itself serialized with JSON.stringify, and a signal carrying the deadline. Everything about the outbound request lives here.

async function createInvoice(input: InvoiceInput): Promise<Result<Invoice>> {
try {
const response = await fetch('/api/invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) {
return err('validation', 'The invoice could not be saved.');
}
return ok(invoiceSchema.parse(await response.json()));
} catch (error) {
if (error instanceof Error && error.name === 'TimeoutError') {
return err('internal', 'The request timed out. Try again.');
}
return err('internal', 'Could not reach the server.');
}
}

Send. Fire the request and await it. await fetch(...) resolves to the Response, not the body, and it resolves for a 200 and a 500 alike. The body is still on the wire; reading it is a separate, second await.

async function createInvoice(input: InvoiceInput): Promise<Result<Invoice>> {
try {
const response = await fetch('/api/invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) {
return err('validation', 'The invoice could not be saved.');
}
return ok(invoiceSchema.parse(await response.json()));
} catch (error) {
if (error instanceof Error && error.name === 'TimeoutError') {
return err('internal', 'The request timed out. Try again.');
}
return err('internal', 'Could not reach the server.');
}
}

Ok-branch. Immediately after the await, check response.ok. This is where the 422 is caught. A real handler reads the typed error body here and maps it to a Result failure; we return a single err(...) for now. Nothing happens to the body until this branch has run.

async function createInvoice(input: InvoiceInput): Promise<Result<Invoice>> {
try {
const response = await fetch('/api/invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) {
return err('validation', 'The invoice could not be saved.');
}
return ok(invoiceSchema.parse(await response.json()));
} catch (error) {
if (error instanceof Error && error.name === 'TimeoutError') {
return err('internal', 'The request timed out. Try again.');
}
return err('internal', 'Could not reach the server.');
}
}

Parse. Past the ok branch lies the success path. await response.json() reads the body but hands you any, so you validate it with invoiceSchema.parse(...) before trusting a single field. Never trust the wire.

async function createInvoice(input: InvoiceInput): Promise<Result<Invoice>> {
try {
const response = await fetch('/api/invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) {
return err('validation', 'The invoice could not be saved.');
}
return ok(invoiceSchema.parse(await response.json()));
} catch (error) {
if (error instanceof Error && error.name === 'TimeoutError') {
return err('internal', 'The request timed out. Try again.');
}
return err('internal', 'Could not reach the server.');
}
}

Catch. Handle the transport failures from the send seam, and only those. Narrow on error.name to tell a timeout apart from a dropped connection, then map each to a Result failure. The 422 never reaches here; it was handled in the ok-branch.

1 / 1

A throwaway script might fold build and send into one line and skip the parse; a production call writes all five explicitly. The shape is there either way.

Every fetch call touches three Web Platform types, not fetch-specific ones. The receiving side you write later (a route handler) is handed a Request and returns a Response, the same two types, so you learn them once and recognize them on both ends of the wire.

  • Request is the outbound request: URL, method, headers, body. You usually construct it inline by passing the init object as fetch’s second argument, but new Request(url, init) builds the same thing.
  • Response is the resolved result of await fetch: status, ok, headers, and a body you read with one of the consumer methods.
  • Headers is a case-insensitive multimap of header name to value, with .get, .set, .append, and .entries.

The one non-obvious behavior is case-insensitive lookup: headers.get('content-type') and headers.get('Content-Type') return the same value, so you never match the server’s capitalization.

const headers = new Headers();
headers.set('Content-Type', 'application/json');
headers.get('content-type'); // 'application/json'
headers.get('Content-Type'); // 'application/json' — same value

Reading the body: pick one consumer, consume it once

Section titled “Reading the body: pick one consumer, consume it once”

await fetch resolved to a Response, but the body isn’t in your hands yet: it’s a stream still arriving over the wire. To read it you call one of five consumer methods, chosen by what the server sent:

  • response.json() parses a JSON body, the default for the JSON APIs a web app talks to all day.
  • response.text() gives you raw text: an HTML fragment, a log, a CSV.
  • response.formData() gives you a FormData object, the inverse of submitting a <form>.
  • response.blob() gives you binary as an opaque Blob: an image to re-display, a PDF to download.
  • response.arrayBuffer() gives you binary as raw bytes, for typed-array reads, crypto, and low-level work.

Each returns a promise, so reading a body is the second await: one to get the Response, a second to read it. That read comes with one rule.

The trap is wanting both the raw text and the parsed object, calling .text() then .json(); the second throws. The fix: read once into a variable, then act on the variable.

const response = await fetch('/api/invoices/123');
await response.json();
await response.json(); // TypeError: body already used

The stream is already drained. The first .json() consumed the body, so the second has nothing to read. Mixing consumers fails the same way.

response.json() carries the reason the parse seam exists. The platform types it as Promise<any>, not Promise<unknown>, and any disables every check TypeScript would do for you. So the moment you await response.json(), you hold a value the type system lets you do anything with, including read fields that aren’t there. fetch has thrown your type safety away.

That’s why invoiceSchema.parse(await response.json()) is more than nice-to-have validation: it’s the line that earns the type back, narrowing the any to Invoice only once the schema validates it.

Match each response to the consumer method that reads it. Drag each item into the bucket it belongs to, then press Check.

.json() Parsed JSON
.text() Raw text
.blob() Opaque binary
.arrayBuffer() Raw bytes
.formData() Form fields
A REST API returning { id, status }
An uploaded image to re-display
A server-rendered HTML fragment
Bytes you need to HMAC-sign
A multipart form the server echoed back
A PDF invoice to download

Setting the request body: four shapes and the FormData header rule

Section titled “Setting the request body: four shapes and the FormData header rule”

Reading the body off the Response has a mirror-image job at the build seam: putting a body on the request. The body field of init takes four shapes, again chosen by what you’re sending:

  • string is almost always JSON from JSON.stringify. Pair it with an explicit Content-Type: application/json so the server knows how to read it.
  • FormData is multipart data, the shape that can carry files.
  • URLSearchParams is URL-encoded form fields, the classic application/x-www-form-urlencoded post.
  • Blob / ArrayBuffer / ReadableStream are binary uploads.

The string case is what createInvoice uses, and it sets its own Content-Type. The FormData case hides a common bug.

When you send a FormData body, the browser generates a multipart Content-Type header for you, carrying a random boundary marker the server uses to find where each field starts and ends. Set Content-Type: multipart/form-data yourself and you overwrite that header, dropping the boundary. The server then sees a multipart body it can’t split apart, and parsing fails.

const body = new FormData();
body.append('file', file);
await fetch('/api/uploads', {
method: 'POST',
headers: { 'Content-Type': 'multipart/form-data' },
body,
});

The boundary is gone. Setting Content-Type by hand overwrites the header the browser would have generated, the one carrying the boundary marker. The server can’t find the field edges, so the parse fails.

One more build-seam reflex, carried over from the URL work earlier: when a request needs a query string, build it with URL and URLSearchParams, never string concatenation like `?q=${userInput}`. Hand-splicing user input into a URL ships encoding bugs and worse.

Headers came up twice: the case-insensitive Headers object, and the Content-Type that a string body needs and a FormData body refuses. Four headers recur across the calls you’ll write.

  • Accept is the content type the caller wants back, as in “send me JSON.”
  • Content-Type is the type of the request body you’re sending. Set it explicitly for JSON, omit it for FormData (the trap you just saw).
  • Authorization carries a bearer token for service-to-service calls. Browser-to-your-own-server calls use cookies instead, covered later.
  • Idempotency-Key makes a mutating call safe to retry, so a double-submit doesn’t create two invoices. You’ll wire it up at billing; for now, just recognize it.
const headers = new Headers({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
});

Deadlines and cancellation with AbortSignal

Section titled “Deadlines and cancellation with AbortSignal”

The naive createInvoice had no deadline and no way to cancel. Both are fixed at the build seam with the same primitive: an AbortSignal passed as init.signal. When the signal fires, the in-flight request is torn down and the fetch promise rejects. There are two ways to make one fire, and the invoice call wants both.

The first is a deadline. AbortSignal.timeout(ms) fires by itself after the given milliseconds. Every outbound call gets one, since a request with no deadline can hang forever, never acceptable on a path a user is waiting on. Calibrate it: around 5 seconds for an internal call, up to 30 for a third party you know is slow.

A timeout fires a TimeoutError, not an AbortError. That distinction is the entire reason the catch seam narrows on error.name: it’s how you tell “the deadline blew” apart from “the user cancelled.”

The second way is a user cancel: when the user navigates away or hits a cancel button, you abort the request yourself with an AbortController . Aborting this way fires an AbortError.

The invoice call wants both, which is two signals, but fetch takes only one. The standard answer is AbortSignal.any([...]), which composes several signals into one that fires the instant any of them does. The walkthrough below builds exactly that.

const controller = new AbortController();
cancelButton.onclick = () => controller.abort();
const response = await fetch('/api/invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(5_000)]),
});

The AbortController owns the user-cancel path: wiring its .abort() to a cancel button tears down the request on click. It fires an AbortError.

const controller = new AbortController();
cancelButton.onclick = () => controller.abort();
const response = await fetch('/api/invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(5_000)]),
});

AbortSignal.timeout(5_000) is the deadline: a self-firing signal that aborts the call after five seconds. It fires a TimeoutError, a different name from the manual abort, on purpose.

const controller = new AbortController();
cancelButton.onclick = () => controller.abort();
const response = await fetch('/api/invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(5_000)]),
});

AbortSignal.any([...]) merges the two into one signal that fires the moment either does, so whichever comes first wins. That composite is what you pass to fetch.

const controller = new AbortController();
cancelButton.onclick = () => controller.abort();
const response = await fetch('/api/invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(5_000)]),
});

When the signal fires, fetch rejects into your catch, and error.name tells you which one: 'AbortError' means the user cancelled, 'TimeoutError' means the deadline blew. Same catch, two stories.

1 / 1

Put a deadline on every call, add AbortSignal.any whenever a request also needs to be cancellable, and let the catch seam sort out which signal fired, which is where we go next.

The catch handles transport failures and only those; the 422 was already dealt with in the ok-branch. Four things can throw at the fetch await or while consuming the body:

  • TypeError means the network failed: no DNS, connection refused, offline, or a CORS rejection. No response at all.
  • AbortError means you called controller.abort(), so the user cancelled.
  • TimeoutError means an AbortSignal.timeout deadline fired.
  • SyntaxError means response.json() hit a body that isn’t valid JSON. The surprise case: the fetch succeeded and response.ok was true, so the failure lands later, as you read the body.

Narrow before you act. Check error instanceof Error, then switch on error.name. Never catch (e: any), never swallow into a blank catch {}. Each branch maps to a distinct Result failure with its own userMessage, so the UI can say something true.

} catch (error) {
if (error instanceof Error) {
switch (error.name) {
case 'TimeoutError':
return err('internal', 'The request timed out.');
case 'AbortError':
return err('internal', 'Request cancelled.');
case 'SyntaxError':
return err('internal', 'The server sent an invalid response.');
}
}
return err('internal', 'Could not reach the server.');
}

You know the call shape; the next question is where it gets written. Two questions in order sort every fetch into one of four homes: is the caller on the server or in the browser, and is the target your own backend or a third party.

Where should this fetch live?

The value is the order of the two questions: caller first, target second, and the right home falls out every time.

Two things layer on top of plain fetch. Recognize them, then move on.

Next.js augments the global fetch. Inside a Server Component, the same fetch(input, init) gains caching, deduplication, and revalidation through extra init options (cache, and next: { revalidate, tags }). Same signature, more behavior. When fetch acts differently on the server, that’s the framework’s layer, not a different API.

XMLHttpRequest survives for one job. It’s the legacy primitive fetch replaced everywhere except upload-progress events, which fetch doesn’t expose. For a byte-level upload progress bar you reach for xhr.upload.onprogress. Recognize the name; don’t learn the API now.

The HTTP-client libraries ky, ofetch, and axios exist, but the default is plain fetch plus a thin in-house helper. Don’t import one until your fetch boilerplate has visibly cost you something. Which brings us to that helper.

By the second and third call to the same backend, you repeat almost everything: same base URL, same default headers, same ok-branch, same parse, same Result mapping. That repetition has earned an abstraction, a typed helper in lib/http.ts:

lib/http.ts
export async function apiFetch<T>(
path: string,
init: RequestInit,
schema: ZodType<T>,
): Promise<Result<T>> {
// the same five seams, factored out: build, send, ok-branch, parse, catch
}
const result = await apiFetch('/invoices', { method: 'GET' }, invoiceSchema);

The helper is no new concept: it’s the same five seams, with the base URL, default headers, ok-branch, schema parse, and Result mapping moved into one place so you write the call shape once instead of every time.

The next lesson reads a Response body as a live stream of chunks, and adds the live channels that push updates from the server without polling.