DEV Community

Cover image for Stop Using Default Node.js fetch() in Production Without These Two Fixes
𝗝𝗼𝗡𝗻
𝗝𝗼𝗡𝗻

Posted on AI-assisted

Stop Using Default Node.js fetch() in Production Without These Two Fixes

Comments reveal hidden socket retention issues

When native fetch() became stable in Node.js, engineering teams celebrated. We finally had a standard, zero-dependency HTTP client built directly into the runtime, powered by Node's fast undici engine. Thousands of teams ripped out axios and node-fetch and replaced them with clean, standard await fetch(url) calls.

Then the production incidents started.

If you copy-paste browser-style fetch() calls into a backend Node.js service, two misleading defaults will eventually take your service down under load: a 5-minute default timeout and lazy garbage collection on unconsumed response streams.

Let's look at why a 6-line fetch() function that passes every code review can silently starve your production server of sockets and memoryβ€”and the exact wrapper that fixes both.


The Code That Looks Completely Fine (Until Traffic Spikes)

Here is how 90% of developers write an outbound API call in Node.js today:

// ❌ Looks clean in code review, dangerous in production
export async function fetchUserProfile(userId: string) {
  const response = await fetch(`https://api.partner-service.com/v1/users/${userId}`, {
    headers: { Authorization: `Bearer ${process.env.API_TOKEN}` },
  });

  if (!response.ok) {
    throw new Error(`Partner API failed with status ${response.status}`);
  }

  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

In local development and staging, this code works flawlessly. In production, when api.partner-service.com experiences a brownout or starts returning 502 Bad Gateway html pages at 200 requests per second, this function triggers a cascading outage in your Node.js process.

Here is why.


Trap #1: The 300-Second (5-Minute) Default Timeout

How long does await fetch(url) wait if the upstream server accepts the TCP connection or TLS handshake, and then simply hangs without sending response headers or finishes the body slowly?

In Node.js, native fetch() follows the Chromium/Undici default: 300,000 milliseconds (5 minutes) for both headersTimeout and bodyTimeout.


The 5-Minute Trap: Without an explicit AbortSignal, a single degraded third-party API will hold your Node.js request handler, database transaction, or serverless execution open for up to 300 seconds before throwing a HeadersTimeoutError.

Think about the math on a backend service handling just 50 requests per second:

  • An upstream webhook or payment provider starts hanging.
  • None of your fetch() calls fail quickly; instead, 50 new promises pile up every single second.
  • Within 10 seconds, you have 500 concurrent in-flight requests holding memory closures, open file descriptors, and often database pool connections waiting for fetch() to settle.
  • Your container hits an Out-Of-Memory (OOM) kill or your upstream load balancer starts dropping health checks long before the 300-second default timeout ever fires.

Trap #2: Throwing on !response.ok Without Draining the Body

Look again at the error branch in our original snippet:

if (!response.ok) {
  // ❌ We throw immediately without reading or canceling response.body!
  throw new Error(`Partner API failed with status ${response.status}`);
}
Enter fullscreen mode Exit fullscreen mode

When await fetch() resolves, Node.js has only parsed the HTTP status line and response headers. The underlying TCP socket is still streaming or buffering the response body (for example, an HTML error page from Cloudflare or a verbose JSON error payload from an upstream microservice) through a Web ReadableStream.

If you neither consume the body (await response.text() / await response.json()) nor explicitly cancel it (await response.body?.cancel()), what happens to the underlying HTTP/1.1 keep-alive socket in Undici's connection pool?

According to the official Undici documentation, the socket cannot be cleanly reused immediately. Instead, Undici must wait for V8's garbage collector to eventually run a finalizer on the unreferenced response object before tearing down or reclaiming the stream.

Why V8 Garbage Collection makes unconsumed bodies worse on large servers
V8's garbage collector is intentionally lazy when a Node.js container has plenty of free RAM. If your container has a 2 GB memory limit and your heap is only at 300 MB, V8 feels no pressure to run a major GC cycle.

Meanwhile, if an upstream service starts returning 500 Internal Server Error or 429 Too Many Requests at high volume and your code throws without consuming or canceling response.body:

  1. Hundreds of unread ReadableStream buffers sit in memory waiting for GC.
  2. Underlying sockets stay occupied or get destroyed instead of returning cleanly to the keep-alive pool.
  3. Your service experiences sudden RSS memory climbs and socket exhaustion (UND_ERR_CONNECT_TIMEOUT) just when you need resilience most.


The Fix: Always Enforce a Deadline and Always Drain or Cancel the Stream

You don't need to reinstall axios to fix this. Native Node.js provides everything you need via AbortSignal.timeout() and explicit stream disposal.

1. Use AbortSignal.timeout(ms) on Every Call

Added natively to Node.js and modern runtimes, AbortSignal.timeout(ms) automatically aborts the fetch() request and rejects with a TimeoutError (DOMException) if the entire request-response cycle (including reading the body!) exceeds your deadline.

2. Consume or Cancel response.body on Non-2xx Responses

Whenever response.ok is false, either read the error payload with await response.text() (which both drains the socket for immediate keep-alive reuse and gives you diagnostic context) or call await response.body?.cancel() if you don't care about the error body.

Here is a drop-in, production-hardened safeFetchJson wrapper you can use across any Node.js service:

// lib/http.ts
export interface SafeFetchOptions extends RequestInit {
  timeoutMs?: number;
}

export class HttpResponseError extends Error {
  constructor(
    public readonly status: number,
    public readonly url: string,
    public readonly bodyPreview: string
  ) {
    super(`HTTP ${status} from ${url}: ${bodyPreview}`);
    this.name = 'HttpResponseError';
  }
}

export async function safeFetchJson<T>(
  url: string,
  options: SafeFetchOptions = {}
): Promise<T> {
  const { timeoutMs = 5000, signal: callerSignal, ...fetchOptions } = options;

  // 1. Combine a strict timeout deadline with any caller-provided AbortSignal
  const timeoutSignal = AbortSignal.timeout(timeoutMs);
  const signal = callerSignal
    ? AbortSignal.any([callerSignal, timeoutSignal])
    : timeoutSignal;

  const response = await fetch(url, {
    ...fetchOptions,
    signal,
  });

  // 2. Always drain or cancel the body stream on error statuses
  if (!response.ok) {
    let errorPreview = '';
    try {
      const rawText = await response.text();
      errorPreview = rawText.slice(0, 256); // Keep logs bounded
    } catch {
      await response.body?.cancel().catch(() => {});
    }
    throw new HttpResponseError(response.status, url, errorPreview);
  }

  // 3. Consuming .json() drains the stream and releases the keep-alive socket
  return (await response.json()) as T;
}
Enter fullscreen mode Exit fullscreen mode

Bonus: What If You Only Need Headers (HEAD or Status Check)?

If you intentionally call fetch(url) only to inspect response.status or response.headers.get('content-length') and never plan to read the body, always cancel the body immediately in a finally block:

export async function checkEndpointReachable(url: string): Promise<boolean> {
  const response = await fetch(url, {
    method: 'GET',
    signal: AbortSignal.timeout(3000),
  });

  try {
    return response.ok;
  } finally {
    // βœ… Immediately release the underlying Undici socket without waiting for V8 GC
    await response.body?.cancel().catch(() => {});
  }
}
Enter fullscreen mode Exit fullscreen mode

Read the Official Node.js Undici Garbage Collection and Socket Reuse Guide


Production fetch() Resilience Checklist

Before deploying native fetch() to production in Node.js, verify these five items:

  • [ ] Every outbound fetch() call includes signal: AbortSignal.timeout(ms) (typically 2000–8000 ms for synchronous user-facing requests).
  • [ ] Combine caller cancellation signals with timeouts using AbortSignal.any([callerSignal, AbortSignal.timeout(ms)]).
  • [ ] Every !response.ok branch either reads await response.text() or calls await response.body?.cancel() before throwing.
  • [ ] Any fire-and-forget request that only inspects status codes or headers cancels response.body in a finally block.
  • [ ] Distinguish between err.name === 'TimeoutError' (deadline exceeded) and err.name === 'AbortError' (caller canceled) in your error monitoring logs.

The Takeaway

Native fetch() in Node.js was designed for spec compliance with browsersβ€”not for defensive backend resilience out of the box. In a browser tab, waiting 300 seconds or letting garbage collection clean up an abandoned stream rarely hurts anyone. In a high-throughput Node.js server, those same defaults turn minor upstream hiccups into full service outages.

Wrap your fetch() calls once: enforce an AbortSignal.timeout() on every request, and never leave a response stream unconsumed when throwing an error.

Have you migrated your Node.js services to native fetch(), or do you still prefer a wrapper library like ky or got for built-in timeouts and retries?


Sources & Further Reading


Connect with Me

If you found this article helpful, let's connect!

Top comments (6)

Collapse
 
nyaomaru profile image
nyaomaru •

Really useful article! 😸

I use APIs generated with hey-api together with TanStack Query, and this reminded me how important it is to actually forward the signal from the query function:

queryFn: ({ signal }) =>
  getUser({ signal })
Enter fullscreen mode Exit fullscreen mode

If that signal never reaches the underlying fetch(), cancellation can’t propagate properly.

And of course, that’s separate from adding an explicit timeout and handling response bodies correctly.

Lots of small details to remember with fetch() in production. 😹

Collapse
 
johnnylemonny profile image
𝗝𝗼𝗡𝗻 •

Great point, @nyaomaru! 🐱 Forwarding the caller's signal is so easy to overlook when using query libraries or generated SDKs.

And the nicest part about modern Node/browsers is that you can combine both using AbortSignal.any([callerSignal, AbortSignal.timeout(5000)]) - so you get clean cancellation propagation from TanStack Query plus a hard safety deadline at the fetch() layer!

Collapse
 
maxbuildsdev profile image
Max Kaya •

Solid writeup. One more behavior worth covering: AbortSignal.timeout() cancels the fetch, but the undici socket underneath doesn't always release right away β€” we saw socket retention linger after the signal fired under bursty low-concurrency loads. Capping the undici Agent itself (dispatcher option) instead of relying only on per-request signals stopped that for us. Also the TimeoutError vs AbortError distinction is underrated; mixing them up in alerting makes dead upstreams look like client-side cancels. Did you measure connection pool retention before/after, or only request-level latency?

Collapse
 
johnnylemonny profile image
𝗝𝗼𝗡𝗻 •

Spot on about tuning the underlying Undici Agent, Max. Per-request AbortSignal.timeout() is the quickest safeguard, but configuring a custom Agent (headersTimeout, bodyTimeout, and keepAliveTimeout) via dispatcher or setGlobalDispatcher gives much tighter control over socket lifecycle.

To answer your question: We tracked both! Request-level p99 latency was the initial symptom, but watching undici:Pool stats (connected, free, pending, and running sockets) alongside open file descriptors was what actually revealed the socket retention before and after draining the bodies and capping timeouts.

Collapse
 
paullovelace profile image
Paul Lovelace •

The undrained-body trap is the one I'd never thought about β€” I always assumed throwing on a failed response was the safe, clean move, and it's exactly what leaves the socket hanging until the garbage collector feels like it. The 50 requests-per-second maths made it real for me: 500 in-flight promises in ten seconds, long before any timeout fires. Do you set the same 5-second deadline everywhere, or do you vary it by upstream β€” payment calls versus internal services, say?

Collapse
 
johnnylemonny profile image
𝗝𝗼𝗡𝗻 •

Throwing immediately on !res.ok feels like the cleanest textbook code until you see what it does to the connection pool under load!

For deadlines, I definitely vary them by upstream tier rather than using a flat 5s everywhere:

  • Internal services / cache lookups: 1s–2s (fail fast so the caller can fallback or retry)
  • Standard third-party APIs: 3s–5s
  • Payment gateways / webhooks / LLM streams: 10s–30s (with strict idempotency keys so a timeout doesn't trigger an accidental double-charge on retry)