The fetch API is the standard way to make HTTP requests from the browser, and it is also available in modern versions of Node.js. With async and await, asynchronous code reads almost like sequential code. That very simplicity, however, hides a few traps: errors that are not caught, requests that hang forever, responses read twice. Let us look at how to write robust calls.
The basic form
async function loadArticles() {
const response = await fetch('/api/articles');
const data = await response.json();
return data;
}
It works, as long as everything goes well. But this code already has an important flaw.
fetch does not fail on HTTP errors
This is the most common surprise: the promise returned by fetch is rejected only for network errors (server unreachable, connection dropped, request blocked by CORS). A 404 or 500 response is, as far as fetch is concerned, a successful response: the promise resolves normally.
That is why you must always check response.ok, which is true for codes from 200 to 299:
async function loadArticles() {
const response = await fetch('/api/articles');
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return response.json();
}
Without this check, an HTML error page would end up in response.json(), which would throw an unhelpful syntax error.
A more useful custom error
When the API returns structured errors, it is worth keeping them:
class HttpError extends Error {
constructor(response, body) {
super(`HTTP ${response.status}`);
this.status = response.status;
this.body = body;
}
}
async function requestJson(url, options = {}) {
const response = await fetch(url, {
...options,
headers: { 'Accept': 'application/json', ...options.headers }
});
const type = response.headers.get('content-type') || '';
const body = type.includes('application/json')
? await response.json()
: await response.text();
if (!response.ok) throw new HttpError(response, body);
return body;
}
We read the body only once: a response stream can be consumed only once, and calling json() and then text() on the same response throws an error. If you need to read it twice, use response.clone().
Handling errors with try/catch
With await, errors are caught with an ordinary try/catch:
try {
const articles = await requestJson('/api/articles');
render(articles);
} catch (error) {
if (error instanceof HttpError && error.status === 401) {
redirectToLogin();
} else {
showMessage('Unable to load articles. Please try again shortly.');
}
}
Distinguishing the cases lets you give users useful messages: an authorisation problem is solved differently from a temporarily unavailable server.
Watch out for async functions called without await or .catch(): if they fail, the error becomes an "unhandled rejection" that can go unnoticed.
Timeouts with AbortController
fetch has no default timeout: if the server does not respond, the request can remain pending for a very long time. The solution is AbortSignal:
const response = await fetch('/api/report', {
signal: AbortSignal.timeout(8000) // 8 seconds
});
When the time runs out the promise is rejected with an error whose name is TimeoutError. To cancel manually, for example when the user changes page or types a new search, use an AbortController:
let controller;
async function search(text) {
controller?.abort(); // cancel the previous search
controller = new AbortController();
try {
const r = await fetch(`/api/search?q=${encodeURIComponent(text)}`, {
signal: controller.signal
});
return await r.json();
} catch (e) {
if (e.name === 'AbortError') return null; // cancelled: not an error
throw e;
}
}
This pattern also prevents a subtle bug: without cancellation, a slow response to an old search could arrive after the new one and overwrite its results.
Note encodeURIComponent too: values placed in a URL must always be encoded, or built with URLSearchParams.
Parallel requests
If requests are independent, do not wait for them one at a time:
// Slow: sequential
const user = await requestJson('/api/user');
const notifications = await requestJson('/api/notifications');
// Faster: parallel
const [user2, notifications2] = await Promise.all([
requestJson('/api/user'),
requestJson('/api/notifications')
]);
Promise.all fails as soon as one of the promises fails. If you want every result, even partial ones, use Promise.allSettled, which returns for each one its status (fulfilled or rejected) and the value or the error.
Retries with increasing delays
For temporary errors (unstable network, 503, 429) it can make sense to retry, but with judgement:
async function withRetries(fn, attempts = 3) {
for (let i = 0; ; i++) {
try {
return await fn();
} catch (e) {
const transient = !(e instanceof HttpError) || [429, 502, 503, 504].includes(e.status);
if (!transient || i >= attempts - 1) throw e;
await new Promise(r => setTimeout(r, 500 * 2 ** i)); // 0.5s, 1s, 2s…
}
}
}
Retry only idempotent operations, such as reads: automatically repeating a POST that creates an order can duplicate it. Never retry 4xx errors that indicate a problem with the request, because the result will not change.
In short
fetchrejects the promise only for network errors: always checkresponse.ok.- Read the response body only once, choosing the method according to the
Content-Type. - Use
AbortSignal.timeoutfor timeouts andAbortControllerto cancel outdated requests. - Run independent requests in parallel with
Promise.allorPromise.allSettled. - Retry only transient errors and idempotent operations, with increasing delays.