An outbound fetch can keep waiting on a slow dependency while the rest of your application waits on the Promise. If your service has a response deadline, put that deadline in the signal passed to fetch. Do not hide a timer in a parallel Promise that only abandons the result.
In Node.js, AbortSignal.timeout(milliseconds) creates a signal that aborts after the given delay. Pass it as signal and keep the same boundary through response-body consumption. If the caller can cancel too, combine the manual signal with the deadline through AbortSignal.any().
This article uses TypeScript and native APIs. I checked the equivalent logic with a local HTTP server that delays headers and the body, without measuring a production application. The current Node.js global objects documentation, checked on September 28, 2026, records the methods and their introduction versions. The MDN reference for AbortSignal.timeout(), checked the same day, documents TimeoutError, AbortSignal.any(), and the fact that the timeout has no built-in cancellation method.

Short answer
- Pass
AbortSignal.timeout(deadline)in thesignaloption offetch.- Combine the deadline with caller cancellation through
AbortSignal.any().- Distinguish timeout, manual cancellation, network failure, and HTTP status.
- Do not treat a client abort as a rollback for work that already reached the server.
Does fetch have its own timeout in Node.js?
Do not confuse the time your application is willing to wait with an internal transport limit. The explicit way to control a call is to pass an AbortSignal. Node.js documents AbortSignal.timeout(delay) as the method that creates a signal aborted after the supplied delay.
For a simple call, the code is small:
async function loadProfile(url: string): Promise<unknown> {
const response = await fetch(url, {
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) {
throw new Error(`Service returned HTTP ${response.status}`);
}
return response.json();
}
The deadline remains attached to the fetch operation. response.json() stays inside that asynchronous operation too. If the response takes longer than the deadline, the Promise rejects and the caller must decide how to log, answer, or retry.
The 5_000 value is only an example. The right deadline depends on the route contract and its dependency. There is no universal number that makes a slow call healthy.
How do you combine a timeout with manual cancellation?
Use two signals when there are two legitimate reasons to stop the call: the operation deadline and a caller decision. AbortSignal.any() returns a signal that aborts when any signal in the list aborts, preserving the reason from the signal that won, according to the Node.js documentation.
type RequestOptions = {
signal?: AbortSignal;
timeoutMs: number;
};
async function loadJson<T>(
url: string,
options: RequestOptions,
): Promise<T> {
const deadline = AbortSignal.timeout(options.timeoutMs);
const request = options.signal
? AbortSignal.any([options.signal, deadline])
: deadline;
const response = await fetch(url, { signal: request });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return (await response.json()) as T;
}
Keep the deadline signal available when you need to classify a failure. In production code, avoid creating it twice. Return an object containing deadline and request, or keep both in the same scope, so the error handler observes the signal that was actually sent to fetch.
AbortSignal.any() arrived in Node.js later than AbortSignal.timeout(). If your support matrix includes older runtimes, check the documented version before shipping this code. A controlled AbortController with setTimeout is the fallback when you need to support a runtime without these static methods.
How can you tell a timeout from cancellation or a network error?
The error object alone does not always explain why the work stopped. Keep the deadline signal and the caller signal. In the catch block, check which one aborted before classifying the failure for logs, metrics, or a retry policy.
type FailureKind = "timeout" | "cancelled" | "network";
function classifyFailure(
error: unknown,
deadline: AbortSignal,
callerSignal?: AbortSignal,
): FailureKind {
if (deadline.aborted) {
return "timeout";
}
if (callerSignal?.aborted) {
return "cancelled";
}
if (error instanceof Error) {
return "network";
}
return "network";
}
With the native timeout, the signal reason is a DOMException named TimeoutError, according to MDN. Manual cancellation can call AbortController.abort(reason) and carry its own reason. Observing the signal state therefore remains useful when different fetch implementations produce different error objects.
HTTP status is a separate path. A 404 or 503 normally resolves the fetch Promise. It is not automatically a network failure. Check response.ok and turn the status into a domain error before applying a retry policy.
The useful decision point is not the error name. It is the boundary you controlled. A known deadline can be handled as an exceeded budget, while a refused connection may deserve a different action. Mixing both paths makes a retry policy look simple and fail during the first incident.
Does the timeout also limit response-body reading?
Yes, if the same signal remains attached to fetch while the body is consumed. MDN shows the request and response read inside the same operation. This matters when a server sends headers quickly but takes much longer to produce all response bytes.
async function readText(url: string, timeoutMs: number): Promise<string> {
const signal = AbortSignal.timeout(timeoutMs);
const response = await fetch(url, { signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.text();
}
Do not place a timer only around the first await fetch(url). That Promise may resolve when headers arrive. Reading text(), json(), or a stream can still be active. The signal needs to follow the boundary you intend to limit.
If the deadline must end immediately when the operation finishes, note that AbortSignal.timeout() has no method to cancel its own timer. For code with many listeners or long deadlines, MDN recommends considering an AbortController, setTimeout, and clearTimeout, with cleanup in finally.
How do you test an expiring fetch without waiting on the network?
Test the behavior with a local HTTP server you control. The executable example below sends headers, waits, and only then sends the body. It checks both a request deadline and a deadline during response reading.
import { createServer } from "node:http";
const server = createServer((_request, response) => {
response.writeHead(200, { "content-type": "text/plain" });
response.flushHeaders();
setTimeout(() => {
response.end("late response");
}, 200);
});
await new Promise<void>((resolve) => server.listen(0, resolve));
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Server did not open a port");
}
try {
const response = await fetch(`http://127.0.0.1:${address.port}`, {
signal: AbortSignal.timeout(30),
});
await response.text();
throw new Error("The timeout should interrupt the body read");
} catch (error) {
if (error instanceof DOMException && error.name === "TimeoutError") {
console.log("timeout verified");
} else {
throw error;
}
} finally {
server.close();
}
In a production repository, turn this idea into a test for the runner your team uses. Also cover a fast response, manual cancellation, and a connection error. Do not depend on sleep against a public API. External network conditions make tests slow and obscure the cause of failure.
I ran the equivalent logic with a delayed local server in the Node.js runtime available in the workspace. That check confirms the timeout and body-read path. It does not prove that every proxy, fetch implementation, or server stops remote work in the same way.
Can you retry automatically after a timeout?
Only after deciding whether the operation is safe to repeat. The client may stop waiting after the server has received the request. This matters for payments, record creation, report generation, and any endpoint with an external effect. Aborting fetch does not undo a remote transaction.
For an idempotent read, a bounded retry may make sense after classifying the timeout. For a write, use an idempotency key or an explicit server contract before retrying. The API idempotency testing guide covers the protection against duplicate effects.
The guide to testing JavaScript retry logic without waiting covers the test clock. It does not replace the product decision about retrying a request that may already have reached its destination.
What does AbortSignal.timeout() not solve?
The signal tells an operation to stop. It does not make the dependency faster, fix an HTTP status, validate JSON, or undo server work. After a timeout, remote work may still be in progress, depending on when the server received and processed the request.
Do not use a TypeScript cast to hide the response either. After the call passes the time and transport boundary, validate the payload before treating it as trusted data. See how to validate external JSON before using it in TypeScript for the next boundary.
Frequently asked questions
Does fetch reject its Promise for a 404?
Not necessarily. An HTTP status can produce a resolved Response. Check response.ok or response.status and turn the result into a domain failure. A timeout is a signal interruption, while a 404 is a response received from the server.
Does AbortSignal.timeout() replace AbortController?
For a simple deadline, it avoids creating and clearing a manual timer. You still need AbortController when the caller can cancel, when you need a custom reason, or when you must end the timer explicitly. Combine signals when the deadline and cancellation represent separate decisions.
Does a timeout stop the server from finishing its work?
No. It stops waiting and observable processing on the client. The request may have reached the server before the abort. For external effects, use idempotency, state confirmation, or a cancellation mechanism understood by the server itself.
Conclusion
The basic pattern is fetch(url, { signal: AbortSignal.timeout(ms) }). When manual cancellation exists, combine the signals with AbortSignal.any(). When the response arrives in pieces, keep the signal attached through body reading. Before retrying, classify the failure and confirm that the remote effect is idempotent.
This article does not define a universal deadline or replace your service policy. It gives the rest of the application an explicit boundary for deciding what to do when a dependency takes longer than the contract allows.
Sources consulted
- Node.js: Global objects, checked September 28, 2026.
- MDN: AbortSignal.timeout() static method, checked September 28, 2026.
- WHATWG Fetch issue 951: Add a timeout option, checked September 28, 2026.
- Stack Overflow: Fetch API request timeout?, checked September 28, 2026.