Module A-20·16 min read

Native fetch socket pool mechanics, Web Crypto vs libuv thread pool, and profiling ReadableStream memory leaks under RPC load.

Written by Jatin Jain Saraf · Architect of SupraScan

Module 19 — The Modern Web Standard Shift: Undici, fetch, and Web Crypto

What this module covers: Node.js 18 shipped native fetch — built on Undici, a from-scratch HTTP/1.1 and HTTP/2 client that replaced the legacy http.request internals. The connection pool model changed. Garbage collection behavior changed. Performance characteristics changed. Engineers who understand http.request semantics may be surprised by subtle differences in how fetch handles keep-alive, connection reuse, and stream consumption. This module covers Undici's architecture, how to avoid ReadableStream memory leaks when consuming large RPC responses, and the Web Crypto API's standard, Promise-native surface for cryptographic operations — which still runs on the same libuv thread pool as legacy crypto, and where that matters for hot-path performance.


Undici: The New HTTP Client Foundation

undici is the HTTP client library underlying Node.js's native fetch. It was written from scratch (unlike the legacy http module which dates to 2009) with modern HTTP semantics, connection pooling, and proper backpressure.

Connection Pool Architecture

Unlike http.request which uses a per-hostname keep-alive agent, Undici uses a Pool or Client with configurable connection counts. Undici's pool is a fleet of taxis outside the RPC node's building: fetch grabs whatever cab is idling, while Pool.request lets you pre-book exactly how many cabs wait there and how long each engine idles between fares.

Client and Pool solve different problems. A Client manages exactly one connection to a single origin — use it when you're deliberately talking to one specific backend instance and want to reason about a single socket's lifecycle (e.g. a sticky connection to one RPC node replica). A Pool manages multiple connections to a single origin and load-balances requests across them — this is what you want for the general case of calling one RPC service at high concurrency, which is why the rest of this module uses Pool.

import { Pool, Client, fetch } from 'undici';
 
// Client: a single connection to one specific host — useful when you need to
// pin requests to one backend instance rather than round-robin across a pool
const client = new Client('https://blockchain-rpc-replica-3.internal', {
  keepAliveTimeout: 60_000,
  connect: { timeout: 5_000 },
});
 
async function getFromPinnedReplica(path) {
  const { statusCode, body } = await client.request({ path, method: 'GET' });
  if (statusCode !== 200) throw new Error(`RPC error: ${statusCode}`);
  return body.json();
}
 
// Explicit pool: control connection count to a specific host
const pool = new Pool('https://blockchain-rpc.internal', {
  connections: 20,             // max simultaneous connections
  pipelining: 10,              // pipeline up to 10 requests per connection
  keepAliveTimeout: 60_000,    // keep connections alive 60s
  connect: {
    timeout: 5_000,            // connection timeout
    keepAlive: true,
  },
});
 
// High-throughput RPC calls over the pool
async function getRpcBlock(height) {
  const { statusCode, body } = await pool.request({
    path: `/api/v1/blocks/${height}`,
    method: 'GET',
    headers: { 'Authorization': `Bearer ${RPC_TOKEN}` },
  });
 
  if (statusCode !== 200) {
    throw new Error(`RPC error: ${statusCode}`);
  }
 
  // IMPORTANT: always consume the body
  return body.json();
}

A caveat on pipelining: 10: HTTP/1.1 pipelining sends multiple requests on a connection before earlier responses arrive, but the responses must still come back in order. Pipelining assumes every queued request will succeed and that the connection never needs a mid-stream fallback. If one pipelined request fails or the connection drops partway through, Undici cannot safely retry or reorder the requests still queued behind it on that connection — they fail too, even though nothing was wrong with them individually. This is why pipelining is not a free 10x: it trades resilience under partial failure for lower per-request overhead, and it's a poor fit for RPC endpoints with any tail latency variance or occasional 5xx responses. Most production Undici pools leave pipelining at the default of 1 (no pipelining) and get their concurrency from connections instead — reach for pipelining only when the upstream is known to be reliable and strictly FIFO-safe.

ReadableStream Memory Leaks: The Critical Mistake

Every fetch response (and Undici response) has a body that is a ReadableStream. If you don't consume the body, the connection is not released back to the pool:

Production story: during a UPI transaction spike, a service's RPC calls started timing out intermittently, and the on-call engineer's first theory was that the upstream RPC provider was down. It wasn't. A health-check probe running every few seconds called fetch(rpcHealthUrl), and on any non-200 response threw immediately without draining the body — exactly the LEAK pattern below. Each failed health check silently held one connection open. The pool was configured for 20 connections; within a few minutes of the RPC node returning intermittent 503s under load, every connection in the pool was pinned to an unconsumed body, and legitimate transaction-verification calls queued behind health checks that would never release their socket. The fix was the same body-draining discipline shown below — but the diagnosis cost 40 minutes because the symptom (RPC timeouts) pointed everyone at the RPC provider instead of the caller's own connection pool.

// LEAK: body not consumed — connection held indefinitely
async function checkHealth(url) {
  const response = await fetch(url);
  if (!response.ok) {
    // BUG: we throw here without reading the body
    // The response body stream is never consumed → connection never released
    throw new Error(`Health check failed: ${response.status}`);
  }
  // We only read body on success — failure path leaks the connection
  return response.json();
}
 
// FIX: always consume the body
async function checkHealth(url) {
  const response = await fetch(url);
  if (!response.ok) {
    // Consume the error body even if we don't need it
    await response.text();  // or response.body?.cancel()
    throw new Error(`Health check failed: ${response.status}`);
  }
  return response.json();
}
// Better: use a try-finally pattern to always clean up
async function safeFetch(url, options) {
  const response = await fetch(url, options);
 
  try {
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${await response.text()}`);
    }
    return await response.json();
  } catch (err) {
    // Ensure body is consumed even on error
    if (response.body && !response.bodyUsed) {
      await response.body.cancel();
    }
    throw err;
  }
}

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.