The two genuinely useful additions around fetch() in recent years are AbortSignal.timeout() (a one-line timeout) and AbortSignal.any() (combining several abort conditions). Both reached Baseline in 2024.
One correction first. fetch() still does not reject on 404 or 500. You will find articles claiming an update made HTTP errors throw automatically; no such change happened. Checking response.ok remains mandatory.
With that established, this article covers aborting, timeouts, streaming, and parallel fetching in code you can use directly.
Sponsored
When does fetch() actually reject?
Only when the network transaction itself fails. Once the server responds at all, the promise fulfils—even with a 500.
| Situation | Promise result |
|---|---|
| 200 OK | Fulfilled (response.ok === true) |
| 404 / 500 | Fulfilled (response.ok === false) |
| Network down, DNS failure | Rejected (TypeError) |
| Blocked by CORS | Rejected (TypeError) |
Aborted via abort() |
Rejected (AbortError) |
| Timed out | Rejected (TimeoutError) |
So the minimum usable helper looks like this:
async function getJson(url, options = {}) {
const res = await fetch(url, options);
if (!res.ok) {
// without this, a 404 flows straight through
throw new Error(`HTTP ${res.status} ${res.statusText}`);
}
return res.json();
}
Including res.status in the error makes triage from logs much faster later.
Timeouts in one line with AbortSignal.timeout()
fetch() has no default timeout. A request that never gets a response simply stays outstanding. This used to require hand-rolling AbortController plus setTimeout; now it is one line.
// abort automatically after 5 seconds
const res = await fetch('/api/data', {
signal: AbortSignal.timeout(5000),
});
A timeout throws TimeoutError, which is distinguishable from a user-initiated AbortError, so you can message them differently.
try {
const res = await fetch('/api/data', { signal: AbortSignal.timeout(5000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
render(data);
} catch (err) {
if (err.name === 'TimeoutError') {
showMessage('No response. Please check your connection.');
} else if (err.name === 'AbortError') {
// user cancelled — show nothing
} else {
showMessage('Failed to load.');
console.error(err);
}
}
For comparison, the old hand-rolled version—where forgetting clearTimeout leaves a timer running:
// no longer needed
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch('/api/data', { signal: controller.signal });
// ...
} finally {
clearTimeout(timer); // easy to forget
}
Sponsored
Combining user cancellation and a timeout
In a search-as-you-type field you need both: cancel the previous request and time out. AbortSignal.any() merges them into one signal.
let currentController = null;
async function search(keyword) {
// discard the previous request
currentController?.abort();
currentController = new AbortController();
// stops on user cancellation OR after 8 seconds
const signal = AbortSignal.any([
currentController.signal,
AbortSignal.timeout(8000),
]);
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
if (err.name === 'AbortError') return null; // simply superseded
throw err;
}
}
AbortSignal.any() requires Safari 17.4 or later. Think of it as Promise.race() for signals: if any one aborts, the whole thing aborts.
Streaming: process while receiving
response.body is a ReadableStream, so you can start processing before everything has arrived. This is essential for rendering generative AI responses.
async function streamText(url, onChunk) {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (!res.body) throw new Error('Streaming not supported');
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
onChunk(value);
}
} finally {
reader.releaseLock();
}
}
await streamText('/api/stream', (text) => {
document.getElementById('output').append(text);
});
The key point is piping through TextDecoderStream. Decoding raw bytes yourself with TextDecoder corrupts multi-byte characters that straddle a chunk boundary; TextDecoderStream handles the boundaries correctly.
For download progress, compare received bytes against Content-Length:
const res = await fetch(url);
const total = Number(res.headers.get('Content-Length')) || 0;
const reader = res.body.getReader();
let received = 0;
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
if (total) {
updateProgress(received / total); // 0 to 1
}
}
const blob = new Blob(chunks);
Content-Length may be absent with compressed or chunked transfer encoding. Without the if (total) guard, your progress bar shows Infinity or NaN.
Sponsored
Fetching in parallel: all versus allSettled
When calling several APIs at once, the choice depends on whether one failure should fail everything.
// 1. useless without all of them → Promise.all
const [user, posts] = await Promise.all([
getJson('/api/user'),
getJson('/api/posts'),
]);
// 2. render what you can → Promise.allSettled
const results = await Promise.allSettled([
getJson('/api/user'),
getJson('/api/recommend'), // non-critical
getJson('/api/notice'),
]);
for (const r of results) {
if (r.status === 'fulfilled') {
render(r.value);
} else {
console.warn('Failed:', r.reason);
}
}
When any single success is enough, use Promise.any()—covered separately in how to use Promise.any().
Options worth knowing
| Option | Purpose |
|---|---|
keepalive: true |
Complete the request even as the page unloads (analytics; 64KB limit) |
cache: 'no-store' |
Bypass the cache entirely |
credentials: 'include' |
Send cookies cross-origin |
priority: 'low' |
Lower the fetch priority (Chromium) |
redirect: 'manual' |
Do not follow redirects automatically |
Logging on page exit used to mean navigator.sendBeacon(), but keepalive lets you set your own headers and method.
addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'hidden') return;
fetch('/api/log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ event: 'leave' }),
keepalive: true,
});
});
Do not detect departure with unload or beforeunload—they frequently do not fire on mobile. Use visibilitychange becoming hidden.
Summary
fetch()does not reject on 404 or 500.response.okis still required- Timeouts:
AbortSignal.timeout(ms), one line. The error name isTimeoutError - Combine cancellation and timeout with
AbortSignal.any([...])(Safari 17.4+) - Stream with
pipeThrough(new TextDecoderStream())to avoid mojibake at chunk boundaries Content-Lengthis not always present—always check before computing progressPromise.allwhen you need everything,Promise.allSettledwhen partial results are fine- Exit logging:
keepalive: trueplusvisibilitychange
AbortController itself has existed since 2017; what changed is that AbortSignal.timeout() and any() finally removed the boilerplate. For event handling patterns in modern JavaScript, see working with classes and events.