The universal HTTP client
The fetch API is the one network primitive every runtime in this course shares, and this lesson teaches the hardened call shape you write everywhere from the browser to a Server Action.
A user clicks “New invoice”. Your code needs to POST a JSON body to /api/invoices, take the row the server sends back, and render it. That sounds like three lines, and it is, until you put it under load, where it can break in four ways. The server is slow today, so the request hangs and the button spins forever. The user gets bored and navigates away mid-flight, but your code keeps running, holding memory and about to write into a screen that’s gone. The server validates the body, doesn’t like it, and answers 422 Unprocessable Entity, so your three lines render that error payload as if it were an invoice. Or the API ships a breaking change and the JSON comes back the wrong shape, so 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, and no wrapper of any kind?
The answer is built on fetch, and the first thing to understand is that fetch is not a browser API you’ll outgrow. It is the network primitive for every runtime this course touches: Chrome and Safari, Node, the Edge runtime, the renderer behind a Server Component, and the body of a Server Action. It is one async function that you await, and it hands you back a typed Response. You will never reach for axios or node-fetch in a 2026 codebase, because the same fetch(input, init) covers all of it. So the shape you learn here is the shape you write everywhere for the rest of the course.
By the end of this lesson you’ll write that hardened call from memory, and you’ll also know the reflexes fetch deliberately leaves to you. Those reflexes, not the syntax, are where most fetch bugs live. We start with the one that matters most.
fetch resolves for every response, even 404 and 500
Section titled “fetch resolves for every response, even 404 and 500”One fact comes before any call-shape detail, because every other decision in this lesson depends on it.
The most common fetch bug comes from a single misconception: you wrap a fetch in try/catch, assume the catch block means “the request failed”, and never check the response at all. That mental model is wrong, and it’s wrong in a way that quietly corrupts data instead of failing loudly.
fetch does not throw when the server returns an error status. The promise resolves for any HTTP response the server actually sends: 200, 404, 422, 500, all of them. From fetch’s point of view a 500 is a complete success, because the request left, the server answered, and the round-trip closed cleanly. The fact 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. In each of these the transport itself failed, and that is what lands in catch.
So you have two completely different failure surfaces, and an experienced engineer keeps them strictly apart:
This is why response.ok, which is true for any status in the 200–299 range and false otherwise, is the load-bearing branch of every call you’ll write. It’s how you separate “the server answered, and the answer was an error” from “there was no answer.” The diagram below lays that split out step by step.
The request fires. await fetch(url) leaves the client and we wait. Nothing has come back yet. The only two ways this can end are 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. This is the happy case, but watch what the next step shares with it.
The server answers 422 or 500. The promise still resolves, and this is the surprise. It travels the same arrow into the same Response box as the 200 did. The difference is that 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 ever gets here.
Notice what steps 2 and 3 share: the resolve arrow. A 200 and a 500 arrive through the exact same door, and the only thing that tells them apart is the response.ok check you write yourself. Skip it, and a 500 flows straight into your render path. The exercise below proves the claim before you trust it.
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 exists to correct. A 404 is data: you read it, you don’t catch it.
The naive call, and the four ways it breaks
Section titled “The naive call, and the four ways it breaks”Now back to the invoice button. Here is the minimal, optimistic code a junior writes with every guard skipped, sitting next to the shape we’re about to build. Flip between the two tabs. We’ll read the naive one for its bugs first, then spend the rest of the lesson assembling the hardened one piece by piece.
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 straight into res.json() and gets returned as if it were an invoice. It sets no deadline, so a slow server hangs this call forever. It can’t be cancelled, so when the user navigates away it keeps running. 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 the transport failures. We’ll build this seam by seam. For now, the point to take away is that the destination is one function, roughly a dozen lines, with no library.
Every fix in the hardened tab is a reflex fetch doesn’t hand you. The rest of the lesson adds them one at a time, in the order they appear in the call.
The canonical call shape: build, send, ok-branch, parse, catch
Section titled “The canonical call shape: build, send, ok-branch, parse, catch”This section gives you a vocabulary you’ll use for the rest of the course. 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, because they are the abstraction. When you read someone else’s fetch later, or write your own when you’re tired, you’ll scan for all five and notice 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. The first seam assembles 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 that carries the deadline. Everything about the outbound request lives in this one object.
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. The send seam fires the request and awaits it. await fetch(...) resolves to the Response object, not the body. This is the line that resolves for a 200 and a 500 alike, exactly as the resolution model showed. The body is still on the wire, and 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, always 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’ll return a single err(...) for now. The rule is that 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 it gives you back any, so you validate it with invoiceSchema.parse(...) before trusting a single field. Never trust the wire. We’ll come back to why this parse is doing more than it looks.
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. The last seam handles the transport failures from step 2, and only those. You 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, because it was handled in the ok-branch. Keeping those two surfaces separate is the whole discipline.
Here’s the discipline in one sentence, worth memorizing:
A throwaway script might fold build and send into one line and skip the parse, while a production call writes all five explicitly. The shape is always there either way, and reading a call means accounting for each seam. The exercise below lets you drill the order, since that order is the mental model.
Put the five seams of a fetch call in the order they execute. Drag the items into the correct order, then press Check.
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.'); }}init object fetch response.ok and handle the error status That sequence, build, send, ok, parse, catch, is the spine of every section that follows. Each one zooms in on a single seam and shows you how to get it right.
The Request, Response, and Headers triad
Section titled “The Request, Response, and Headers triad”Before we zoom into the seams, meet the three types every fetch call touches. They aren’t fetch-specific. They’re Web Platform types, and that matters: when you write the receiving side later in the course (a route handler), the handler is handed a Request and returns a Response, the very same types. fetch and the route handler are two ends of one wire, speaking the same vocabulary. Learn the types once and you recognize them on both sides.
Requestis the outbound request: URL, method, headers, body. You usually construct it inline by passing theinitobject asfetch’s second argument, which is the common case, but you can also build anew Request(url, init)and pass that. Same thing, two spellings.Responseis the resolved result ofawait fetch:status,ok,headers, and a body you read with one of the consumer methods. This is the object the send seam hands you.Headersis a case-insensitive multimap of header name to value, with.get,.set,.append, and.entries. It has one non-obvious behavior worth a demonstration.
That behavior is case-insensitive lookup, guaranteed by contract. headers.get('content-type') and headers.get('Content-Type') return the exact same value. HTTP header names were never case-sensitive, and the Headers object honors that, which means you can stop worrying about matching the server’s capitalization. The small block below shows it.
const headers = new Headers();headers.set('Content-Type', 'application/json');
headers.get('content-type'); // 'application/json'headers.get('Content-Type'); // 'application/json' — same valueThat’s the whole triad. You don’t need a reference of every method. You need the three names, the case-insensitivity contract, and the fact that the route handler on the other end speaks the same two types.
Reading the body: pick one consumer, consume it once
Section titled “Reading the body: pick one consumer, consume it once”Step back to the send seam for a second. 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 get it, you call one of five consumer methods, and which one you pick is decided entirely by what the server sent:
response.json()parses a JSON body. This is the default for the JSON APIs a SaaS app talks to all day.response.text()gives you raw text: an HTML fragment, a plain-text 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. (File handling gets a full treatment later in the course.)response.arrayBuffer()gives you binary as raw bytes, for typed-array reads, crypto, and low-level work. (Byte-level work comes later too.)
Each returns a promise, which is the second await: one to get the Response, a second to read its body. There’s one rule about reading it that you have to respect:
This trips up juniors who want both the raw text and the parsed object and reach for .text() then .json(). The second call throws. The reflex is simple: 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 and throws. The same happens if you mix consumers: .text() then .json() fails identically.
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 that variable freely. If you genuinely need the raw text and the parsed object, take .text() once and JSON.parse the string yourself.
There’s a deeper point hiding in response.json(), and it’s the reason the parse seam exists. The platform types response.json() as returning Promise<any>, not Promise<unknown>. The difference matters: any is the type that disables every check TypeScript would otherwise do for you. So the moment you await response.json(), you’ve handed yourself a value the type system will let 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. The parsed value stays any until the schema validates it and narrows it to Invoice. Stated cleanly, the reflex is:
Now drill the consumer choice: given what the server sent, which method reads 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: the four shapes, and the FormData trap
Section titled “Setting the request body: the four shapes, and the FormData trap”That was reading the body off the Response. The build seam has the mirror-image job: putting a body on the request. The body field of init accepts four shapes, and again the shape is decided by what you’re sending:
stringis almost always JSON you built withJSON.stringify, the common case. Pair it with an explicitContent-Type: application/jsonheader so 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 file-upload chapters later in the course build on these.)
The string case is what createInvoice uses, and it sets its own Content-Type. The FormData case, though, hides one of the most common bugs in this whole area, and it deserves its own warning.
When you send a FormData body, the browser generates a multipart Content-Type header for you, and that header carries a randomly generated boundary marker the server uses to find where each field starts and ends. If you set Content-Type: multipart/form-data yourself, you overwrite the browser’s header and the boundary goes missing. The server then sees a multipart body it has no way to split apart, and parsing fails. The fix is almost too simple to believe:
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, and the parse fails on a body that looks fine to you.
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 entirely for a FormData body and the browser writes multipart/form-data; boundary=... with the correct marker. This is the one case where being explicit is wrong.
One more build-seam reflex while we’re here, carried over from the URL work earlier in the course: when a request needs a query string, build it with URL and URLSearchParams, never with string concatenation like `?q=${userInput}`. Hand-splicing user input into a URL is how you ship encoding bugs and worse.
The header surface every call touches
Section titled “The header surface every call touches”Headers came up twice already: the case-insensitive Headers object, and the Content-Type that the string body needs and the FormData body refuses. Let’s name the four headers that actually recur across the calls you’ll write, then state the posture that governs all of them.
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, and omit it forFormData(the trap you just saw).Authorizationis where a bearer token goes for service-to-service calls. The browser-to-your-own-server case uses cookies instead of this header, and cookies have their own rules you’ll meet later in the course.Idempotency-Keyis a header that makes a mutating call safe to retry, so a double-submit doesn’t create two invoices. You’ll wire it up properly when the course reaches billing; for now, just recognize it.
const headers = new Headers({ Accept: 'application/json', 'Content-Type': 'application/json', Authorization: `Bearer ${token}`,});The posture is the part worth keeping in mind:
There’s a full catalog of HTTP headers, but you don’t reach for it call by call. These four cover the daily work, and you look up the rest when a specific contract demands them.
Every outbound call carries a deadline
Section titled “Every outbound call carries a deadline”That brings us to the call’s biggest gaps. The naive createInvoice had no deadline and no way to cancel, which were failures two and three. Both are fixed at the build seam, with the same primitive: an AbortSignal passed as init.signal.
An AbortSignal is a cancellation token. You hand it to fetch through init.signal, and 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) gives you a signal that fires by itself after the given milliseconds, with no manual wiring. Every outbound call gets one. A request with no deadline can hang forever, and “forever” on a user-facing path is never acceptable. Calibrate it: around 5 seconds for an internal call, up to 30 for a third party you know is slow, and never “no deadline” on anything a user is waiting on.
The detail that matters is that 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 want to abort the request yourself. That’s an AbortController : you create it, pass its .signal, and call .abort() when you want the request gone. Aborting this way fires an AbortError.
That leaves one question. The invoice call wants a deadline and a user cancel, which is two signals, but fetch takes only one signal. The 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)]),});An AbortController owns the user-cancel path. Wiring its .abort() to a cancel button means clicking the button tears down the request. On its own 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. On its own it fires a TimeoutError, a different error 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 a single signal that fires the moment either one does, so whichever comes first wins. That composite is what you pass to fetch. This is the standard way to give one request both a deadline and a cancel button.
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, the fetch rejects into your catch, and error.name tells you which one fired: 'AbortError' means the user cancelled, 'TimeoutError' means the deadline blew. Same catch, two different stories, told apart by the name.
Both AbortSignal.timeout and AbortSignal.any are available everywhere this course runs, in every current browser and the pinned Node version, so you can reach for them as the default with no fallback to worry about. The takeaway is a deadline on every call, and AbortSignal.any whenever a request also needs to be cancellable. The catch seam then sorts out which signal fired, which is exactly where we go next.
Catching the right errors
Section titled “Catching the right errors”The catch seam handles the transport failures, and only those, because the 422 was already dealt with in the ok-branch. Here are the things that can actually throw at the fetch await and during body consumption:
TypeErrormeans the network failed: no DNS, connection refused, offline, or a CORS rejection. This is the classic “no response at all.”AbortErrormeans you calledcontroller.abort(), so the user cancelled.TimeoutErrormeans anAbortSignal.timeoutdeadline fired.SyntaxErroris thrown byresponse.json()when the body isn’t valid JSON. This one is the surprise: the fetch succeeded,response.okwas true, and the failure happens later, while you’re reading the body.
The reflex is the same one the course established for every catch: narrow before you act. Check error instanceof Error and switch on error.name. Never catch (e: any), and never swallow the error 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.');}And the keystone one final time, because the catch seam is exactly where the temptation to merge surfaces is strongest:
That closes the five-seam loop. With build, send, ok-branch, parse, and catch in place, you can now write createInvoice from memory, and it survives all four failures the lesson opened with. The rest of the lesson is orientation: where this shape gets written, what the platform layers on top of it, and when the boilerplate earns a helper.
Where fetch calls live in a 2026 codebase
Section titled “Where fetch calls live in a 2026 codebase”You know the call shape. The next question is where it gets written, and an experienced engineer doesn’t guess. They ask two questions in order: is the caller running on the server or in the browser, and is the target your own backend or a third party. Those two questions sort every fetch into one of four spots, each with a default move. The decision tree below walks through them.
A Server Component fetching your own /api route makes the server talk to itself over HTTP for no reason. Almost always, re-architect to a direct function call. (This is the “logic lives in /lib, not behind a self-call” principle you’ll meet later.)
The everyday server case: calling Stripe, Resend, an analytics service. You’ll often use the vendor’s SDK, which is fetch underneath. This is also where retries and backoff get added, when they’re warranted.
A Client Component calling your own route handler: the home of live feeds, search-as-you-type, and anything the form/action surface can’t carry. This is where the next lesson lives.
Calling a third party straight from the browser is gated by CORS and leaks your keys. The senior call is to proxy the request through one of your own route handlers instead.
The value isn’t the four cells, it’s the order of the two questions. Caller first, target second. Run them in that order 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. You won’t learn either one here; the goal is just to recognize them as “you’ll meet this later,” then move on.
Next.js augments the global fetch. Inside a Server Component, the very same fetch(input, init) you just learned gains caching, request deduplication, and revalidation through extra init options (cache, and next: { revalidate, tags }). Same signature, different behavior: the function is doing more than the platform version. That augmentation is owned by a later chapter, and it’s named here only so that when you see fetch behaving differently on the server, you know it’s the framework’s layer, not a different API. The principle generalizes: recognize the platform default first, then the framework’s augmentation on top of it.
XMLHttpRequest still exists for one job. It’s the legacy primitive fetch replaced everywhere except upload-progress events, the one capability fetch doesn’t natively expose. When you need a progress bar that tracks bytes uploaded, you reach for xhr.upload.onprogress, reading e.loaded and e.total. That’s the entire reason XMLHttpRequest survives in a 2026 codebase, and a later chapter owns the worked example. Recognize the name, but don’t learn the API now.
What about the HTTP-client libraries, ky, ofetch, and axios, that you might have reached for in another era? They exist, but the 2026 default is plain fetch plus a thin in-house helper. The posture is to not import an HTTP client until your fetch boilerplate has visibly cost you something. Which brings us to that helper.
When fetch boilerplate earns a helper: apiFetch
Section titled “When fetch boilerplate earns a helper: apiFetch”You’ve now written the five seams enough times to see what’s coming: the second and third call to the same backend repeat almost everything. Same base URL, same default headers, same ok-branch, same parse, same Result mapping. By the third such call, that repetition has earned an abstraction, so extract a typed helper into 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);Look at what’s inside that body: it’s exactly the five seams you just learned, with the base URL, the default headers, the ok-branch, the schema parse, and the Result mapping all moved into one place. The helper isn’t a new concept. It’s the canonical call shape, factored out so you write it once instead of every time.
The discipline is in the timing:
That’s the whole arc. You started with a button that broke four ways, and you end holding a single call shape, five seams and no library, that handles every one of them, plus the helper that shape becomes when it repeats. The next lesson picks up where the buffering consumers stop: reading a Response body as a live stream of chunks, and 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.