← Debugging Guides

阅读中文版本

Node.js fetch — UND_ERR_HEADERS_TIMEOUT Against Slow Upstreams

Explain UND_ERR_HEADERS_TIMEOUT from the Node fetch global, measure where response headers stall, and configure undici timeouts per endpoint instead of globally.

intermediatedebuggingnodejsfetchundici

Symptoms

  • TypeError: fetch failed with cause UND_ERR_HEADERS_TIMEOUT after exactly 300 seconds in long-running requests
  • The upstream endpoint legitimately takes minutes to compute before returning headers, and only Node fetch calls fail
  • curl against the same endpoint succeeds while the Node service times out

Possible causes

  • undici applies a default headersTimeout of 300 seconds from request start to first response header, which slow endpoints exceed
  • A proxy chain adds queuing delay before the app server answers, consuming the budget invisibly
  • The endpoint streams a body after a quick 200, but a retry wrapper measures the total time against headersTimeout
  • A global dispatcher was configured for fast APIs and silently applies the same limit to a batch endpoint

Diagnosis steps

Time the phases of the failing call

curl -w "dns=%{time_namelookup} connect=%{time_connect} ttfb=%{time_starttransfer} total=%{time_total}\n" -o /dev/null -s https://slow.example.com/report

Interpretation

A time_starttransfer above 300 seconds confirms the server spends that long before headers, which is exactly what headersTimeout bounds.

Confirm the exact undici defaults in play

node -p "require('undici').Agent.prototype.constructor.name" && node -e "console.log(new (require('undici').Agent)().dispatch)" 2>/dev/null | head -2; node --version

Interpretation

The Node fetch global uses an undici agent with documented defaults: headersTimeout 300s, bodyTimeout 300s. Knowing the defaults explains the exact 300-second failure.

Reproduce with a deliberate delay

node -e "setTimeout(() => console.log('late'), 310000)" & node -e "fetch('http://localhost:9999/').catch(e => console.log(e.cause.code))"

Interpretation

An endpoint that answers after 305 seconds deterministically produces UND_ERR_HEADERS_TIMEOUT, proving the boundary is header arrival, not total duration.

Root cause

The Node fetch global routes through an undici dispatcher whose headersTimeout bounds the wait from request send to first response header. An endpoint whose work legitimately exceeds that window — batch reports, cold caches, long queries — crosses the boundary and the request fails before any body or status exists.

Fix

Give slow endpoints their own dispatcher with a raised headersTimeout and bodyTimeout, prefer architectural fixes (make the job asynchronous and return 202 with a polling URL), and set per-request AbortSignal timeouts as an outer budget so limits stay explicit.

Risk note

Raising timeouts globally lets every endpoint hold connections for minutes, exhausting the pool under load. Scope the change to the endpoints that need it.

Verify the fix

Call the slow endpoint with the scoped dispatcher and confirm completion past the previous boundary; verify fast endpoints still fail at their original timeout, proving the change is scoped.

Symptom walkthrough

The error arrives nested under a generic fetch failure, so unwrap the cause first:

TypeError: fetch failed
  cause: Error: Headers Timeout Error
    code: 'UND_ERR_HEADERS_TIMEOUT'

The timing is the fingerprint: exactly 300 seconds. That is the undici default for headersTimeout, and it bounds send-to-first-header, not total duration. Measure the server-side truth with curl -w and its time_starttransfer field. When that number exceeds 300 seconds, the failure is the client enforcing a policy the endpoint legitimately violates — curl succeeds because it imposes no such limit.

Fix in practice

Scope a dedicated dispatcher for the slow service rather than changing global behavior:

import { Agent, fetch as undiciFetch } from 'undici';

const slowAgent = new Agent({
  headersTimeout: 900_000, // 15 minutes: send -> first header
  bodyTimeout: 900_000,    // header -> last body byte
});

const res = await undiciFetch(url, { dispatcher: slowAgent });

Keep an explicit outer budget per request so limits stay readable:

const res = await undiciFetch(url, {
  dispatcher: slowAgent,
  signal: AbortSignal.timeout(15 * 60_000),
});

When the endpoint can be changed, the stronger fix is architectural: return 202 immediately with a status URL, and poll. That removes the long-held connection instead of accommodating it.

Verification

Re-run the slow call with the scoped dispatcher and confirm it completes beyond the old 300-second wall. Then verify scoping: a fast endpoint with a short AbortSignal.timeout must still fail on schedule, proving the global default was untouched. Finally, watch connection metrics during a batch run — with the 202 pattern, connections should be short-lived and pool occupancy flat, while the raised-timeout path holds a few connections for the full duration by design.