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(); } 

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}`); } 

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; } 

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(() => {}); } } 

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!