Step 05

fetch, and what changed

Step 04 ended with a 45-line function that wraps XMLHttpRequest in a promise. Most of those lines translate events into promise outcomes. fetch does that translation itself, and the same function comes out at 18 lines. It also puts the error/data line in a place that surprised people. It has taken criticism for that since 2015. Step 04 set that argument up and left it alone.

Learning Objectives

  • Write step 04's request with fetch and name the lines that disappeared
  • Say what response.ok tests and what it does not
  • Name the conditions that reject a fetch promise, and the ones that do not
  • Put a 404 on the data side of the line and defend the choice
  • Normalize XHR's three failure events and fetch's three exception names into one error type with one kind
  • Set a deadline with AbortSignal.timeout() and combine it with a caller's cancel using AbortSignal.any()
  • Say which Chrome versions report a timeout as an abort, and what that does to code branching on the name
  • Read a Response body once, say what the second read does, and use clone() when two things need it
  • Name what fetch still does not do

The same job, fewer lines

fetch shipped in Chrome 40 in January 2015 and Firefox 39 that July. Safari was last, in 10.1, on 27 March 2017. It has been available everywhere for nine years.

Here is step 04's function, abridged to 21 lines. A promise constructor wrapping the object, five listeners, an AbortController holding all five so one call removes them, and a bridge from AbortSignal to xhr.abort() written by hand because the object predates signals.

function request(method, url, options = {}) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); const listeners = new AbortController(); const opts = { signal: listeners.signal }; xhr.open(method, url, true); xhr.responseType = 'json'; xhr.timeout = options.timeout ?? 8000; xhr.addEventListener('load', () => resolve({ /* … */ }), opts); xhr.addEventListener('error', () => reject(new Error('network')), opts); xhr.addEventListener('timeout', () => reject(new Error('timeout')), opts); xhr.addEventListener('abort', () => reject(new Error('abort')), opts); xhr.addEventListener('loadend', () => listeners.abort(), opts); options.signal?.addEventListener('abort', () => xhr.abort(), { once: true }); xhr.send(options.body ?? null); }); }

The same request, the same result, on fetch.

async function request(method, url, options = {}) { const { body = null, headers = {}, timeout = 8000, signal = null } = options; const deadline = AbortSignal.timeout(timeout); const response = await fetch(url, { method, body, headers, signal: signal ? AbortSignal.any([deadline, signal]) : deadline, }); return { ok: response.ok, status: response.status, data: await response.json(), }; }

The promise constructor, the five listeners, the controller, and the bridge are gone. fetch takes an AbortSignal as an option, so there is nothing to bridge.

You still write the status check, the parse, and the failure decision. Two lines in that function are wrong. await response.json() throws on a body that will not parse, and nothing in it can tell a timeout from a cancel. The next three sections fix both.

fetch does not reject on 404 or 500

A fetch promise resolves whenever the exchange completed. A 403, a 404, and a 500 all completed. The promise rejects when there is no response to hand you.

Five things reject it. MDN's Exceptions list names AbortError, TypeError, and a NotAllowedError for two APIs you are not using. It does not name TimeoutError, because the rejection value for any aborted fetch is whatever the signal's reason is, and AbortSignal.timeout() sets that reason to a TimeoutError DOMException.

  • AbortError, a DOMException. Your code called abort() on a controller whose signal you passed.
  • TimeoutError, a DOMException. A deadline from AbortSignal.timeout() elapsed. Both of these arrive through the signal option, and they are not the same exception.
  • TypeError, network error. DNS did not resolve, the connection failed, the machine is offline, or the browser blocked a cross-origin read. Chrome's message is Failed to fetch for all of them.
  • TypeError, bad URL. The URL will not parse, or it carries a username and password.
  • TypeError, bad options. A property on the init object has a value fetch will not accept.

No status code appears in that list. response.ok is true when status is 200 through 299 and false for everything else. You have to read it.

// Wrong. There is no status check anywhere in it. fetch('/api/nope') .then((response) => response.json()) .then(render) .catch(showError);

