Since Node.js v18 introduced a native fetch implementation powered by Undici, many engineering teams have transitioned away from third-party HTTP client libraries like Axios, node-fetch, or Request. While native fetch simplifies dependencies, it introduces subtle stream management requirements that differ from older node modules.
A common pattern in application code is checking the response HTTP status code and exiting early if the request failed or returned a status outside the 200 range. When developers return early without reading or explicitly closing the response stream, memory usage steadily climbs under high request volumes.
Understanding Undici Streams and Native Fetch
Native fetch in Node.js is built on top of Web Streams standards. Every Fetch Response object wraps a ReadableStream instance connected to an internal C++ network stream handler. When an HTTP response headers frame arrives, Node.js constructs the Response object and begins buffering incoming payload data into memory.
Stream Abstraction
Native fetch wraps responses in Web Streams standards backed by low-level C++ socket bindings.
Buffer Allocation
Incoming chunk buffers remain allocated in memory until the stream reaches end-of-file or receives an explicit cancel signal.
Checking response.ok and returning early without consuming or cancelling the stream leaves socket buffers active in heap memory.
Diagnosing Heap Memory Growth Under Traffic
In high-throughput microservices, this pattern causes gradual memory growth that is notoriously difficult to reproduce in local development. When building web applications, integration test runs rarely execute enough HTTP requests in sequence for memory pressure to trigger process crashes.
- › Steady RSS and heap allocation growth despite flat request concurrency
- › High memory allocation in undici internal stream queues not freed by garbage collection
- › Socket exhaustion and intermittent ETIMEDOUT errors on downstream services
- › Process memory returning to baseline levels only after service restarts
Fixing the Leak by Draining or Cancelling Response Streams
To prevent memory leaks, you must explicitly signal to the underlying stream controller that the response payload is no longer needed. You can accomplish this either by consuming the body completely or by calling response.body.cancel().
When auditing production infrastructure for cloud infrastructure projects at Storm Code, we recommend calling response.body.cancel() whenever response body content is intentionally ignored, as it avoids reading unneeded bytes off the network socket.
Implementing Safe Fetch Wrappers for Microservices
Relying on individual developers to remember response.body.cancel() across every API endpoint call is error-prone. Standardizing internal HTTP clients around a resilient fetch wrapper guarantees that all unread response streams are garbage collected properly.
- Always consume or explicitly cancel response.body when response.ok is false
- Use AbortController signals to enforce strict timeout boundaries on outbound requests
- Centralize fetch logic into shared utility functions or API clients
- Monitor Node.js RSS memory alongside V8 heap usage statistics in production
Ensuring every native fetch call drains or cancels unneeded response streams eliminates unexpected heap accumulation and ensures predictable memory stability across your Node.js services.