Your fetch timeout starts before the request does
I lost an afternoon to this one, so you don’t have to.
A request queue I was testing kept reporting timeouts. Not slow requests — timeouts, on a local server that answered every call in 400 milliseconds, with a one-second limit on each one. The math said nothing should fail. Three of my four requests failed anyway.
The bug was one line long, and it was not in the queue.
The line that starts the timeout early
const signal = AbortSignal.timeout(1000)
queue.add(() => fetch(url, { signal }))Reads fine, right? “Give this request one second.” That is not what it says.
AbortSignal.timeout(1000) does not mean one second for the fetch. It means
one second from right now, wherever “now” happens to be. The stopwatch
starts on the line that creates the signal. Whether a fetch ever uses it is
none of its business.
So if the signal sits in a queue for 900ms waiting for its turn, the request it guards has 100ms left to live. And if it waits 1100ms, the request is already dead before it is sent.
Proving it
I do not trust an explanation I have not watched fail. Here is the test I ran on Node v22.16.0 — a server that takes 400ms to answer everything, and a queue that runs one request at a time:
import http from "node:http"
const server = http.createServer((req, res) => {
setTimeout(() => res.end("ok"), 400) // every request takes 400ms
})
await new Promise((r) => server.listen(0, r))
const url = `http://127.0.0.1:${server.address().port}/`
async function runQueued(tasks) {
const out = []
for (const t of tasks) out.push(await t().catch((e) => e.name))
return out
}
// Signals built up front — the clock is already running on all four
const tasks = [1, 2, 3, 4].map((i) => {
const signal = AbortSignal.timeout(1000)
return () => fetch(url, { signal }).then(() => `ok#${i}`)
})
console.log("built up front:", await runQueued(tasks))
// Signal built at the moment the task actually runs
const fresh = [1, 2, 3, 4].map(
(i) => () =>
fetch(url, { signal: AbortSignal.timeout(1000) }).then(() => `ok#${i}`),
)
console.log("created on run:", await runQueued(fresh))The output:
built up front: [ 'ok#1', 'ok#2', 'TimeoutError', 'TimeoutError' ]
created on run: [ 'ok#1', 'ok#2', 'ok#3', 'ok#4' ]Same server. Same limit. Same four requests. The only difference is where the signal was born. Request three had burned 800ms standing in line before it was allowed to start, and it only needed 400ms more — but it was out of budget at 1000ms and got cut off partway through.
timeline
title One second of budget, spent in the queue
0ms : req1 starts (signal 1,2,3,4 all created here)
400ms : req1 done, req2 starts
800ms : req2 done, req3 starts — 200ms of its budget left
1000ms : req3 killed mid-flight, req4 already expiredIt gets quieter than that
A retry loop with one shared signal is the same bug wearing a different hat, and this time it does not even reach the network. Three attempts against a server that never answers:
attempt 1: TimeoutError after 305ms
attempt 2: TimeoutError after 0ms
attempt 3: TimeoutError after 1msAttempts two and three “timed out” instantly. An expired signal means fetch
rejects before it opens a socket — I counted the requests on the server side
and it saw zero. Your logs will show three timeouts against an endpoint
that was never contacted. Good luck reading that dashboard.
This is the part that makes the bug expensive. The error message is not wrong, exactly, but it points at the wrong thing. It says the network took too long. The network did nothing at all.
The other half: it is not an AbortError
While I was in there I checked what actually comes out of a timed-out fetch, because plenty of code branches on it:
| How it aborted | err.name |
|---|---|
AbortSignal.timeout(50) | TimeoutError |
controller.abort() | AbortError |
Both are DOMException. Both are instanceof Error. But the names differ, and
that matters for the pattern nearly every data-fetching hook contains:
catch (err) {
if (err.name === "AbortError") return // user navigated away, stay quiet
setError(err)
}That check does the right thing here — a real timeout falls through and gets
shown. The trap is the reverse: code written to treat any abort as “the user
cancelled” will swallow genuine timeouts if it tests with instanceof DOMException or a truthy signal.aborted. If you need to tell the two apart,
use the name, and use it on purpose:
const cancelled = err.name === "AbortError" // someone pressed stop
const timedOut = err.name === "TimeoutError" // the clock ran outThe fix: create the timeout late
Create the signal as late as you can — inside the function that performs the request, not outside it.
// ✅ every attempt gets its own full second
const withTimeout = (url, ms = 1000) =>
fetch(url, { signal: AbortSignal.timeout(ms) })That is the whole fix. One layer of indirection, and the clock now starts when the request does.
Two situations need a little more. If you also want the caller to be able to cancel, combine the two signals instead of picking one:
async function request(url, { signal: userSignal, timeout = 1000 } = {}) {
const signal = userSignal
? AbortSignal.any([userSignal, AbortSignal.timeout(timeout)])
: AbortSignal.timeout(timeout)
return fetch(url, { signal })
}And if what you actually want is a deadline for the whole operation — “this must finish in three seconds, retries included” — then one shared signal is exactly right. It is only a bug when you meant per-attempt and wrote per-lifetime. Say which one you meant, out loud, in the variable name:
const overallDeadline = AbortSignal.timeout(3000) // shared on purposeWhere this actually bites
A toy queue is the clearest way to show the bug, but it is not where most people meet it. Four real shapes, all the same mistake:
Options built once, reused twice. Somebody hoists a defaultOptions
object to module scope so the code looks tidy. If that object holds a signal,
every request in the process shares one stopwatch that started at import time.
The first call works. Everything after it fails.
A retry with backoff. Wait 500ms, try again, wait 1000ms, try again. If the signal was made before the first attempt, the backoff is spending the budget you meant for the request.
A React effect that fetches a list, then details. The second fetch reuses the signal from the first because it was right there in scope. It inherits whatever time the first one left behind, which on a slow connection is none.
A server handler under load. Requests pile up behind a connection pool or a rate limiter. Under light traffic nothing waits and everything passes; under load the waiting eats the budget and your error rate jumps in a way that looks like the upstream service degraded. It did not. You did.
That last one is the nasty one, because it only appears when you are busy — the exact moment nobody wants to read a stack trace.
How to spot it in your own code
Three questions, in order:
- Is there any distance between where the signal is created and where
fetchis called? A variable assignment, an options object built in advance, an array of jobs — each one is a gap the clock ticks through. - Does anything sit between them that can wait? A queue, a concurrency limit,
a retry backoff, a lock, an
awaiton something unrelated. - Do your timeout errors arrive suspiciously fast, or suspiciously in bunches? A 0ms timeout is not a slow server. It is an expired signal.
If a queue is involved, the same care applies to how you count the wait itself
— I wrote about the neighbouring trap in
what happens when two requests share a console.time label,
where the timing you print belongs to a different request than the one you
think. And when the timeout does fire for real, what you log about it decides
whether the next person can debug it: see
why error objects print as empty in JSON logs,
because a DOMException serialises to {} just as cheerfully as an Error
does.
The behaviour is not a Node quirk, by the way. It is what the spec asks for:
AbortSignal.timeout() is defined to start its timer when the signal is
created, per the
WHATWG DOM standard.
Browsers do the same thing. The API did exactly what it promised — it just
promised something slightly different from what most of us read.