How to Add a Timeout to fetch in TypeScript
Oleksandr Zhuravlov
Add a timeout to fetch when a slow or hung response shouldn't be allowed to block your caller indefinitely. fetch ships with no timeout option of its own, so a request to a stalled upstream waits as long as the OS lets the socket stay open — which can be minutes. You reach for a timeout the first time a single slow dependency holds up a request you promised would return in seconds.
The generic way: an AbortSignal that cancels the request
The correct primitive is AbortSignal. On current runtimes (Node 18+, modern browsers) AbortSignal.timeout(ms) gives you one that aborts itself after the deadline — pass it to fetch and a breached deadline cancels the in-flight request:
async function getReport(): Promise<unknown> {
try {
const res = await fetch('https://api.example.com/report', {
signal: AbortSignal.timeout(3000), // aborts the request after 3s
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
if (err instanceof DOMException && err.name === 'TimeoutError') {
throw new Error('report timed out after 3s');
}
throw err;
}
}If your runtime predates AbortSignal.timeout, build the same thing from an AbortController and a timer — and clear the timer on success so it doesn't dangle:
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);
try {
const res = await fetch(url, { signal: controller.signal });
return await res.json();
} finally {
clearTimeout(timer);
}For a single call, stop here — this is the right amount of code, and it does the one thing that matters: the signal makes the abort real, so the connection is actually cancelled.
The trap: racing a Promise doesn't cancel anything
The pattern that looks equivalent but isn't is Promise.race against a timer:
// Looks like a timeout. Leaks the request.
const res = await Promise.race([
fetch(url),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('timeout')), 3000),
),
]);This rejects your promise after 3 seconds, so the caller unblocks — but the fetch is never told. The socket stays open, still consuming a connection-pool slot, and the response body downloads into nothing in the background. Under load this is how a slow upstream quietly exhausts your connection pool. The difference between the two patterns is the whole point of a timeout: a real one cancels the work, a raced one only stops waiting for it.
Where the hand-rolled version runs out
A single AbortSignal answers one question — how long the whole call gets. That's enough until you add retries, and then one deadline can't express what you need. If the timeout is per-attempt, three retries plus backoff can block the caller well past it; if it's the total budget, one slow attempt can eat the whole thing so the retries never fire. You want both bounds, named separately, and threading two timers plus an AbortController plus retry logic through every call site is exactly the boilerplate that rots.
The stitch way: total and per-attempt deadlines on the call
A stitch makes the timeout a property of the call. You declare both scopes as one timeout object, and the engine backs them with a real AbortSignal — the same cancellation the manual version did right, without the wiring:
import { stitch } from 'stitchapi';
const report = stitch({
baseUrl: 'https://api.example.com',
path: '/report',
timeout: { total: '10s', perAttempt: '3s' },
});perAttempt bounds each individual try; total bounds the entire call across every retry and the backoff waits between them. Whichever fires first aborts the request. Both accept a duration string ('3s') or a number of milliseconds (3000), so total: '10s' and total: 10000 are the same thing. A breached deadline surfaces as STITCH_TIMEOUT — a distinct, catchable error rather than a generic abort you have to sniff with err.name.
The two scopes exist because they compose with retry. Each try is gated by perAttempt, while total caps the sum of all tries plus their waits — so a long enough total is what lets a slow attempt be retried at all, and a tight perAttempt stops one hung attempt from swallowing the budget:
import { stitch } from 'stitchapi';
const getQuote = stitch({
baseUrl: 'https://api.example.com',
path: '/quote/{symbol}',
retry: { attempts: 3, on: [429, 502, 503], respectRetryAfter: true },
timeout: { total: '10s', perAttempt: '3s' },
});Because the timeout lives on the declaration, every front door — the in-process function, the CLI, the HTTP endpoint, the agent over MCP — inherits the same deadline instead of each re-wiring its own AbortController.
When the plain AbortSignal is enough
The stitch buys you two scopes, a typed error, and composition with retry; a one-off call needs none of that.
- A single fetch with no retries. One endpoint, one attempt —
AbortSignal.timeout(3000)on thesignalis the whole job. A stitch would be more setup for the same bound. - You're not also adding retry, auth, or validation. A timeout earns its place on a declaration when it sits next to
retry, an auth boundary, and anoutputvalidator that all share the call. If a bare deadline is genuinely all you need, the inline signal is leaner. - The deadline never composes. The reason for
totalandperAttemptis retry. With no retries there's only one question to answer, and oneAbortSignalanswers it.
The line isn't the timeout itself — both versions cancel the socket with an AbortSignal. It's whether you need one deadline or two that compose, a typed error, and the same policy on every caller. When a slow dependency turns into a sustained one and retries start making the outage worse, that's a different problem — a circuit breaker plus layered timeouts is the tool for it.
Try it
npm i stitchapiGive a stitch a timeout: { total, perAttempt } and a slow upstream is bounded at two scopes with a real abort and a typed error — once, for every caller. The mechanics are in Timeouts, the error in STITCH_TIMEOUT, and how it composes with backoff in Retry.