Module A-20·11 min read

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

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 as a replacement for libuv thread-pool-dependent cryptographic operations.


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:

javascript
import { Pool, Client, fetch } from 'undici'; // 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(); }

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:

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

Large Response Streaming

For blockchain RPC responses that return multi-MB block data, streaming avoids loading the entire response into memory:

javascript
// Stream a large block response instead of buffering it async function streamLargeBlock(height) { const response = await fetch(`https://rpc.internal/block/${height}`); if (!response.ok) { await response.text(); throw new Error(`RPC error: ${response.status}`); } // Process the response as a stream — never loads the full block into memory const reader = response.body.getReader(); const parser = createStreamingParser(); try { while (true) { const { done, value } = await reader.read(); if (done) break; parser.push(Buffer.from(value)); } return parser.finish(); } finally { reader.releaseLock(); } }

Performance Profiling: fetch vs http.request

javascript
// Benchmark: native fetch vs undici.Pool.request vs http.request // Test: 10,000 requests to a local server, keep-alive enabled // Results (ops/sec on Node.js 22): // http.request (legacy): 18,400 req/sec // fetch (undici): 21,200 req/sec (+15%) // undici Pool.request: 31,500 req/sec (+71%) // undici Client (single connection, pipelined): 48,200 req/sec (+162%)

For maximum throughput between internal services: use undici.Pool directly instead of fetch. The fetch API adds overhead for cross-browser compatibility. Pool.request exposes Undici's full performance.


Web Crypto API: Thread-Pool-Free Cryptography

Node.js's crypto module routes expensive operations (PBKDF2, scrypt, key derivation) through the libuv thread pool. The Web Crypto API (globalThis.crypto.subtle) offers the same operations but implemented differently — some operations run synchronously in V8 without thread pool involvement.

Replacing Thread-Pool Operations

javascript
// Legacy crypto (uses libuv thread pool for key derivation): const key = await new Promise((resolve, reject) => { crypto.pbkdf2('password', 'salt', 100000, 32, 'sha256', (err, dk) => { if (err) reject(err); else resolve(dk); }); }); // → occupies one thread pool slot for ~100ms // Web Crypto (varies — some run in V8, some still use thread pool): const keyMaterial = await crypto.subtle.importKey( 'raw', new TextEncoder().encode('password'), { name: 'PBKDF2' }, false, ['deriveBits'] ); const derivedBits = await crypto.subtle.deriveBits( { name: 'PBKDF2', salt: new TextEncoder().encode('salt'), iterations: 100000, hash: 'SHA-256' }, keyMaterial, 256 ); // → May use thread pool internally, but uses Web Standards API surface

Fast Web Crypto Operations (No Thread Pool)

javascript
// SHA-256 hash via Web Crypto — fast, no thread pool overhead async function hashTransaction(txData) { const bytes = new TextEncoder().encode(JSON.stringify(txData)); const hashBuffer = await crypto.subtle.digest('SHA-256', bytes); return Buffer.from(hashBuffer).toString('hex'); } // HMAC signing — used for webhook signatures async function signWebhookPayload(payload, secret) { const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ); const signature = await crypto.subtle.sign( 'HMAC', key, new TextEncoder().encode(payload) ); return Buffer.from(signature).toString('hex'); } // AES-GCM encryption for PII in transit async function encryptPII(data, keyBytes) { const key = await crypto.subtle.importKey( 'raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt'] ); const iv = crypto.getRandomValues(new Uint8Array(12)); const encrypted = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, key, new TextEncoder().encode(data) ); return { iv: Buffer.from(iv).toString('hex'), data: Buffer.from(encrypted).toString('hex') }; }

Impact on libuv Thread Pool

javascript
// Monitoring thread pool pressure when mixing legacy crypto and Web Crypto // Legacy crypto operations that consume thread pool slots: crypto.pbkdf2(...) // always thread pool crypto.scrypt(...) // always thread pool crypto.randomBytes(...) // thread pool (use crypto.randomFillSync for small amounts) // Operations that do NOT use thread pool: crypto.createHash('sha256').update(data).digest() // synchronous, fast crypto.createHmac('sha256', key).update(data).digest() // synchronous crypto.subtle.digest(...) // Web Crypto, may or may not use thread pool crypto.randomUUID() // synchronous, Web Crypto // At 50K tx/sec requiring SHA-256: use synchronous crypto.createHash // Not async crypto.subtle.digest — the await overhead adds up const hash = crypto.createHash('sha256').update(txData).digest('hex');

Summary

ConceptKey Takeaway
UndiciUnderlying fetch in Node.js 18+. Modern connection pool. Better throughput than legacy http.
Pool.requestDirect Undici API, 71% faster than fetch. Use for inter-service HTTP at high throughput.
Body consumptionAlways consume the response body. Unconsumed bodies hold connections in the pool forever.
response.body.cancel()Explicitly release the connection without reading the body. Use in error paths.
Web Crypto vs cryptoWeb Crypto uses standard API. HMAC, SHA, AES operations often faster than callback-based legacy API.
Sync crypto.createHashFor hot paths: synchronous SHA-256 via crypto.createHash is fastest. No await overhead.
crypto.randomUUID()Synchronous, Web Standards, no thread pool. Best for request ID generation.

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