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.
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 serversV8'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:
- Hundreds of unread
ReadableStreambuffers sit in memory waiting for GC. - Underlying sockets stay occupied or get destroyed instead of returning cleanly to the keep-alive pool.
- 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 includessignal: AbortSignal.timeout(ms)(typically2000–8000ms for synchronous user-facing requests). - [ ] Combine caller cancellation signals with timeouts using
AbortSignal.any([callerSignal, AbortSignal.timeout(ms)]). - [ ] Every
!response.okbranch either readsawait response.text()or callsawait response.body?.cancel()before throwing. - [ ] Any fire-and-forget request that only inspects status codes or headers cancels
response.bodyin afinallyblock. - [ ] Distinguish between
err.name === 'TimeoutError'(deadline exceeded) anderr.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
- Node.js / Undici Documentation: Garbage Collection & Unconsumed Response Bodies
- MDN Web Docs: AbortSignal.timeout() Static Method
- MDN Web Docs: AbortSignal.any() Static Method
- Node.js Official Documentation: Global fetch() API
Connect with Me
If you found this article helpful, let's connect!
- 💻 GitHub: johnnylemonny

