Learn by reading through in order

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
}
throw Carries the Cause to catch
If ok is false,return nullordersbecomes nullReadorders.lengthTypeError, andthe 403 is lostIf ok is false,throwThe await loadJsonline throwsSkip the rest,jump to catcherror.message isHTTP 403: …
If you return null, you get a different error on the orders.length line. If you throw, a message that includes the status reaches catch.

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.

On a delivery status screen, load the order list. A skeleton of the loadOrders function is already in place, and the two paths are listed at the top of the console.

① If the response signals failure, throw an exception whose message is “Can't load orders: ” followed by the path.

② On success, turn the body into an array and return it.

③ Load with the correct path and print the combined total of the orders.

④ Load with the misspelled path, and if it fails, print the exception's message.

(If it runs correctly, an explanation will appear.)

JavaScript / TypeScript Editor

Run code to see output

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
Where Each Kind of Failure Starts
The caller's try — the await loadJson(url) line
  • Either failure throws on this line and jumps to catch
  • In catch, check error instanceof TypeError to tell them apart
Inside loadJson
  • if (!response.ok) — for order.json, a response arrives and ok is false
  • throw new Error(...) — an Error you throw yourself
Inside await fetch(url)
  • https://shop.invalid/… — no response arrives
  • TypeError: Failed to fetchthrown by fetch
loadJson throws HTTP errors, and fetch throws network errors. Both reach the same catch, so tell them apart by type.

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.

On an inventory screen, show a different message for each kind of failure. loadJson and urls (three URLs) are already declared.

① In showStock, load the product list and print a line like “Products: 4”.

② If the request never reached the server, print “Can't reach the server”.

③ For any other failure, print “Can't load the product list”.

④ Pass each URL in urls to showStock in order.

JavaScript / TypeScript Editor

Run code to see output

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
}
Which Comes First: the Timer or the Response
Limit 100msresponse at 300msAt 100ms,abort() runsfetch failswith AbortErrorcatch reportsthe timeoutLimit 3000msresponse at 300msAt 300ms, theresponse arrivesclearTimeoutin finallyabort() isnever called
If the response is slower than the time limit, abort() runs first and the request fails. If the response makes it in time, cancel the scheduled abort().

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.

On a plan details screen, put a time limit on loading the member list. slowFetch, which stands in for a slow API, is already declared.

① Inside loadUsers, schedule the request to be cancelled once the time limit has passed.

② Use slowFetch to fetch the member list in a way that can be cancelled, and return the body's array.

③ Cancel the scheduled call whether the request succeeds or fails.

④ With a 3000ms limit, print the number of members; with a 100ms limit, print the error's name.

JavaScript / TypeScript Editor

Run code to see output

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
}
Retries Stop After One
Call loadWithRetry(path)1st trysucceeds1st tryTypeError1st tryTypeErrorNo 2ndcall2nd trysucceeds2nd try alsoTypeErrorReturns the arrayfrom the 1st tryLogs the retry,then returns arrayNo 3rd try;throws the error
If the first attempt succeeds, there's no second call. If the second attempt fails too, there's no third; the exception goes to the caller.

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.

FailureCheck in catchRetry?
Network errorerror instanceof TypeErrorOnce (may reconnect)
Timeout (abort())error.name === "AbortError"Once (server may be less busy)
HTTP error (403, 404)Anything elseNo (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.

Load the member list from a slow API. slowFetch and loadWithin are already declared.

① In loadWithRetry, load with a time limit of limitMs and return the result.

② Only on a timeout, print “Timed out, retrying” and load it again with a 3000ms limit.

③ Load the members with loadWithRetry and print a line like “Members: 4”.

④ Also load the wrong path, and print “Gave up: ” followed by the exception's message.

JavaScript / TypeScript Editor

Run code to see output
QUIZ

Knowledge Check

Answer each question one by one.

Q1When you await fetch(url) with a URL whose domain doesn't exist, which error.name reaches catch?

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?