TestingAPIs is still being built. If something breaks, email support@testingapis.com.

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:

demos/no-timeout.mjs
// 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`);
This example uses endpoints for signed-in members.Sign inCreate a free account
Terminal
$ node demos/no-timeout.mjs
still waiting after 15 s
still waiting after 30 s
still waiting after 45 s
status 200 after 45372 ms

It 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:

Diagram
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
TimeoutLimitsCatches
ConnectHow long opening the connection may takeA server that's down or unreachable, quickly
Headers (time to first byte)How long until the status and headers arriveA server that accepted the request but is stuck working on it
Body, or idleThe longest gap between two pieces of the bodyA response that stalls halfway
Total, or deadlineThe whole exchange, start to finishAll 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:

src/timeout.mjs
// 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, fetch gives up and rejects with an error named TimeoutError.
  • Callers sometimes have their own reason to stop a request, such as a user leaving the page. They pass their own signal, and AbortSignal.any combines the two into one signal that fires when either does.
  • Everything else in options goes to fetch unchanged, 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/timeouts.test.mjs
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`);
});
This example uses endpoints for signed-in members.Sign inCreate a free account

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:

Result
headers arrived after 241 ms, status 200
reading the body threw TimeoutError after 2519 ms

The 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:

Terminal
$ node demos/latency.mjs
p50   108 ms
p90   439 ms
p95   941 ms
p99   1541 ms
p100  3402 ms

Half 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:

Terminal
$ 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:28Z

The 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. 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. 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. 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. 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.

Practise and go further