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.”
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.
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.
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.
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');404 is a response the server sent, so the promise resolves and execution continues straight through — response.ok is false, response.status is 404, and 'reached' logs. Nothing throws, so there is no catch to enter. Only a transport failure (no DNS, connection refused, abort, timeout) would have rejected the promise.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.
The naive call and its four fixes
Section titled “The naive call and its four fixes”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.
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.'); }}Each fix maps to one failure. The signal gives it a deadline. The if (!response.ok) branch catches the 422. The parse validates the wire. The catch handles transport failures. One function, roughly a dozen lines, no library.
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:
- build: assemble the request.
- send:
await fetch, get aResponse. - ok-branch: check
response.ok, handle the error status. - parse: read and validate the success body.
- 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.
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.
The Request, Response, and Headers types
Section titled “The Request, Response, and Headers types”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.
Requestis the outbound request: URL, method, headers, body. You usually construct it inline by passing theinitobject asfetch’s second argument, butnew Request(url, init)builds the same thing.Responseis the resolved result ofawait fetch:status,ok,headers, and a body you read with one of the consumer methods.Headersis 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 valueReading 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 aFormDataobject, the inverse of submitting a<form>.response.blob()gives you binary as an opaqueBlob: 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 usedThe stream is already drained. The first .json() consumed the body, so the second has nothing to read. Mixing consumers fails the same way.
const response = await fetch('/api/invoices/123');const invoice = await response.json();// reuse `invoice` as many times as you needRead once, reuse the value. Consume the body into a variable, then read it freely. If you need the raw text and the parsed object, take .text() once and JSON.parse the string yourself.
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.
{ id, status }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:
stringis almost always JSON fromJSON.stringify. Pair it with an explicitContent-Type: application/jsonso the server knows how to read it.FormDatais multipart data, the shape that can carry files.URLSearchParamsis URL-encoded form fields, the classicapplication/x-www-form-urlencodedpost.Blob/ArrayBuffer/ReadableStreamare 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.
const body = new FormData();body.append('file', file);
await fetch('/api/uploads', { method: 'POST', // no Content-Type — the browser sets it with the boundary body,});Let the browser pick it. Omit Content-Type for a FormData body and the browser writes multipart/form-data; boundary=... with the correct marker.
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.
The four headers every call touches
Section titled “The four headers every call touches”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.
Acceptis the content type the caller wants back, as in “send me JSON.”Content-Typeis the type of the request body you’re sending. Set it explicitly for JSON, omit it forFormData(the trap you just saw).Authorizationcarries a bearer token for service-to-service calls. Browser-to-your-own-server calls use cookies instead, covered later.Idempotency-Keymakes 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.
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.
Narrowing transport errors in the catch
Section titled “Narrowing transport errors in the catch”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:
TypeErrormeans the network failed: no DNS, connection refused, offline, or a CORS rejection. No response at all.AbortErrormeans you calledcontroller.abort(), so the user cancelled.TimeoutErrormeans anAbortSignal.timeoutdeadline fired.SyntaxErrormeansresponse.json()hit a body that isn’t valid JSON. The surprise case: the fetch succeeded andresponse.okwas 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.');}Where fetch calls live
Section titled “Where fetch calls live”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.
A Server Component fetching your own /api route makes the server talk to itself over HTTP for no reason. Re-architect to a direct function call: the logic lives in /lib, not behind a self-call.
The everyday server case: calling Stripe, Resend, an analytics service. You’ll often use the vendor’s SDK, which is fetch underneath, and this is where retries and backoff get added when warranted.
A Client Component calling your own route handler: the home of live feeds, search-as-you-type, and anything the form and action surface can’t carry.
Calling a third party straight from the browser is gated by CORS and leaks your keys. Proxy the request through one of your own route handlers instead.
The value is the order of the two questions: caller first, target second, and the right home falls out every time.
The same fetch, augmented
Section titled “The same fetch, augmented”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.
When to extract the apiFetch helper
Section titled “When to extract the apiFetch 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:
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.
External resources
Section titled “External resources”The platform reference for fetch, Request, and Response — including the resolution behavior this lesson centers on.
The deadline primitive, plus AbortSignal.any() for composing cancellation signals.
The deep-dive on the lesson's keystone: the fetch promise is a request promise, which is why a 500 resolves and only transport failures reject.
A practical walkthrough of the same surfaces: response.ok, JSON parse errors, AbortController, and mapping each to user feedback.