Against this site that URL answers 404 with {"error":"No such endpoint","path":"/api/nope"}. The promise resolves. response.json() parses the body successfully, because it is valid JSON. render runs with an object that has no posts in it and draws an empty list. showError never runs. Nothing appears in the console. The user gets a page that looks like it worked and has nothing on it.

async function loadPosts() { const response = await fetch('/api/nope'); if (!response.ok) { // The server answered. Read the answer. const problem = await response.json().catch(() => null); report(response.status, problem); return; } render(await response.json()); }

This behavior is deliberate. A 500 is a response. It has a status line, headers, and usually a body saying what went wrong. Rejecting the promise would leave you holding an exception and would throw the explanation away with the response that carried it.

Where the error/data line sits

fetch's choice is a judgment about who decides what a response means. A transport layer knows whether bytes arrived. Only the caller knows whether a 404 is a bug, an expected empty result, or a sign the user typed the wrong id.

So the contract is one function, two implementations, and a single shape coming back.

transport(url, { timeoutMs, signal }) -> Promise<Result> Result = { ok: boolean // HTTP status in the 2xx range status: number statusText: string data: unknown // parsed JSON, or null if the body would not parse }

A 404 or a 500 resolves with ok: false. The server answered, that answer is data, and the caller decides what it means. Only a failure to complete the exchange rejects: network down, timed out, canceled.

Both transports produce those three failures and report them differently.

Did not completeXMLHttpRequestfetchkind
Network down, DNS, cross-origin blockerror eventTypeError'network'
Deadline elapsedtimeout eventDOMException, name TimeoutError'timeout'
Your code canceled itabort eventDOMException, name AbortError'abort'

XHR gives you three event names and one status of 0 for all three. Step 04 told you to listen for the events rather than read the number. fetch gives you one rejection and three values of error.name. Your calling code wants neither. Give it a third.

export class TransportError extends Error { /** @param {'network'|'timeout'|'abort'} kind */ constructor(kind, message, options) { super(message, options); this.name = 'TransportError'; this.kind = kind; } }

On the fetch side, the whole translation is one catch.

export async function fetchTransport(url, { timeoutMs = 8000, signal } = {}) { const deadline = AbortSignal.timeout(timeoutMs); const combined = signal ? AbortSignal.any([deadline, signal]) : deadline; let response; try { response = await fetch(url, { signal: combined, headers: { Accept: 'application/json' }, }); } catch (cause) { // fetch throws one exception type for several situations. `name` tells // them apart. if (cause.name === 'TimeoutError') { throw new TransportError('timeout', 'No response in time.', { cause }); } if (cause.name === 'AbortError') { throw new TransportError('abort', 'Request canceled.', { cause }); } throw new TransportError('network', 'Network error.', { cause }); } // Match the XHR contract: an unparseable body becomes null, not a throw. // Error responses often carry a non-JSON body, so this is the common path. let data = null; try { data = await response.json(); } catch { data = null; } return { ok: response.ok, status: response.status, statusText: response.statusText, data, }; }

{ cause } is the second argument to the Error constructor. It keeps the original DOMException attached, so a log can print the real message while your code branches on kind.

The XHR half of the same contract is step 04's function, with its four listeners resolving and rejecting into that shape. The demo below runs both. Switch the selector and nothing above the transport changes.

Reading name is the fragile way to do this

controller.abort() with no argument aborts with an AbortError DOMException. controller.abort('user pressed cancel') aborts with that string, and fetch rejects with the string. A string has no name. Both if tests fail and the block above files a user's cancel as a network failure.

The signals themselves do not have that problem. Ask them instead.

let response; try { response = await fetch(url, { signal: combined }); } catch (cause) { // Ask the signals, not the exception. This survives abort(reason), // and it does not depend on the engine naming the DOMException right. if (deadline.aborted) { throw new TransportError('timeout', 'No response in time.', { cause }); } if (signal && signal.aborted) { throw new TransportError('abort', 'Request canceled.', { cause }); } throw new TransportError('network', 'Network error.', { cause }); }

Order matters. If the deadline and the cancel land in the same task, deadline.aborted can be true while the cancel is what actually killed the request, and the result is labeled timeout. Rare, and the caller stops waiting either way.

The next section gives the other reason to write it this way.

Timeout is a signal, not a property

XMLHttpRequest has xhr.timeout, a number of milliseconds sitting on the object. fetch has no timeout option and never has. For seven years the answer was a setTimeout that called controller.abort(), plus a clearTimeout in a finally block that people forgot to write.

async function getJson(url) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 8000); try { const response = await fetch(url, { signal: controller.signal }); return await response.json(); } finally { // Without this the timer runs to completion on every fast request. clearTimeout(timer); } }

AbortSignal.timeout(ms) returns a signal that aborts itself when the time runs out. There is no controller to hold and no timer to clear.

const response = await fetch(url, { signal: AbortSignal.timeout(8000) });

The clock starts when you call the method, not when the request goes out. Build the signal with no fetch attached to it and it still aborts on schedule, so build it next to the call that uses it. MDN adds that the timer counts active time rather than elapsed time: it pauses while the document sits in the back/forward cache or a worker is suspended.

Two reasons to give up, one signal

You have two reasons to stop waiting: your deadline, and the user pressing Cancel. Neither knows about the other, and a request can only take one signal.

AbortSignal.any([a, b]) returns a signal that aborts when the first of them does, carrying that one's reason. If one is already aborted when you call it, the result comes back already aborted with that reason.

// The caller owns this and can abort it whenever it likes. const cancel = new AbortController(); cancelButton.addEventListener('click', () => cancel.abort()); const deadline = AbortSignal.timeout(8000); const signal = AbortSignal.any([deadline, cancel.signal]); const response = await fetch('/api/slow?ms=20000', { signal });

Whichever fires first, fetch rejects. Both signals are still readable in the catch block, and asking them is how the corrected block in the last section tells the two apart.

Support, and one version range that gets it wrong

AbortSignal.timeout() landed in Firefox 100 in May 2022, Chrome 103 that June, and Safari 16 in September 2022. AbortSignal.any() landed in Chrome 116 in August 2023, Safari 17.4 in March 2024, and Firefox 124 later that month. The two are a year apart in Chrome, so a page that combines a deadline with a cancel needs Chrome 116, not 103.

Chrome 103 through 123 shipped AbortSignal.timeout() with the wrong abort reason. A timed-out fetch rejected with an AbortError, never a TimeoutError. Read name on those versions and every timeout comes back kind: 'abort'. Chromium issue 40263649. Chrome 124 fixed it on 16 April 2024. MDN dates Baseline for the method to that day.

This is the second reason the corrected catch asks deadline.aborted. The signal reports what happened to it whatever the engine decided to call the exception, and it survives abort(reason). Write that version and neither problem reaches you.

Step 04 said this about xhr.timeout and it holds here too. A client timeout tells the server nothing. The handler keeps running, the database write still lands, and your page has stopped listening.

Request and Response are objects

XMLHttpRequest is one object that holds the request and the response at once, and you read the parts off it by name. The Fetch API splits them: Request, Response, and Headers, each constructible, each passable to a function that has never heard of fetch.

const listPosts = new Request('/api/posts?limit=5', { method: 'GET', headers: { Accept: 'application/json' }, }); // Somewhere else entirely, possibly much later. const response = await fetch(listPosts);

Headers is a real collection. Step 04 parsed the CRLF string that getAllResponseHeaders() hands back; response.headers.get('content-type') replaces that whole helper. The visibility rules did not change. Cross-origin you still see only the seven safelisted names plus whatever the server puts in Access-Control-Expose-Headers, and Set-Cookie is invisible to script everywhere. Step 08 runs that experiment.

The body reads once

json(), text(), arrayBuffer(), blob(), formData(), and bytes() all consume the same stream. After any one of them response.bodyUsed is true and the stream is spent. bytes() is the recent one: Firefox 128 in July 2024, Safari 18 that September, Chrome 132 in January 2025.

const response = await fetch('/api/posts'); const data = await response.json(); // fine const text = await response.text(); // TypeError: body already read

The second call returns a rejected promise, so with await it throws at that line. The error is a TypeError, not a SyntaxError. Read the name before you conclude the server sent garbage.

response.clone() gives you a second independent body, and you have to call it before the first read. clone() is synchronous. On a response whose body is used it throws TypeError at the call.

async function getJsonAndLogIt(url) { const response = await fetch(url); // Clone first. After the next line the original is spent. logRawBody(await response.clone().text()); return response.json(); }

The waterfall panel at the bottom of every demo in this series does this. It wraps fetch, calls clone() on whatever comes back, reads the clone to fill in the panel, and returns the untouched original to the page. Without the clone, opening the panel would break every demo on the site.

An empty body is not empty JSON

response.json() on a zero-length body rejects with a SyntaxError, the same failure JSON.parse('') gives you. Chrome's message is Unexpected end of JSON input. A 204 No Content has no body by definition, so parsing one always fails, and so does the 202 that /api/beacon answers with. XMLHttpRequest with responseType = 'json' hands back null instead of throwing. The contract in this step returns data: null on both transports to match it. Check response.status before you parse, or wrap the parse the way fetchTransport does.

Demo: one contract, two transports

The step 04 demo again, with the transport behind a selector. Choose fetch or xhr and press Send. The posts list, the outcome panel, and the status line are driven by the same result object either way, and nothing downstream of the transport branches on which one ran.

What changed from step 04: the event log is gone, because fetch has no events to log. In its place is an outcome panel that says whether the promise resolved or rejected, and the scroll ruler from steps 02 through 04 has been dropped. The instrumentation strip carries over with the transport name added to it.

Four things to run.

  • Prove the contract. Send with fetch, then switch to xhr and send again. Every line of the outcome panel except the first reads the same. The waterfall at the bottom records both, and its two rows look alike.
  • Resolve with ok: false. Press GET /api/flaky until you get a 500. The promise resolved. ok is false, status is 500, and data holds {"error":"Upstream exploded","rate":0.5}. The server's explanation arrived intact. Press GET /api/nope for the 404 version.
  • Resolve with a body that will not parse. Press GET /api/bad-json. Status 200, ok: true, Content-Type: application/json, and data is null. The response is fine. The body is truncated. Step 07 is about what to do here.
  • Make the two failure kinds separate. Choose /api/slow?ms=5000 with the timeout at 1000 and send: the promise rejects with kind: 'timeout'. Send again with the timeout at 0 and press Cancel: kind: 'abort'. Run both on each transport, then read the signaledBy line: it names an XHR event on one path and an exception on the other.

The demo's fetchTransport asks deadline.aborted rather than reading name, so it is right on Chrome 116 as well as on Chrome 124. It prints what the exception called itself on a line of its own. On a current browser that line reads TimeoutError. On Chrome 123 it reads AbortError beside a kind of 'timeout'. Below Chrome 116 the demo runs on xhr, because AbortSignal.any() is not there to combine the deadline with the Cancel button.

What fetch still does not do

Upload progress, portably. Step 04 covers this in full: xhr.upload fires progress events for the request body, and the fetch equivalent needs duplex: 'half', which exists only in Chromium. If a progress bar for an upload matters, that request stays on XMLHttpRequest. Download progress is fine on fetch, through response.body.

Synchronous mode. There is none and there will not be. fetch returns a promise. xhr.open(method, url, false) still runs and is deprecated. Don't write either.

Surviving the page. A fetch started while the user navigates away gets canceled with the document. Step 09 covers navigator.sendBeacon and the keepalive option.

Between 2015 and 2022 fetch had no deadline of any kind, and every codebase grew its own setTimeout wrapper. AbortSignal.timeout() closed that one.

Next Steps

Every request so far has been a GET with nothing in it. Step 6: What to send fills in the body:

  • The three encodings a browser sends without help, and what each looks like on the wire
  • URLSearchParams, FormData, and JSON.stringify, one per encoding
  • Why setting Content-Type yourself on a FormData body breaks the request
  • What JSON loses: Date, undefined, Map, Set, and anything with a cycle in it
  • How much of the object in front of you is actually worth putting on the wire