Fetch and streaming
A successful fetch can contain an HTTP error
fetch() returns a promise that fulfills with a Response when the browser has received the response status and headers. The body may still be arriving. A server response such as 404 or 503 also fulfills that promise.
async function loadProfile() {
const response = await fetch("/api/profile");
if (!response.ok) {
throw new Error(`Request failed with HTTP ${response.status}`);
}
return response.json();
}
response.ok is true for statuses from 200 through 299. Network errors and some other failures, such as a bad URL scheme, reject the fetch promise. JSON parsing can reject later if the response body is not valid JSON.
Wrong "The catch block handles every 404 and 500 response."
Right A received HTTP error is still a response. Check ok or status, then decide which statuses your application accepts.
Request and Response carry the HTTP data
fetch() accepts a URL or a Request. A Request can package the URL, method, headers, body, credentials mode, cache mode, redirect mode, and an abort signal. The fulfilled value is a Response with status, exposed headers, and a body.
The convenience methods consume the complete body and convert it:
const response = await fetch("/api/report");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const report = await response.json();
Methods such as json(), text(), blob(), and arrayBuffer() are asynchronous. A body is one-use. After json() starts reading it, text() on the same response returns a rejected promise because the stream is disturbed.
const response = await fetch("/api/report");
const copy = response.clone();
const parsed = await response.json();
const raw = await copy.text();
Clone before either read when two consumers need the body.
Cancellation and timeouts
Pass an AbortSignal to make a fetch cancelable. Calling abort() causes the fetch operation to reject with an AbortError. If headers have arrived but the body has not been read, aborting also makes a later body read reject.
const controller = new AbortController();
const request = fetch("/api/slow", {
signal: controller.signal,
});
cancelButton.addEventListener("click", () => controller.abort());
try {
const response = await request;
if (!response.ok) throw new Error(`HTTP ${response.status}`);
console.log(await response.text());
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
console.log("Canceled by the caller");
} else {
throw error;
}
}
AbortSignal.timeout(5000) creates a signal that aborts automatically and uses a TimeoutError DOMException as its reason.
const response = await fetch("/api/slow", {
signal: AbortSignal.timeout(5_000),
});
Wrong "Promise.race() with a timer cancels the losing fetch."
Right Promise.race() only settles the wrapper promise. An abort signal tells Fetch that the caller has lost interest in the request and body transfer.
Process a response before the whole body arrives
The fetch promise fulfills after response status and headers are available, potentially before the body has arrived. response.text() and response.json() wait for the complete body. response.body exposes a ReadableStream, so code can process chunks incrementally.
The chunks are bytes. Decode them with TextDecoderStream when the payload is UTF-8 text:
async function readEvents(url, onText) {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if (!response.body) throw new Error("Response has no readable body");
const text = response.body.pipeThrough(new TextDecoderStream());
for await (const chunk of text) {
onText(chunk);
}
}
A network or decoding failure can occur during iteration even though the original fetch() already fulfilled. Keep HTTP status handling and stream error handling conceptually separate.
Wrong "Each text chunk is one server message or one line."
Right The stream supplies chunks, not application records. A line, JSON object, or other record can cross chunk boundaries, and one chunk can contain several records. Keep incomplete text between reads and parse records from the accumulated data.
async function readLines(response, onLine) {
if (!response.body) throw new Error("Response has no readable body");
const reader = response.body
.pipeThrough(new TextDecoderStream())
.getReader();
let pending = "";
try {
while (true) {
const { value = "", done } = await reader.read();
if (done) break;
pending += value;
const lines = pending.split("\n");
pending = lines.pop() ?? "";
for (const line of lines) onLine(line);
}
if (pending) onLine(pending);
} finally {
reader.releaseLock();
}
}
Locked and disturbed bodies
Calling getReader() locks a stream to that reader. No other reader can be acquired until the lock is released. Once any content has been read, the body is disturbed and cannot be consumed again through json(), text(), or another reader.
Response.clone() creates two body branches when called before consumption. This is useful for cases such as returning a network response while placing a clone in a service worker cache. It is a poor default for two large consumers that run at different speeds. The cloned body applies backpressure at the rate of the faster consumer, while unread data accumulates for the slower branch without a limit.
const response = await fetch("/large-export");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if (!response.body) throw new Error("Response has no readable body");
const cacheCopy = response.clone();
const cacheWrite = caches.open("exports").then((cache) =>
cache.put("/large-export", cacheCopy),
);
await consumeIncrementally(response.body);
await cacheWrite;
Use this only when both branches will be consumed. If one clone is never consumed, the entire body can be buffered in memory for that branch.
One signal for caller cancellation and deadline
AbortSignal.any() returns a signal that aborts when the first input signal aborts and adopts that signal's reason. It can combine navigation or user cancellation with a timeout.
async function fetchWithDeadline(url, callerSignal) {
const timeout = AbortSignal.timeout(10_000);
const signal = AbortSignal.any([callerSignal, timeout]);
try {
const response = await fetch(url, { signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response;
} catch (error) {
if (signal.aborted && error === signal.reason && error === timeout.reason) {
throw new Error("The request timed out", { cause: error });
}
if (signal.aborted && error === signal.reason) {
throw new Error("The caller canceled the request", { cause: error });
}
throw error;
}
}
Comparing the caught value with signal.reason preserves caller cancellation even when the caller supplied a custom reason. A timeout uses a TimeoutError; AbortController.abort() with no reason uses an AbortError.
The timeout measures active time. It can pause while a document is in the back-forward cache or a worker is suspended, so it is not a wall-clock service-level deadline.
Streaming uploads have a half-duplex contract
The Fetch Standard permits a ReadableStream as a request body. When code supplies that stream through RequestInit.body, it must also set duplex: "half". The specified meaning is precise: the user agent sends the entire request before processing the response.
const body = new ReadableStream({
pull(controller) {
const chunk = produceNextChunk();
if (chunk === null) {
controller.close();
} else {
controller.enqueue(chunk);
}
},
});
const response = await fetch("/upload", {
method: "POST",
body,
duplex: "half",
});
This allows the request body to be produced incrementally. It does not expose a full-duplex conversation in which JavaScript consumes response chunks while it is still producing request chunks.
As of the MDN Request.duplex page updated July 3, 2026, this feature is experimental and not Baseline because it fails in some widely used browsers. Production code needs a compatibility decision rather than assuming every browser accepts the example.
Wrong "A streaming request body means browser Fetch is a portable bidirectional stream."
Right The current Fetch API defines only "half". Full duplex is reserved for future use in the standard.
The Fetch Standard also returns a network error when a request has a streaming body with no replayable source and the connection is HTTP/1.x. Retry behavior is constrained too: if the user agent reads beyond its permitted buffer and later needs to resend such a body, it can return a network error. Treat streamed uploads as non-replayable unless the application can create a fresh body for a new request.
Backpressure stops at the application boundary
Streams are designed to provide queuing and backpressure. A pull-based ReadableStream asks its underlying source for more data as capacity becomes available. For a fetched response, the browser can suspend ongoing network intake when its internal buffer exceeds a user-agent-selected upper limit.
Backpressure does not make arbitrary application parsing cheap. If code appends every decoded chunk to one string, memory still grows with the whole response.
async function countBytes(response) {
if (!response.body) return 0;
let total = 0;
for await (const chunk of response.body) {
total += chunk.byteLength;
}
return total;
}
Keep the working set bounded by emitting complete records and retaining only an incomplete suffix. Response.clone() needs special care because its two branches backpressure at the faster consumer's rate. The slower branch accumulates unread data without a limit, and an unread branch can buffer the entire body.
Failure categories carry different information
Fetch exposes several distinct outcomes:
- An HTTP error such as 503 is a
Response; application code checksokorstatus. - A network error makes the
fetch()promise reject withTypeError. The Fetch Standard intentionally exposes limited detail. - Aborting can reject the fetch call or error a response body stream with the abort reason.
- A body can fail after headers have already caused
fetch()to fulfill.
Do not infer a retry policy from TypeError alone. The same JavaScript exception category does not prove that a request was safe, that the server received no bytes, or that repeating a mutation is valid.
async function postOnce(url, body, signal) {
const response = await fetch(url, {
method: "POST",
body,
signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response;
}
This function reports failure; it does not retry. Retry safety belongs to the operation's HTTP semantics and application protocol.
Fetch and XHR overlap, but their interfaces differ
Fetch is promise-based, uses Request and Response, integrates response bodies with ReadableStream, and is available in window and worker contexts. XHR uses an event-driven object with ready states and response types.
XHR exposes ProgressEvent notifications for downloads and uploads. Upload events come from xhr.upload, with loaded, total, and lengthComputable. Fetch response progress can be computed by counting streamed bytes, although a meaningful total requires exposed metadata and may be unknown. XHR still has a direct upload-progress API that browser Fetch does not replace with an equivalent progress event.
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) {
renderProgress(event.loaded / event.total);
}
});
xhr.open("POST", "/upload");
xhr.send(file);
Choosing Fetch for stream composition does not make XHR obsolete in every browser upload UI. The required capability decides between them.
dig deeper
Primary sources behind these notes - the specs and official docs worth reading in full.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.