Q1When you await fetch(url) with a URL whose domain doesn't exist, which error.name reaches catch?
fetch Error Handling and Timeouts — AbortController and Retries
Covers HTTP errors that fetch doesn't throw on, network errors where fetch itself fails, timeouts with AbortController and setTimeout, and retrying just once.
Reading response.ok tells you when the server says a file doesn't exist. If the connection is down, though, the await fetch line throws before you ever get to ok. And if the response is slow, the code just keeps waiting instead of moving on to the next line.
This article covers how to handle each kind of fetch failure as an exception, and how to cancel a request with AbortController.
Turning Failed Responses into Exceptions — throw new Error
Suppose three different screens load the order list. If loadJson returns null on failure, as it did in the previous article, every screen has to check for null, and if one of them forgets, a later line that reads the value fails with an error.
With an HTTP error (a request that reached the server but got back a status such as 403 or 404 that signals failure), fetch doesn't throw. If you throw new Error(...) when ok is false, the caller can handle the failure in one place with try / catch.
// If ok is false, throw an exception that includes the status
async function loadJson(path) {
const response = await fetch(path);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${path}`);
}
return await response.json();
}
try {
const orders = await loadJson("fixtures/en/order.json"); // this line throws
console.log(`Orders: ${orders.length}`); // never runs
} catch (error) {
// 403 in the exercise console, 404 or similar on other servers
console.log(error.message); // HTTP 403: fixtures/en/order.json
}
With throw, even if a screen forgets catch, the uncaught error still says HTTP 403. Instead of writing an if on every screen to check for null, you only have to decide where to catch the failure.
Telling Network Failures Apart — TypeError and instanceof
When you lose signal while you're out, the request never reaches the server and no response comes back. This failure lands in the same catch as an HTTP error, so as things stand, you'd show the same message for “the file doesn't exist” and “can't connect”.
With a network error (a failure where no response arrives, for example because the connection is down or the domain doesn't exist), the Promise that fetch returns is rejected with a TypeError. If you check instanceof TypeError in catch, you can tell it apart from an Error you threw yourself.
// loadJson is the same as in the previous section (throws if ok is false)
const urls = [
"fixtures/en/order.json", // reaches the server, but the file doesn't exist
"https://shop.invalid/orders.json", // a .invalid domain never connects anywhere
];
for (const url of urls) {
try {
await loadJson(url);
} catch (error) {
// An Error you threw yourself, or a TypeError thrown by fetch?
const kind = error instanceof TypeError ? "Can't connect" : "Request failed";
console.log(`${kind} / ${error.name}: ${error.message}`);
}
}
// Request failed / Error: HTTP 403: fixtures/en/order.json
// Can't connect / TypeError: Failed to fetch
- Either failure throws on this line and jumps to
catch - In
catch, checkerror instanceof TypeErrorto tell them apart
if (!response.ok)— fororder.json, a response arrives andokisfalsethrow new Error(...)— anErroryou throw yourself
https://shop.invalid/…— no response arrivesTypeError: Failed to fetch— thrown by fetch
A TypeError from the inner fetch leaves loadJson before ever reaching its if. Each screen wants its own message, so loadJson only throws, and the caller's catch, which receives both kinds, checks the type.
The Network Error Message Varies by Browser
The message of the TypeError that fetch throws differs by browser: Failed to fetch in Chrome and Load failed in Safari. If you check by matching the message text, other browsers end up in the wrong branch. Check the type with instanceof TypeError instead.
Cutting Off a Request on Timeout — AbortController
A busy server can take tens of seconds to respond. fetch has no option that sets a maximum wait, so the await fetch line doesn't move on until a response arrives, and the screen looks stuck loading. The code in this section uses slowFetch, a stand-in for fetch that takes 300ms to respond.
If you create an AbortController (an object that lets you cancel a fetch in progress) and pass it as fetch(url, { signal: controller.signal }), calling abort() makes the request fail with an AbortError.
To cancel a scheduled call, pass the timer ID (a number that identifies the scheduled call) returned by setTimeout to clearTimeout.
// slowFetch is a stand-in for fetch, defined in the Exercise 3 console; it takes 300ms to respond and is used just like fetch
const controller = new AbortController();
// Schedule abort() to run after 100ms, and keep the timer ID
const timerId = setTimeout(() => controller.abort(), 100);
try {
// Just like fetch, pass signal in the second argument
const response = await slowFetch("fixtures/en/orders.json", { signal: controller.signal });
console.log(response.ok); // the response takes 300ms, so this never runs
} catch (error) {
console.log(error.name); // AbortError
} finally {
clearTimeout(timerId); // cancel the scheduled call in case the response arrives first
}
Putting clearTimeout in finally means no scheduled call is left behind, whether the request succeeds or fails. If abort() runs before the body has been read, json() also fails with an AbortError, so keep return await response.json() inside try as well, and move on to finally only after the body has been read.
Trying Once More — Retries and return await
On a phone on a moving train, the connection can drop for a moment and a request fails, but trying again right away often works. Instead of asking the user to reload every time, your code can retry automatically and keep the data on screen.
To retry (send a failed request again to the same URL), call the function again inside catch. Without a cap on attempts, the code would keep sending requests for as long as the connection is down, so if the second attempt also fails, the error goes straight to the caller.
// loadJson is the same as in the first section. Retry once, and only for network failures and timeouts
async function loadWithRetry(path) {
try {
return await loadJson(path);
} catch (error) {
const retryable = error instanceof TypeError || error.name === "AbortError";
if (!retryable) {
throw error; // resending just gets the same 403 or 404
}
console.log(`Retrying after ${error.name}`); // Retrying after TypeError
return await loadJson(path); // a second failure goes straight to the caller
}
}
try {
await loadWithRetry("https://shop.invalid/orders.json");
} catch (error) {
console.log(`Failed twice: ${error.name}`); // Failed twice: TypeError
}
An HTTP error, such as the one for fixtures/en/order.json, is rethrown in the first catch, so it goes to the caller without a second call. The table below shows how to spot each kind of failure in catch and whether to retry it.
| Failure | Check in catch | Retry? |
|---|---|---|
| Network error | error instanceof TypeError | Once (may reconnect) |
| Timeout (abort()) | error.name === "AbortError" | Once (server may be less busy) |
| HTTP error (403, 404) | Anything else | No (same response again) |
return Without await Skips catch
If you write try { return loadJson(path); } without await, the function leaves try as soon as it returns the Promise. Even if that Promise is rejected later, catch doesn't run, and the failure goes straight to the caller. Write return await loadJson(path); instead.
Knowledge Check
Answer each question one by one.
Q2You schedule abort() for 100ms from now and pass signal to a request that takes 300ms to respond. What happens?
Q3Which failure does this article's loadWithRetry rethrow without retrying?