Timeouts: deciding how long to wait
Why a missing deadline hurts, what fetch does by default, and choosing a value from real latency.
A timeoutis a limit on how long your code waits for a response. When the limit passes, the client stops waiting and treats the request as failed. It sounds like a small setting, but it decides what happens in one of the most common failures there is: a server that doesn't answer. Without a timeout, a slow server makes your code slow, and a stuck server makes it stuck.
What happens without one
/api/chaos/timeout?seconds=45sign in to see holds a request open for 45 seconds before answering. Here is fetch with no limit set, calling it:
// fetch with no deadline, against a server that hangs for 45 seconds. Watch how long it waits.
// Run: node demos/no-timeout.mjs
import { BASE_URL, apiHeaders } from "../src/api.mjs";
const started = Date.now();
const timer = setInterval(() => console.log(`still waiting after ${Math.round((Date.now() - started) / 1000)} s`), 15_000);
const response = await fetch(`${BASE_URL}/api/chaos/timeout?seconds=45`, { headers: apiHeaders() });
clearInterval(timer);
console.log(`status ${response.status} after ${Date.now() - started} ms`);$ node demos/no-timeout.mjs
still waiting after 15 s
still waiting after 30 s
still waiting after 45 s
status 200 after 45372 msIt waited the full 45 seconds. That's the longest this endpoint will hang, not the longest fetchwill wait. Node's fetch is built on a library called undici, whose documentation gives a default of 300 seconds for the headers to arrive and another 300 seconds for each gap in the body. Five minutes, in other words, and longer if the body trickles in.
Waiting that long costs more than time:
- A person is looking at a spinner. Most people give up long before five minutes, and they don't come back.
- Each waiting request holds memory and a connection. If a server you call starts hanging, requests pile up in your service until it runs out of one or the other, and then your service fails too, for everyone. One slow dependency has taken down a healthy system.
- Nothing gets logged while the request waits, so nobody knows anything is wrong until much later.
A timeout turns "stuck, for an unknown time" into "failed, after a known time". A failure you know about is one you can handle: retry it, show a message, fall back to something else.
Four things you can time
A request goes through phases, and HTTP clients can limit each separately:
connect ├──┤ opening the connection
headers ├───────────┤ waiting for the status and headers
body ├─┤ ├──┤ ├─┤ gaps between pieces of the body
total ├────────────────────────────────┤ the whole exchange, start to finish| Timeout | Limits | Catches |
|---|---|---|
| Connect | How long opening the connection may take | A server that's down or unreachable, quickly |
| Headers (time to first byte) | How long until the status and headers arrive | A server that accepted the request but is stuck working on it |
| Body, or idle | The longest gap between two pieces of the body | A response that stalls halfway |
| Total, or deadline | The whole exchange, start to finish | All of the above, plus a body that trickles in slowly but never stalls |
The first three each watch one phase. A body that sends one byte every second never trips a 10-second idle timeout, however long it takes in total. The total deadline is the one that matches what the caller cares about, "I need an answer within 3 seconds", and it's the one this tutorial uses.
A deadline with AbortSignal
fetch has no timeout option. Instead it accepts a signal, an object that can tell it to stop. AbortSignal.timeout(ms) creates a signal that fires by itself after the given time:
// fetch with a deadline. The deadline covers the whole exchange: connecting, waiting for the headers and reading
// the body, as long as the body is read before the time runs out.
export function fetchWithTimeout(url, { timeoutMs = 5000, signal, ...options } = {}) {
const deadline = AbortSignal.timeout(timeoutMs);
// If the caller passed its own signal (a cancel button, say), either one can stop the request.
const combined = signal ? AbortSignal.any([signal, deadline]) : deadline;
return fetch(url, { ...options, signal: combined });
}AbortSignal.timeout(timeoutMs)starts the clock. When it runs out,fetchgives up and rejects with an error namedTimeoutError.- Callers sometimes have their own reason to stop a request, such as a user leaving the page. They pass their own
signal, andAbortSignal.anycombines the two into one signal that fires when either does. - Everything else in
optionsgoes tofetchunchanged, so the function is used exactly like it.
Here is the test that checks the deadline works, against the endpoint that hangs for 10 seconds:
test("a server that hangs is cut off at the deadline", async () => {
const started = Date.now();
await assert.rejects(
fetchWithTimeout(`${BASE_URL}/api/chaos/timeout?seconds=10`, { headers: apiHeaders(), timeoutMs: 1500 }),
{ name: "TimeoutError" },
);
const elapsed = Date.now() - started;
assert.ok(elapsed >= 1500 && elapsed < 4000, `gave up after ${elapsed} ms`);
});It checks two things: that the error is a TimeoutError, and that it came at roughly the right time. The time check allows a range rather than an exact value, because networks and test machines vary. Lesson 8 says more about testing time.
The deadline covers the body too
A signal stays attached to the response after the headers arrive, so it also stops the body from being read. To see it, /api/stream/drip?durationMs=6000&bytes=6sign in to see sends its headers at once and then one byte per second, with a 2.5-second deadline:
headers arrived after 241 ms, status 200
reading the body threw TimeoutError after 2519 msThe headers came back in a quarter of a second, so fetch itself succeeded. The error came later, from response.text(), when the clock ran out partway through the body. Read the body before the deadline passes, or the read fails. The test the deadline also covers reading the body checks exactly this.
Timing out is not the same as cancelling
When your own code calls abort() on an AbortController, fetch rejects with an AbortError, not a TimeoutError. The difference matters later. A timeout is a failure: log it, and maybe retry it. A cancellation is a decision: someone no longer wants the result, so retrying it or reporting it as an error would be wrong. src/classify.mjs from lesson 1 gives them different names for that reason.
Choosing the number
Too short and you fail requests that would have succeeded. Too long and you're back to waiting on a stuck server. The right value comes from how long requests to that endpoint really take, measured as percentiles.
Percentiles describe a set of measurements by asking how many were faster than a given value. The p50, or median, is the time half of all requests beat. The p99 is the time 99 out of every 100 requests beat; only 1 in 100 was slower. Averages hide slow requests, which are the ones timeouts are about, so percentiles are the numbers to use.
/api/chaos/random-latencysign in to see imitates a real endpoint: most calls are quick, a few are slow and a very few are much slower. demos/latency.mjs calls it 200 times:
$ node demos/latency.mjs
p50 108 ms
p90 439 ms
p95 941 ms
p99 1541 ms
p100 3402 msHalf the calls finished in about a tenth of a second, 99 in 100 within about 1.5 seconds, and the slowest took 3.4. Your numbers will differ, since the endpoint is random and your network isn't mine. Reading this as an engineer would:
- A 500 ms timeout fails at least 1 request in 20, since the p95 is above it. Far too short.
- A 2-second timeout lets through 99 in 100, and fails the rare very slow ones.
- A 5-second timeout lets almost everything through, but a stuck server costs 5 seconds per attempt.
A common starting point is a little above the p99 of normal traffic, here 2 to 3 seconds, then adjusted to what the caller can afford. If a page has to load within 5 seconds and you plan to try twice, each attempt gets no more than about 2. Measure each endpoint separately: a search that scans a large table and a lookup by ID need different limits.
The request you gave up on may still succeed
A timeout ends the wait on your side only. The server doesn't know you stopped listening, and it usually carries on with the work. That's harmless for a read. For a request that changes something, it's the "unknown" row from lesson 1, and here it is happening for real. The payments endpoint can be told to take 3 seconds, and the client gives up after 1:
$ node demos/timeout-still-succeeds.mjs
1st try: TimeoutError after 1 s. Did the payment happen? We can't tell.
2nd try: 201, Idempotent-Replayed: true
payment 17, succeeded, created 2026-10-11T16:55:28ZThe first attempt timed out, yet the payment was created: the second request, sent with the same idempotency key, got back the payment the first one made. Had the client sent a plain new request instead, the customer would have paid twice. Lesson 6 explains how that second request works; for now, remember that a timeout tells you that you stopped waiting, not that nothing happened.
Check your understanding
1.Node's fetch has no timeout set. How long will it wait for a server that never answers?Show answer
Minutes. undici, the library behind it, waits up to 300 seconds for headers by default, and up to another 300 seconds for each gap in the body. Always pass a signal.
2.fetch resolved with status 200, then response.json() threw a TimeoutError. How?Show answer
The deadline covers the whole exchange. The headers arrived in time, but the body was still arriving when the time ran out, so reading it failed.
3.An endpoint's p50 is 80 ms and its p99 is 2 seconds. Why is a 200 ms timeout a bad idea?Show answer
More than 1 request in 100, possibly many more, takes longer than 200 ms, and every one of them would fail even though the server was about to answer. Start a little above the p99 instead.
4.Your request to send an email timed out. Was the email sent?Show answer
Maybe. The server may have finished after you stopped waiting. Treat the outcome as unknown, which is the problem idempotency keys solve in lesson 6.
Exercise
Run node demos/latency.mjs three times and note how much the p99 moves between runs. Based on your numbers, choose a timeout for /api/chaos/random-latencysign in to see. Then write a test that calls it 50 times with that timeout and counts how many calls time out. Is the count close to what your percentiles predicted? For a different kind of slow, try /api/chaos/slow?minMs=1000&maxMs=3000sign in to see, which waits a random time between the two values. The failure injection API reference lists every option.