v154 · network / connectivity · enabled by default
Fetch API: Forward reason from AbortController to fetch Response
From Chrome 154, when a fetch() is aborted after its promise has resolved but before the response body has been fully read, the reason you passed to AbortController.abort(reason) is forwarded to the Response body: consumption methods such as response.text() reject with your reason, and the response.body ReadableStream errors with it. Previously those paths produced a generic "AbortError" DOMException and your reason was lost. This is a spec-compliance fix — no new API surface is added.
at a glance
| Shipped in | Chrome 154 (desktop, Android, Android WebView, iOS) — filed under “Enabled by default” in the milestone=154 listing; per-platform milestones from the ChromeStatus API record |
|---|---|
| Runtime flag | ForwardReasonToFetchBodyAbort, status stable in Blink’s runtime_enabled_features.json5 (“Flag for rolling out the standardized behavior where fetch calls are rejected with the reason provided to the abort controller”; verified 2026-10-04) |
| Specification | Fetch Living Standard; the abort-reason forwarding was added by spec commit c9039b9 “Add abort reason support” (Nidhi Jaju, 2022-10-05), a follow-up to whatwg/dom#1027 which added AbortSignal.reason |
| Tracking bug | issues.chromium.org/502133195 |
| Intent to Ship | blink-dev thread (2026-08-05), also archived as mail-archive msg17127 |
| Web platform tests | fetch/api/abort/general.any.html — the intent names the subtests “response.arrayBuffer() rejects with abort reason if already aborted” and “Stream errors once aborted with abort reason” |
| ChromeStatus | 5158507786665984 — Fetch API: Forward reason from AbortController to fetch Response |
what changed
Aborting a fetch has two windows. If you abort while the fetch() promise is still pending, the promise rejects with your reason — Chromium already did this (see the earlier abortable-fetch entry). If you abort after the headers arrived — the promise has resolved to a Response, but the body is still streaming — the spec says the reason must also reach everything that is reading that body. That second window is what Chrome 154 fixes:
| Surface | Abort mid-body, Chrome ≤ 153 | Abort mid-body, Chrome 154+ |
|---|---|---|
fetch() promise | Already settled — unaffected in both. (Aborting before settlement already rejected it with your reason.) | |
response.arrayBuffer(), .blob(), .bytes(), .formData(), .json(), .text() | Rejected with a newly minted generic "AbortError" DOMException; your reason was discarded | Rejected with your abort reason |
response.body (ReadableStream) — pending and subsequent reader.read() calls | Stream errored with a generic "AbortError" | Stream errored with your abort reason |
The normative requirement comes from two places in the Fetch Standard. “Abort a fetch() call” (§5.6):
“To abort a
fetch()call with a promise, request, responseObject, and an error: Reject promise with error. … If request’s body is non-null and is readable, then cancel request’s body with error. If responseObject is null, then return. Let response be responseObject’s response. If response’s body is non-null and is readable, then error response’s body with error.”
And HTTP-network fetch (§4.7), for the network-layer teardown of the body stream:
“If aborted, then: If fetchParams is aborted, then: Set response’s aborted flag. If stream is readable, then error stream with the result of deserialize a serialized abort reason given fetchParams’s controller’s serialized abort reason and an implementation-defined realm.”
Note the spec also cancels a readable request body with the same error; the Chrome 154 entry describes the Response-side gap specifically, and the request-body side is not separately called out in the sources read.
syntax
No signatures change. The surfaces whose rejection and error values change are the body members of the Response object, defined by the Body interface mixin (IDL verbatim from the Living Standard, fetched 2026-10-04 — textStream() is a newer spec addition quoted as-is, not part of this Chrome 154 change):
interface mixin Body {
readonly attribute ReadableStream? body;
readonly attribute boolean bodyUsed;
[NewObject] Promise<ArrayBuffer> arrayBuffer();
[NewObject] Promise<Blob> blob();
[NewObject] Promise<Uint8Array> bytes();
[NewObject] Promise<FormData> formData();
[NewObject] Promise<any> json();
[NewObject] Promise<USVString> text();
[NewObject] ReadableStream textStream();
};
None of these methods takes arguments; each returns a promise that settles as the body is fully read. The abort reason itself enters the system through AbortController.abort(reason) / AbortSignal, defined in the DOM Standard (IDL verbatim, fetched 2026-10-04):
[Exposed=*]
interface AbortSignal : EventTarget {
[NewObject] static AbortSignal abort(optional any reason);
[Exposed=(Window,Worker), NewObject] static AbortSignal timeout([EnforceRange] unsigned long long milliseconds);
[NewObject] static AbortSignal _any(sequence<AbortSignal> signals);
readonly attribute boolean aborted;
readonly attribute any reason;
undefined throwIfAborted();
attribute EventHandler onabort;
};
The reason is any JavaScript value. When none is given, the DOM Standard sets the signal’s abort reason “to a new "AbortError" DOMException”, and AbortSignal.timeout() aborts with “a new "TimeoutError" DOMException” — which is what makes timeouts distinguishable from user cancels at the body-read site (see examples). Response objects (and these members) are exposed in Window and Worker contexts.
how the reason travels (and when you still get AbortError)
The reason is not passed around by reference. When a fetch controller is aborted, the Fetch Standard stores a structured-serialized copy, and each consumer deserializes it back into its own realm (abort a fetch controller, quoted verbatim):
“Let fallbackError be an
"AbortError"DOMException. Set error to fallbackError if it is not given. Let serializedError be StructuredSerialize(error). If that threw an exception, catch it, and let serializedError be StructuredSerialize(fallbackError). Set controller’s serialized abort reason to serializedError.”
And the reverse step (deserialize a serialized abort reason, quoted verbatim):
“To deserialize a serialized abort reason, given null or a Record abortReason and a realm realm: Let fallbackError be an
"AbortError"DOMException. Let deserializedError be fallbackError. If abortReason is non-null, then set deserializedError to StructuredDeserialize(abortReason, realm). If that threw an exception or returned undefined, then set deserializedError to fallbackError. Return deserializedError.”
Practical consequences, all directly from those two algorithms:
- Inputs: any value works as a reason, but only structured-cloneable values survive the trip.
ErrorandDOMExceptionobjects, plain objects, strings and numbers arrive intact (as copies). - Fallback: if the reason cannot be serialized (a function, a value with call-site-only semantics) or deserializes to
undefined, consumers on this path receive the generic"AbortError"DOMExceptioninstead — the pre-154 behavior, by design. - Identity: a deserialized reason is a structurally equal copy, not the same object; don’t compare it with
===against the value you passed toabort(). (On the same-realm path, §5.6’s abort steps hand the signal’s abort reason to “abort a fetch() call” directly.) - Lifecycle: the serialized reason is set once, when the controller is aborted; every later delivery — erroring the body stream, rejecting in-flight
reader.read()promises, rejecting body-consumption promises — reuses it. A body that was already fully read, or a stream already closed/errored, is not re-errored (“if … is readable” guards every step).
examples
Cancelling a download after the headers arrived, and seeing your reason at the body read:
const controller = new AbortController();
const response = await fetch('/api/export.csv', {
signal: controller.signal,
});
// Headers are in: the fetch() promise has already resolved.
// The body is still streaming.
// Later, the user clicks "cancel":
controller.abort(new DOMException('User cancelled the export', 'AbortError'));
// ...any value is allowed: controller.abort({ why: 'user-cancel' });
try {
const text = await response.text();
} catch (err) {
// Chrome 154+ (and Firefox): err IS the reason passed to abort()
// (a structured clone of it on the cross-realm path).
// Chrome <= 153: err was a fresh generic "AbortError" DOMException
// and the reason was lost.
console.log(err.message); // "User cancelled the export"
}
Telling a timeout apart from a deliberate cancel while reading response.body — the case that makes this fix useful, because AbortSignal.timeout() aborts with a "TimeoutError" DOMException (DOM Standard, quoted above):
const response = await fetch(url, {
signal: AbortSignal.any([userController.signal, AbortSignal.timeout(10_000)]),
});
const reader = response.body.getReader();
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
render(value);
}
} catch (err) {
// Chrome 154+: the stream is errored with the abort reason.
if (err instanceof DOMException && err.name === 'TimeoutError') {
offerRetry(); // the 10 s budget ran out
} else {
showCancelled(err); // the user's own abort reason
}
}
response.text(), a stream reader, and signal.reason. Sibling concept demos (both 200): timeout vs. user cancel and cancelling mid-stream.Source: chrome-platform-showcaseTimeoutError per the DOM Standard; snippets gendn-derived (labelled accordingly), exercised live by the embedded showcase demo
browser compatibility
BCD has no entry for this behavior change yet: BCD api/Response.json contains no abort-reason subfeature or version note (raw file checked 2026-10-04 — zero matches for “abort”). The table below is therefore interim, built from the ChromeStatus record, the Intent to Ship, and BCD for the prerequisite AbortSignal.reason:
| Browser | Abort reason forwarded to Response body reads | Notes |
|---|---|---|
| Chrome | 154 (interim: ChromeStatus, not yet BCD) | Desktop, Android, Android WebView and iOS all 154 per the ChromeStatus API record; runtime flag ForwardReasonToFetchBodyAbort is stable |
| Edge | Unknown | “chromium/Edge… are not” compliant per the Intent (2026-08); as a Chromium browser it is expected to inherit the fix at its 154-based release, but no source read here records an Edge version |
| Firefox | Already compliant; version unknown | “Firefox at least is compliant here” (Intent); vendor view “Shipped/Shipping” in the ChromeStatus record. The first compliant Firefox version is not recorded in the sources read |
| Safari | Not compliant (as of 2026-08) | Per the Intent; the ChromeStatus record links the WebKit standards-position entry WebKit/standards-positions#711 as “Closed Without a Position” |
Prerequisite — AbortSignal.reason itself, per BCD api/AbortSignal.json (verified 2026-10-04): Chrome 98, Firefox 97, Safari 15.4, Edge mirrors Chrome. | ||
Cross-engine status of the two relevant subtests (“response.arrayBuffer() rejects with abort reason if already aborted”, “Stream errors once aborted with abort reason”) is tracked on wpt.fyi; per-browser subtest results were not individually verified here. Write code that treats the reason as a progressive enhancement: always handle a plain "AbortError" DOMException as the baseline outcome.
security and privacy
No new capability is exposed: the abort reason is a value the page itself supplied to abort(), delivered back to the same page’s own Response object. The structured-serialize/deserialize round trip (quoted in how the reason travels) is the trust boundary: the reason crosses between realms and off the network thread only as a structured clone, never as a live object reference, and anything that cannot be cloned is replaced by the neutral "AbortError" fallback rather than leaked or thrown across the boundary. Nothing about the reason is sent on the network, and the server cannot observe it — aborting still simply tears down the connection (for HTTP/2, the spec transmits an RST_STREAM frame). The change is observable only to script that already holds the Response.
specifications
see also
- MDN: AbortSignal.reason — the reason attribute itself (MDN does not yet document this Chrome 154 body-forwarding change; HEAD-checked 2026-10-04)
- MDN: Response.body and MDN: Using the Fetch API
- chrome-platform-showcase: abort-reason demos (three concepts, all routes 200)
- ChromeStatus — Fetch API: Forward reason from AbortController to fetch Response (5158507786665984)
- blink-dev Intent to Ship · tracking bug 502133195
- ChromeStatus — the original abortable-fetch entry (promise-phase abort)