Skip to content
TaeyoungKim.dev

JavaScript fetch timeout: Why Promise.race does not cancel the request

WebWritten 3 min readTaeyoungKim
LinkedInX

The UI says a request timed out, but the server responds a moment later. The timeout ended the UI's wait; it did not necessarily stop the request. That distinction is easy to miss when the timeout uses Promise.race().

Why does a Promise.race timeout leave fetch running?

Imagine /api/report responds after 120 ms, but the UI waits only 20 ms:

js
const request = fetch('/api/report');
const timeout = new Promise((_, reject) => {
  setTimeout(() => reject(new Error('Exceeded 20 ms')), 20);
});

try {
  await Promise.race([request, timeout]);
} catch (error) {
  console.log(error.message); // Exceeded 20 ms
}

race settles with the first promise to settle. It does not tell the other participant, request, to cancel. The fetch may still receive the response at 120 ms, even though the UI no longer uses it. These timings illustrate the order of events; they are not network guarantees. MDN's Promise.race reference describes the settling behavior.

The snippet isolates that difference. In application code, also clean up the timeout when the request finishes first.

The diagram separates a timeout of the caller's wait from cancellation of the client request. AbortController interrupts the client-side fetch, but it cannot guarantee that work already started on the server is undone.

Stop a fetch with AbortController

Pass a signal to fetch() and call abort() when the timer fires. Keep the timer active through response-body reading if the limit should cover that work too.

js
async function fetchReport(url, timeoutMs = 3000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, { signal: controller.signal });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return await response.text();
  } finally {
    clearTimeout(timer);
  }
}

If the timer fires before the slow response finishes, fetchReport() typically rejects with an AbortError. But AbortError by itself does not prove a timeout: another caller could abort the same signal. Record the reason separately if the UI needs to distinguish a timer from user cancellation. MDN documents how AbortController aborts fetch and response-body consumption.

The finally block matters when the request finishes first: it removes the pending timer. clearTimeout() removes a timer, while abort() interrupts the client request. They serve different purposes.

What does a slow-response comparison show?

With a local HTTP server that replies done after about 120 ms, a 20 ms Promise.race timeout finishes first, but awaiting the original fetch afterward still receives done. With AbortController, calling abort() at 20 ms instead causes the client-side fetch to reject:

text
Promise.race → 20 ms timeout → original fetch still receives the 120 ms response
AbortController → abort() after 20 ms → client fetch rejects with AbortError

This comparison establishes what happens to the client's wait. It does not show that abort() rolls back a server-side calculation or write that has already begun. For requests with side effects, such as payments or orders, plan for duplicate processing before retrying an aborted call.

Key takeaways

Promise.race() can enforce a waiting deadline, but it does not cancel an ongoing fetch. To interrupt the client request, attach an AbortController signal and call abort() at the deadline. Clear the timer after the response body is read, and treat HTTP errors, timeouts, and user cancellation as distinct events.

Author

TaeyoungKim

Connecting technical foundations with implementation, verification, and production decisions.

#JavaScript fetch#Fetch timeout#Promise.race#AbortController#Request cancellation

Read next