Learning Objectives
- Write step 04's request with
fetchand name the lines that disappeared - Say what
response.oktests and what it does not - Name the conditions that reject a
fetchpromise, 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 onekind - Set a deadline with
AbortSignal.timeout()and combine it with a caller's cancel usingAbortSignal.any() - Say which Chrome versions report a timeout as an abort, and what that does to code branching on the name
- Read a
Responsebody once, say what the second read does, and useclone()when two things need it - Name what
fetchstill 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.
The same request, the same result, on fetch.
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, aDOMException. Your code calledabort()on a controller whose signal you passed.TimeoutError, aDOMException. A deadline fromAbortSignal.timeout()elapsed. Both of these arrive through thesignaloption, 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 isFailed to fetchfor 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 valuefetchwill 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.
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.
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.
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 complete | XMLHttpRequest | fetch | kind |
|---|---|---|---|
| Network down, DNS, cross-origin block | error event | TypeError | 'network' |
| Deadline elapsed | timeout event | DOMException, name TimeoutError | 'timeout' |
| Your code canceled it | abort event | DOMException, 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.
On the fetch side, the whole translation is one catch.
{ 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.
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.
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.
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.
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.
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.
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.
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 toxhrand 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.okisfalse,statusis 500, anddataholds{"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, anddataisnull. 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=5000with the timeout at1000and send: the promise rejects withkind: 'timeout'. Send again with the timeout at0and press Cancel:kind: 'abort'. Run both on each transport, then read thesignaledByline: 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, andJSON.stringify, one per encoding- Why setting
Content-Typeyourself on aFormDatabody 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