Skip to Content
Add a Timeout to fetch With AbortController
Advanced
0 likes
Tutorials

Add a Timeout to fetch With AbortController

fetch has no timeout option and can wait a very long time. AbortSignal.timeout(ms) fixes that in one line, and AbortSignal.any combines it with a cancel button.

fetch has no timeout option. If the server accepts the connection and then never answers, your await fetch(...) can wait for minutes. In a browser the spinner just spins. On a server, requests pile up until something runs out. The fix is one extra option: signal: AbortSignal.timeout(ms).

The one-line fix

const response = await fetch(url, { signal: AbortSignal.timeout(5000) });

If the response hasn't arrived within 5 seconds, fetch rejects and the request is cancelled. Here it is against a local server that takes 2 seconds to answer, with a 300 ms timeout:

import http from "node:http";

const server = http.createServer((req, res) => {
  setTimeout(() => res.end("finally"), 2000);
}).listen(0);
const url = `http://localhost:${server.address().port}/`;

const start = Date.now();
try {
  await fetch(url, { signal: AbortSignal.timeout(300) });
} catch (error) {
  console.log(error.name);
  console.log(error.message);
  console.log(Date.now() - start < 1000 ? "gave up in under a second" : "waited too long");
} finally {
  server.closeAllConnections();
  server.close();
}
TimeoutError
The operation was aborted due to timeout
gave up in under a second

Save it as .mjs and run it with Node 18 or later. The local server stands in for a slow API, so you don't depend on someone else's.

Timeout vs cancel

There are two reasons to stop a request, and they need different handling:

  • Timeout: the server was too slow. Tell the user, maybe retry. The error name is TimeoutError.
  • Cancel: your code or the user stopped it on purpose (they left the page, typed a new search). Show nothing. The error name is AbortError.

Combine both with AbortSignal.any:

import http from "node:http";

const server = http.createServer((req, res) => {
  setTimeout(() => res.end("finally"), 2000);
}).listen(0);
const url = `http://localhost:${server.address().port}/`;

async function load(cancelSignal, timeoutMs) {
  try {
    const signal = AbortSignal.any([cancelSignal, AbortSignal.timeout(timeoutMs)]);
    const response = await fetch(url, { signal });
    return await response.text();
  } catch (error) {
    if (error.name === "TimeoutError") return "Show: The server took too long. Try again.";
    if (error.name === "AbortError") return "Show nothing: cancelled on purpose.";
    throw error;
  }
}

const userCancel = new AbortController();
setTimeout(() => userCancel.abort(), 100);
console.log(await load(userCancel.signal, 5000));
console.log(await load(new AbortController().signal, 200));

server.closeAllConnections();
server.close();
Show nothing: cancelled on purpose.
Show: The server took too long. Try again.

💡 Tip: AbortSignal.timeout and AbortSignal.any work in current browsers and in Node 20 and later. On older setups, use an AbortController and setTimeout(() => controller.abort(), ms), and clear the timer when the request finishes.

The timeout covers the body too

The signal stays active while you read the body. If the headers arrive in time but the body is a huge, slow download, await response.json() can still be aborted by the same timeout. Usually that's what you want: the timeout is for the whole operation, not just the first byte. If you want separate limits, use separate signals.

Pick a sensible number

  • User-facing request: 5 to 15 seconds. Longer than that, they've already given up.
  • Server-to-server call inside a request: shorter than your own request timeout, so you can still answer with a proper error.
  • Big downloads or reports: longer, or no timeout but a cancel button.

âš  Warning: A timeout doesn't undo anything on the server. If you timed out a "create order" request, the order may still have been created. Before retrying something like that, check whether it went through, or use an idempotency key if the API supports one.

Don't do it with Promise.race

You'll see this in older code:

await Promise.race([fetch(url), new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 5000))]);

It stops waiting, but the request keeps running in the background and the connection stays open. AbortSignal.timeout actually cancels it.

Which fetch calls in your current project have no timeout at all, and what happens to the user if that server stops answering?

About

Add a timeout to fetch with AbortSignal.timeout, tell a timeout apart from a user cancel, and combine both signals. After this, no request hangs forever.

Responsible
John G
Last Update
08/28/2026
Reading Time
4 min
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.