API Timeout Error in Node.js: Causes, Fixes, and Circuit Breaker Patterns
Quick answer
💡Node.js has no default HTTP request timeout — connections wait indefinitely unless you explicitly set one. Use AbortController with setTimeout to cancel requests after a deadline, or set the timeout option in undici. When calling AWS Lambda behind API Gateway, note the hard 29-second API Gateway timeout regardless of Lambda's configured limit. For repeated failures, implement a circuit breaker to stop hammering a down dependency.
Error symptoms
- ✕
HTTP requests hang indefinitely and the Node.js process never resolves or rejects the promise - ✕
DOMException: The operation was aborted when using AbortController to cancel a timed-out fetch - ✕
Axios error.code ETIMEDOUT when a connection attempt exceeds the socket timeout - ✕
Axios error.code ECONNABORTED when the Axios timeout option is exceeded after connecting - ✕
AWS Lambda function invocations timeout at exactly 29 seconds regardless of the function timeout setting - ✕
Node.js process memory grows unbounded because hanging request promises never settle
Common causes
- •Node.js http.request and node-fetch v3 have no built-in timeout — requests wait forever without one
- •socket.setTimeout() fires the timeout event but does not close the socket — socket.destroy() must be called manually
- •AbortController.abort() was not called inside a finally block, so the timer fires after the request succeeds and aborts the next request
- •AWS API Gateway enforces a 29-second hard limit independently of the Lambda function timeout
- •Cloudflare Workers enforce CPU time limits, not wall-clock time, causing inconsistent timeouts under load
- •Database queries inside a route handler timeout separately from the HTTP response timeout, leaving both connections open
When it happens
- •Calling a slow third-party API in a Lambda function or serverless context with strict time limits
- •Making outbound HTTP requests from a Node.js server without configuring timeout on the http.request options
- •Using node-fetch v3 which removed the timeout option from the fetch call signature
- •Deploying to a hosted service that has its own idle timeout shorter than the upstream API response time
- •Running database queries that take longer than expected due to missing indexes or lock contention
Examples and fixes
Node.js http.request without a timeout leaves the process waiting forever if the server stalls.
HTTP request with no timeout — hangs indefinitely
❌ Wrong
const http = require('http');
function fetchInventory(productId) {
return new Promise((resolve, reject) => {
// No timeout set — hangs indefinitely if server stalls
const req = http.request(
`http://inventory.internal/products/${productId}`,
(res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => resolve(JSON.parse(body)));
}
);
req.on('error', reject);
req.end();
});
}✅ Fixed
const http = require('http');
function fetchInventory(productId, timeoutMs = 5000) {
return new Promise((resolve, reject) => {
const req = http.request(
`http://inventory.internal/products/${productId}`,
(res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
try { resolve(JSON.parse(body)); }
catch (e) { reject(new Error('Invalid JSON in response')); }
});
}
);
// timeout event fires but does NOT destroy the socket
req.setTimeout(timeoutMs, () => {
req.destroy(new Error(`Request timed out after ${timeoutMs}ms`));
});
req.on('error', reject);
req.end();
});
}Node.js http.request has no default timeout. If the target server accepts the TCP connection but then stalls — due to a slow database query, lock contention, or a stuck worker — the request will wait indefinitely. The process does not crash; it just leaks a pending promise that never resolves. In serverless environments like Lambda, this eventually causes the function to timeout at the platform's hard limit. The key subtlety is that req.setTimeout() fires the timeout event but does not close the socket — you must call req.destroy() inside the callback to actually terminate the connection and trigger the error event. Without destroy(), the timeout event fires, nothing happens, and the request continues hanging.
Using AbortController to cancel a fetch request after a deadline, with proper cleanup to avoid post-success abort.
AbortController with fetch for modern Node.js timeout
❌ Wrong
// Leaky AbortController — timer fires even after success
async function fetchUserData(userId) {
const controller = new AbortController();
// This timer is never cleared if the request succeeds
setTimeout(() => controller.abort(), 5000);
const res = await fetch(
`https://api.example.com/users/${userId}`,
{ signal: controller.signal }
);
return res.json();
}✅ Fixed
async function fetchUserData(userId, timeoutMs = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(new Error(`Timed out after ${timeoutMs}ms`)),
timeoutMs
);
try {
const res = await fetch(
`https://api.example.com/users/${userId}`,
{ signal: controller.signal }
);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${await res.text()}`);
}
return await res.json();
} finally {
// Always clear the timer — prevents abort after success
clearTimeout(timeoutId);
}
}
try {
const user = await fetchUserData('user_abc123');
} catch (err) {
if (err.name === 'AbortError') {
console.error('Request cancelled due to timeout');
} else {
throw err;
}
}The broken version creates a setTimeout that is never cleared. If the request completes in 2 seconds, the abort fires 3 seconds later — potentially cancelling a completely unrelated request that happens to share the same controller instance, or leaving a dangling timer that delays Node.js process exit. Always clear the timeout in a finally block so it runs whether the request succeeded, failed, or was cancelled. The abort reason parameter in the AbortController constructor lets you pass a meaningful Error object that appears as the abort reason, making error messages more diagnostic. Catch AbortError specifically in calling code to distinguish timeout from other network errors.
Using undici's built-in timeout options for fine-grained control over connection, header, and body phases.
undici with separate header and body timeouts
❌ Wrong
const { request } = require('undici');
// No timeouts — any phase can hang indefinitely
async function streamLargeReport(reportId) {
const { body } = await request(
`https://reports.internal/api/reports/${reportId}`
);
return body.json();
}✅ Fixed
const { request } = require('undici');
async function streamLargeReport(reportId) {
const { statusCode, body } = await request(
`https://reports.internal/api/reports/${reportId}`,
{
// Time to receive first byte of headers
headersTimeout: 5000,
// Time to read the complete body after headers received
bodyTimeout: 30000,
maxRedirections: 3
}
);
if (statusCode !== 200) {
// Consume and discard the body to free the connection
await body.dump();
throw new Error(`Report API returned ${statusCode}`);
}
return body.json();
}undici exposes two separate timeout dimensions that a single timeout value cannot express. headersTimeout controls how long to wait for the server to send the first response header byte — this catches slow-starting servers and hanging connections early. bodyTimeout controls how long to wait for the full body to arrive after headers are received — this is the right place to set a generous limit for large payloads. Setting only one global timeout penalizes either fast endpoints or slow-starting servers. Also note the body.dump() call on error — undici requires you to consume the body before the connection is returned to the pool, otherwise connections leak silently.
Why Node.js has no default request timeout
Node.js made a deliberate design decision not to impose a default timeout on HTTP connections. The rationale was that timeout values are application-specific: a health check endpoint should time out in 1 second, a file upload might need 5 minutes, and a streaming connection might legitimately never close. Baking in a global default would either be too short for legitimate long-running requests or too long to protect against hung connections. This philosophy puts the burden on developers to configure timeouts explicitly, which many omit entirely.
The result is that a Node.js server making outbound HTTP requests without explicit timeouts can accumulate thousands of pending connections to a slow or unresponsive upstream. Each pending request holds a socket, occupies a slot in the connection pool, and keeps its promise unresolved. The Event Loop continues processing other requests, but over time the backlog grows until the process runs out of file descriptors, memory, or connection pool capacity — and then all requests start failing, not just those going to the slow upstream.
The complexity is deepened by the fact that timeouts in HTTP have multiple phases. A TCP connection timeout fires if the three-way handshake never completes, which usually indicates a firewall dropping packets silently. A socket inactivity timeout fires when no data has been transferred for a given period after the connection is established. A total request timeout caps the entire operation from connection attempt to final byte received. Node.js exposes socket-level timeouts through socket.setTimeout() and req.setTimeout(), but these control inactivity, not total duration. AbortController with setTimeout is the standard way to set a total deadline.
Serverless environments add a third layer of timeout concerns. AWS Lambda has a per-function timeout configurable up to 15 minutes, but API Gateway — the most common way to expose Lambda functions as HTTP endpoints — enforces its own 29-second timeout regardless of the Lambda setting. A Lambda function can be configured for 10 minutes but will receive a 504 response from API Gateway after 29 seconds. Cloudflare Workers have a 10ms CPU time limit on the free plan and 50ms on paid plans — a CPU budget, not a wall-clock limit — so under concurrent request load, CPU time can be exhausted faster than expected.
Finding where the timeout originates
The first diagnostic step is measuring how long each phase of the request takes. Add timestamps before the fetch call, after response headers arrive, and after the body is fully read. The gap between each timestamp identifies which phase is slow. If the connection takes 20 seconds and the body reads in 100ms, the problem is with the server starting to respond. If the connection is fast but the body read takes minutes, the issue is payload generation time on the server or a streaming response blocking the client.
In the browser, use DevTools Network tab to break down request timing. Select the failing request and look at the Timing panel: DNS lookup, TCP connect, TLS handshake, TTFB (time to first byte), and content download are shown separately. For API requests, TTFB is the most diagnostic number — a high TTFB means the server is slow to process the request, not that the network is slow. Use the /tools/http-request-builder on ToolDock to send the same request from a neutral location and compare timing values.
For Node.js server-side requests, add performance.now() measurements around each fetch call and log the duration with the request URL and response status. When logs show certain URLs consistently taking over 2 seconds, those are the candidates causing timeout-induced problems under concurrent load. Correlate slow request logs with server memory usage and connection pool size metrics — high memory usage often indicates connection pool exhaustion from accumulated hung requests.
For AWS Lambda, check CloudWatch Logs for the invocation that timed out. Lambda logs the duration and whether the function hit its configured timeout. If the logged duration is exactly 29000ms and the Lambda timeout is set higher, API Gateway is enforcing its own limit. If the duration matches the Lambda configured timeout exactly, the function itself ran out of time. Look for the last log lines before the timeout to see what the function was executing when the clock ran out.
For distributed tracing of downstream dependency timeouts, use X-Ray or an OpenTelemetry SDK to add spans around each outbound HTTP call. Spans capture exact timing per downstream request and make it immediately obvious which dependency is slow when multiple external API calls run in parallel. The /tools/cors-tester and /tools/http-request-builder tools on ToolDock let you check if a specific API endpoint is currently responding within acceptable time, independently of your application code.
Setting timeouts that actually work
For native Node.js fetch (Node.js 18+) or undici, use AbortController with a cleared timer in a finally block. This pattern is the most composable because the controller can also be triggered by user cancellation, component unmount in React, or a parent timeout higher in the call stack. Pass the signal into every fetch call that should respect the timeout. When using undici directly, prefer its headersTimeout and bodyTimeout options over a single AbortController for fine-grained phase control.
For Axios in Node.js, the timeout option sets an inactivity timeout via socket.setTimeout(), not a total request duration. A request that trickles data slowly can reset the inactivity timer indefinitely and never trigger the Axios timeout. For strict total-duration enforcement with Axios, combine an AbortController signal with the Axios timeout option. Both mechanisms run in parallel and the one that fires first cancels the request.
For AWS Lambda with API Gateway, design handlers to complete within 25 seconds to leave a 4-second buffer before the 29-second hard limit. Use Promise.race() to compete a real response against a timeout that returns a meaningful error payload — the function returns before API Gateway terminates it, so the client receives a structured error instead of a 504 with an empty body. Set individual downstream API timeouts to 10-15 seconds to leave room for at least one retry cycle.
For database query timeouts, set them at the database client level separately from HTTP timeouts. Prisma supports query timeouts via the queryTimeout option. The pg package supports query_timeout in the pool configuration. These database timeouts must be shorter than the HTTP response timeout — if the HTTP timeout is 10 seconds and the database timeout is also 10 seconds, there is no time left to construct a proper error response. A rule of thumb: database timeout should be 70-80% of the HTTP timeout budget.
For services that experience repeated timeouts from a specific upstream, implement a circuit breaker using the opossum npm package. After a configurable number of consecutive failures, the circuit opens and immediately rejects requests to that dependency for a cooldown period. This prevents timeout accumulation where one slow dependency degrades the entire application by filling the Event Loop with hanging promises.
Timeout behavior in serverless and edge runtimes
Cloudflare Workers measure CPU time rather than wall-clock time. A Worker that makes five sequential outbound fetch calls might take 500ms of wall-clock time but only 2ms of CPU time — the CPU budget is consumed only by JavaScript execution, not by waiting for I/O responses. Workers can call slow APIs without hitting the CPU limit, but computation-heavy Workers can hit the limit even on fast networks. Do not use CPU time limits as a mental model for timeout behavior on I/O-heavy workloads.
Vercel serverless functions have a default timeout of 10 seconds on the Hobby plan and 60 seconds on the Pro plan. The timeout applies to total execution time. If your function makes two sequential 6-second API calls, it will timeout on the second call even though each individual call is individually reasonable. Use Promise.all() for independent parallel calls to reduce total wall-clock time and fit within the platform timeout.
Keep-alive connections pool TCP connections for reuse across requests. When a pooled connection is idle and the server closes it, Node.js does not immediately detect the closure — it only discovers the dead connection when it tries to use that socket for the next request. This appears as an ECONNRESET error that superficially resembles a timeout but is actually a stale connection. Setting keepAlive on the http.Agent and configuring a low idleTimeout flushes dead connections before they produce unexpected errors.
The Retry-After response header, returned by APIs on 429 or 503 responses, specifies how long to wait before retrying. It can be a number of seconds or an HTTP date string. Parsing it correctly requires handling both formats: use parseInt() for numeric values and Date.parse() for date strings, then compare against Date.now(). When an API sends Retry-After, respect the value rather than immediately retrying — ignoring it can cause the server to extend its backoff window and ban the client IP.
Node.js 18+ global fetch with a URL that follows a redirect does not always inherit the AbortController signal timeout across the redirect boundary consistently across minor versions. If a redirected URL is slow and the original timeout fires between the initial request and the final response, the AbortError is thrown. Test this behavior explicitly in your Node.js version because the fetch spec behavior for signal inheritance through redirects changed across minor versions.
Timeout mistakes that are hard to spot
Calling req.setTimeout() and assuming the socket closes is the most common Node.js timeout bug. The timeout event is purely informational — Node.js does not automatically destroy the socket when it fires. Developers add req.setTimeout(5000, callback) and assume the request will be cancelled after 5 seconds, but without calling req.destroy() inside the callback, the request continues indefinitely. This bug is particularly subtle because in testing with fast local servers, the timeout never fires and the missing destroy() call is never exercised.
Cleaning up AbortController timers only in the catch block rather than in a finally block means successful requests leave behind active timers. The timer fires after the request completes and calls controller.abort() on a controller whose signal is attached to nothing — unless the same controller instance is reused for a subsequent request, in which case it aborts that request instead. Always use try/finally to guarantee clearTimeout runs regardless of whether the request succeeded, failed, or was cancelled.
Setting the same timeout value for all operations in an application leads to either overly strict timeouts on legitimately slow operations or overly lenient timeouts on simple reads. Profile actual API endpoint response times and set timeouts that reflect the 99th percentile response time plus a reasonable buffer. A 5-second timeout is appropriate for a user profile fetch; a 30-second timeout is appropriate for a report generation endpoint that runs complex aggregation queries.
Not accounting for connection pool wait time is a subtle source of apparent timeouts that are actually client-side queuing. If your http.Agent has maxSockets set to 5 and six requests arrive simultaneously, the sixth request waits in a queue until a socket frees up. This queue wait counts against the request's total time but has nothing to do with network or server speed. Increase maxSockets or use undici's connection pooling configuration with higher concurrency limits to reduce queuing delays under load.
Forgetttng that AbortController signals are one-use is a common source of confusing immediate request failures. Once a signal is aborted, it stays aborted permanently. If you create one controller, use it for a request, that request completes or times out, and then you pass the same signal to a new request — the new request starts in an already-aborted state and fails immediately. Always create a fresh AbortController for each new request.
Timeout architecture for resilient Node.js services
Define timeout budgets at the service boundary and cascade them downward. If an incoming HTTP request has a 10-second budget, allocate specific portions: 6 seconds for the primary database query, 2 seconds for a cache lookup, 1 second for any external enrichment API call, and 1 second for response serialization. Each downstream call gets an AbortController signal derived from the remaining budget, not a static value. This prevents the sum of individual timeouts from exceeding the total service boundary timeout that the calling client is waiting for.
Implement a circuit breaker for every external dependency. The circuit breaker tracks failure rate over a rolling window. When the failure rate exceeds a threshold — say, 50% over the last 30 seconds — the circuit opens and all new requests to that dependency fail immediately with a clear error rather than accumulating as hanging promises. The circuit closes again automatically after a cooldown period and lets through a test request to see if the dependency recovered. Use opossum for a production-tested Node.js circuit breaker implementation.
Log every request timeout with enough context for diagnosis: the URL, the timeout duration, how long the request had already been running when cancelled, current server memory usage, and the count of in-flight requests to that host. When reviewing production logs, patterns in this data reveal whether a timeout is caused by a slow dependency, connection pool exhaustion, or server-side load shedding. Without this context, timeout logs are indistinguishable noise from one another.
Use the /tools/http-request-builder on ToolDock to establish a baseline for how long specific API endpoints take to respond under normal conditions. Save the measurements and compare them over time — if an endpoint that used to respond in 300ms now takes 800ms consistently, that signals a need to investigate the upstream API before it starts causing timeout failures at scale.
For Node.js applications deployed behind API Gateway or a load balancer, set your application-level timeout 10-20% shorter than the infrastructure timeout. This ensures your application returns a properly formatted error response before the infrastructure terminates the connection, giving clients a meaningful error body instead of an abruptly cut TCP stream. The infrastructure timeout should act as an emergency safety net, not the primary timeout mechanism your application relies on.
Quick fix checklist
- ✓Verify req.destroy() is called inside the setTimeout callback — req.setTimeout alone does not close the socket
- ✓For native fetch, use AbortController with clearTimeout in a finally block to prevent post-success abort
- ✓Check AWS API Gateway timeout limit (29-second hard limit) separately from Lambda function timeout
- ✓Use undici headersTimeout and bodyTimeout for independent per-phase timeout control
- ✓Confirm Axios timeout value is in milliseconds — a value of 5 means 5ms, not 5 seconds
- ✓Add circuit breaker logic for any downstream dependency with repeated timeout failures
- ✓Log timeout events with URL, configured timeout duration, actual elapsed time, and current in-flight request count
- ✓Test timeout behavior by routing requests through a proxy that adds artificial latency to confirm the timeout fires correctly
Related guides
Frequently asked questions
Why does my Node.js request hang forever even after I set a timeout?
Most likely you used req.setTimeout() but forgot to call req.destroy() inside the callback. The timeout event in Node.js is purely informational — it signals that no data has arrived for the specified duration but does not close the socket automatically. You must call req.destroy(new Error('timeout')) to actually terminate the connection. Without destroy(), the request continues waiting indefinitely after the timeout event fires, leaving a stuck promise.
What is the difference between ETIMEDOUT and ECONNABORTED in Axios?
ETIMEDOUT is a system-level code thrown when a TCP connection attempt exceeds the socket timeout waiting for the handshake — the server likely has a firewall that silently drops packets. ECONNABORTED is Axios-specific, thrown when the Axios timeout option is exceeded after a connection was already established. ECONNABORTED means the connection succeeded but the server took too long to respond within the configured timeout window.
How do I set a timeout for fetch in Node.js 18?
Use AbortController with setTimeout and pass the signal to fetch: fetch(url, { signal: controller.signal }). Store the setTimeout ID and call clearTimeout in a finally block to prevent the timer from firing after a successful response. Catch AbortError in your error handler to distinguish timeout cancellation from other network failures. This same pattern works in browsers, Node.js 18+, and Deno.
Why does my AWS Lambda timeout at 29 seconds even though I configured a 5-minute timeout?
API Gateway enforces its own 29-second integration timeout independently of the Lambda function timeout — this is a hard limit that cannot be increased on HTTP APIs or REST APIs with proxy integration. To handle long-running work, accept the request and immediately return 202 Accepted, process asynchronously in the background, and deliver results through a webhook or a polling endpoint.
What is a circuit breaker and when should I use one?
A circuit breaker tracks failure rates for a downstream dependency and stops sending requests for a cooldown period after a failure threshold is exceeded. Instead of each request hanging until timeout, the circuit breaker fails immediately. Use it for any external dependency your service calls — third-party APIs, payment processors, databases. Without a circuit breaker, a slow dependency fills the Event Loop with hanging promises and degrades the entire service.
Does node-fetch v3 support a timeout option?
No. node-fetch v3 removed the timeout option that existed in v2. To add a timeout, create an AbortController, set a setTimeout that calls controller.abort(), and pass the signal to the fetch call: fetch(url, { signal: controller.signal }). node-fetch v3 accepts standard AbortSignal, making the pattern identical to native Node.js 18 fetch. Clear the timer in a finally block to prevent post-success abort.
How should I handle the Retry-After header when a request times out?
Parse the Retry-After header: if it is a number, wait that many seconds. If it is an HTTP date string, calculate the seconds until that time. Use setTimeout to delay the retry by the specified duration. Do not retry immediately when a server sends Retry-After — ignoring the header can cause the server to extend its backoff window. Cap the maximum wait to 60 seconds and enforce a maximum retry count to prevent unbounded delays.
Can I use Promise.race() to implement a timeout for fetch?
Yes, but with an important caveat. Promise.race() resolves with the first settled promise, so racing a fetch against a timer works. However, the losing promise continues running in the background — if the timer wins and the fetch is still in flight, the fetch continues until the server responds or an error occurs. Use AbortController alongside Promise.race() to explicitly cancel the fetch when the timer wins, freeing the socket and preventing background resource use.
All tools run in your browser. Your data never leaves your device. Last updated: 2026-05-06.