Node.js · intermediate
Node.js ETIMEDOUT: identify which network deadline expired
Node.js ETIMEDOUT errors are ambiguous by default: the same error code can be raised by socket connect timeouts, DNS resolution deadlines, HTTP request timeouts, or upstream server keep-alive deadlines. This guide shows engineers how to read the surrounding stack, code, and options object to identify which deadline actually expired before changing any timeout value.
The symptoms
- •Application logs show `Error: connect ETIMEDOUT` or `Error: read ETIMEDOUT` with an `errno: -110` (or platform-equivalent) and a code property of `ETIMEDOUT` thrown from `net`, `http`, `https`, `dns`, or `tls`.
- •The same operation succeeds on retry from a developer machine but fails consistently from a container, VM, or specific egress path, suggesting a deadline was hit rather than a hard refusal.
- •Failures cluster around a numeric value that matches a configured timeout (for example `timeout: 5000`) but the error does not name which library set it.
- •Health checks and unrelated traffic continue to work while a single dependency or single request pattern fails, indicating the deadline is scoped to one code path.
- •Stack trace originates from `node:net`, `node:http`, `node:https`, or `node:dns` internals rather than from a user library, while the originating line in user code calls `socket.setTimeout`, `http.request`, `https.request`, `dns.lookup`, or `dns.resolve*`.
Likely causes
- •A user-set `socket.setTimeout(ms, ...)` or `http.request({ timeout })` fired before the operation completed, and Node translated the timeout into an `ETIMEDOUT` error on the socket.
- •The TCP `connect()` call did not complete within the underlying socket connect deadline (commonly the platform default around 120 seconds on Linux), often because a firewall silently drops packets instead of returning RST.
- •A DNS lookup exceeded its own deadline, surfaced as `ETIMEDOUT` from `node:dns` rather than `ENOTFOUND`, often when a recursive resolver hangs instead of returning NXDOMAIN.
- •An HTTP keep-alive socket was idle too long, the peer closed it, and the next request inherited a socket that was already past its inactivity deadline.
- •Proxy or TLS handshake stages each have implicit deadlines; a slow TLS handshake can surface as `ETIMEDOUT` on the parent request.
First ten minutes
- 01Capture the full error object, not just the message: `err.code`, `err.errno`, `err.syscall`, `err.address`, `err.port`, and the stack's deepest non-internal frame, since these name which boundary raised the deadline.
- 02Search the codebase for every call site near the failing operation that sets a timeout: `setTimeout`, `socket.setTimeout`, `http.Agent({ timeout })`, `keepAlive`, `httpsAgent`, and any client options object with a `timeout` field.
- 03Annotate the failure timestamp and compare it against any retry, circuit-breaker, or backoff schedule to determine whether the deadline is global or per-attempt.
- 04From the affected host, run a low-level reachability check that does not rely on Node: record how long a TCP `connect()` to the target host and port takes versus how long the failing Node call took.
- 05Reproduce the failure with `NODE_DEBUG=net,dns,http` and capture stderr; the trace will show whether the call reached the connect, TLS, or response phase before the deadline fired.
- 06Temporarily isolate the failing path on a single instance with verbose logging on the user-side timeout callback so you can see which library fired the timeout first.
Evidence to collect
- •The thrown `Error` object with `code`, `errno`, `syscall`, `address`, and `port` properties plus the full stack trace.
- •The exact timeout value and which option name was used to set it (for example `http.request({ timeout: 30000 })` versus `socket.setTimeout(30000)`).
- •Timing of the failing call: start time, end time, number of retries, and total elapsed before the error was thrown.
- •Process-level Node version and the version of any HTTP, DNS, or agent library on the failing code path, since default deadlines vary between releases.
- •Network-layer evidence: whether the TCP `connect()` succeeded, whether TLS handshake completed, and whether the first byte of the response was received before the error fired.
- •Whether the failure is correlated with keep-alive socket reuse or only with the first request to a given peer.
Where to look
- •The Node.js errors documentation entry for `ETIMEDOUT` to confirm the operating-system errno mapping and the family of calls that can raise it.
- •The `net` module boundary, specifically `Socket` and `net.connect`, where connect and read deadlines are enforced.
- •The `http` and `https` client boundaries, where request-level `timeout`, agent-level `timeout`, and `keepAliveMsecs` interact.
- •The `dns` module boundary, where `dns.lookup` and `dns.resolve*` apply their own resolver deadlines independently of socket deadlines.
- •The `tls` module boundary, where handshake deadlines can be inherited from the parent socket and surface as `ETIMEDOUT` after partial handshake progress.
- •Process or container environment boundaries: DNS resolver configuration, MTU, egress proxy, and any sidecar that terminates or relays the connection.
Diagnostic steps
- 01Differentiate connect-deadline failures from read-deadline failures by inspecting `err.syscall`: `connect` indicates the TCP handshake did not complete; `read` indicates bytes were expected but none arrived within the deadline.
- 02Compare the elapsed time-to-error against any user-set timeout value; if the elapsed time closely matches a configured `ms`, the deadline is almost certainly user-set rather than platform-default.
- 03Disable user-set timeouts in a single test invocation (by passing a large value or omitting the option) and observe whether the error changes to a deeper, slower failure or disappears; this isolates whether the deadline is the cause or merely the messenger.
- 04Reproduce with `NODE_DEBUG=net` to confirm whether the operation reached `connect` and whether the remote peer ever replied with `SYN-ACK` or `RST`; absence of a reply suggests a silent drop rather than a refusal.
- 05Reproduce with `NODE_DEBUG=http` to see whether the client ever wrote the request; if the request was never written, the deadline fired during connect or DNS, not during response.
- 06Switch the resolver to a known-good one in a single test to determine whether the deadline is being consumed inside the `dns` module before the socket is even created.
- 07For keep-alive paths, test with `keepAlive: false` and `Agent({ keepAliveMsecs: ... })` to determine whether an idle-pool socket caused the inherited deadline.
- 08Record all of the above into a single per-incident note so the chosen fix can be matched to the boundary that actually raised the error.
Common mistakes
- •Raising every timeout value seen in code as a first response, without identifying which specific deadline fired; this masks the real cause and can hide resource exhaustion.
- •Treating `ETIMEDOUT` and `ENOTFOUND` as the same failure; `ENOTFOUND` indicates the resolver returned NXDOMAIN, while `ETIMEDOUT` indicates the resolver never replied in time.
- •Reading only `err.message` and ignoring `err.code`, `err.syscall`, `err.address`, and `err.port`, which together identify the boundary.
- •Assuming a single global `timeout` option controls everything; Node's HTTP client and many third-party clients use distinct options per phase (connect, headers, body, keep-alive).
- •Adding retries on top of a deadline that is too tight without measuring whether the underlying operation ever succeeds when given more time, which can amplify load instead of fixing the failure.
- •Ignoring that the same `ETIMEDOUT` can be raised by the parent socket, the keep-alive agent, and the TLS handshake simultaneously, and that fixing one may simply move the failure to the next stage.
Safe fixes
- •If the evidence shows a user-set timeout is shorter than the operation's normal duration, raise that specific option only after measuring baseline p99 latency for the same call, and add a per-call ceiling so the new value cannot become unbounded.
- •If the evidence shows the TCP connect itself does not complete, address the network path (firewall rule, route, MTU, proxy) rather than increasing timeouts; a connect that takes tens of seconds usually indicates a dropped path, not a slow server.
- •If the evidence shows the deadline fires inside `dns.lookup`, fix the resolver path or add a resolver-level timeout with an explicit fallback, instead of relying on Node's default resolver deadlines.
- •If the evidence shows the failure correlates with keep-alive socket reuse, disable keep-alive for the affected code path or set `keepAliveMsecs` below the peer's idle-close threshold, verified by a second test run.
- •If the evidence shows the deadline fires during TLS handshake, isolate whether the slow stage is certificate validation, OCSP, or SNI by enabling TLS tracing on a single test invocation.
- •After any change, redeploy to a single instance and confirm the new behavior with the same reproducer before rolling out broadly, and keep the previous configuration available for rollback.
Prove the fix
- 01The reproducer that previously raised `ETIMEDOUT` now completes (or raises a different, expected error) within a defined latency budget that has been recorded in the per-incident note.
- 02Application logs for the same code path over a defined observation window show zero `ETIMEDOUT` occurrences with the same `err.syscall`, `err.address`, and `err.port` as the original failure.
- 03If the fix was a timeout increase, the new timeout value is documented in code with the measured p99 plus a margin, and a code-review or policy check rejects unbounded values such as `0`, `Infinity`, or `Number.MAX_SAFE_INTEGER`.
- 04If the fix targeted keep-alive or DNS, a second test run with `NODE_DEBUG=net,dns,http` shows the operation now reaches the next stage (connect completes, DNS resolves, or response is read) instead of timing out at the original stage.
- 05An alert or dashboard query is in place that fires on recurrence of the same `err.code` plus `err.syscall` combination, so a regression is detectable rather than silently absorbed.
Prevention and next steps
- •Adopt a single internal convention for naming timeout options per phase (connect, request, response, keep-alive) and lint for undocumented numeric timeout literals scattered through call sites.
- •Centralize HTTP, DNS, and agent construction so timeout defaults live in one module and are easy to audit; avoid creating `http.Agent` instances ad hoc in business code.
- •Emit structured logs that include `err.code`, `err.syscall`, `err.address`, `err.port`, and elapsed milliseconds on every outbound call failure, so future incidents can be triaged without re-reading source.
- •Track outbound call p50, p95, and p99 latency per dependency and review trend changes before they cross user-set timeout thresholds, since most `ETIMEDOUT` spikes are preceded by a gradual latency rise.
- •Test failure modes in pre-production by injecting latency and connection drops at known boundaries, and assert that the error logged matches the boundary that was injected, not an opaque generic timeout.
Safe commands and checks
NODE_DEBUG=net,dns,http node app.js 2> debug.log # reproduce the failure with low-level traces; inspect which phase logs the connect or write that precedes the error.
node -e \"const e=new Error('x');e.code='ETIMEDOUT';e.syscall='connect';console.log(JSON.stringify({code:e.code,syscall:e.syscall},null,2));\" # print the standard error code and syscall fields used by Node so log parsers can match them.
node --version # record the Node runtime version, since timeout defaults and error mappings differ between major versions.
node -p \"process.versions\" # capture V8, OpenSSL, and libuv versions alongside Node, since they influence TLS and DNS deadlines.
grep -RIn --include='*.js' --include='*.ts' -E \"(setTimeout|http\\.request|https\\.request|new http\\.Agent|dns\\.lookup|dns\\.resolve)\" src/ # locate every outbound-call site that may carry a timeout option near the failing code path.
grep -RIn --include='*.js' --include='*.ts' -E \"timeout\\s*[:=]\\s*[0-9]+\" src/ # enumerate numeric timeout literals so each can be mapped to the phase it controls.
strace -f -e trace=connect,sendto,recvfrom,close -p <pid> 2> net.log # attach to the failing process with a system call tracer using the placeholder <pid>; correlate connect attempts with the time the ETIMEDOUT was raised.
tcpdump -i any -nn -s 0 -w trace.pcap host <remote-ip> and port <port> # capture packets for the failing flow using the placeholder <remote-ip> and <port> to verify whether SYN receives a reply at all.