Networking HTTP TLS DNS Sockets Undici and Fetch

Reading time
11 min read
Word count
2180 words
Diagram count
1 diagram

Source: Victor Bona's Obsidian Compendium snapshot, Knowledge base/nodejs-v8-runtime-engineering/11 Networking HTTP TLS DNS Sockets Undici and Fetch.md.

Purpose: Field manual for building, tuning, debugging, and operating Node.js networking systems across Node.js V8 Runtime Engineering, raw sockets, UDP, DNS, HTTP, HTTP/2, TLS, Undici, and Fetch, with links forward to 12 Web Platform APIs in Node.js URL Blob Web Streams AbortController and Test Runner and boundary concerns in 13 Native Addons N-API WASM FFI and Embedding.

Networking HTTP TLS DNS Sockets Undici and Fetch

Node.js networking is a set of layered interfaces over libuv, OpenSSL, the operating system resolver, c-ares DNS resolution, HTTP parsers, stream backpressure, and JavaScript cancellation. The runtime gives you a low-friction path from a single fetch() to a custom TCP server, but production systems fail at the seams: DNS lookup behavior, keep-alive pool limits, half-open sockets, TLS trust, HTTP timeout defaults, body buffering, backpressure, proxy handling, and shutdown.

Use this note as a network operations map. Reach for the highest-level API that preserves the control you need, then instrument the layer below it.

Mental model

Rendering diagram...

Node does not make network IO magically safe. It gives you nonblocking handles and event-driven APIs. You still own deadlines, cancellation, circuit breaking, body size limits, DNS policy, socket pool sizing, TLS trust, and graceful close.

API selection

NeedPreferWhyWatch
Ordinary outbound HTTP(S)fetch()Browser-compatible request model, AbortSignal support, powered by Undici in Node.Pool tuning and proxy policy need dispatcher configuration.
High-control outbound HTTP/1.1undici package or node:httpExplicit dispatcher, agents, pipelining, raw headers, socket controls.You own status/body handling and connection lifecycle.
HTTP servernode:http or framework built on itDirect access to request and response streams.Timeouts, body limits, slow clients, keep-alive shutdown.
HTTP/2 service or clientnode:http2Multiplexed sessions, streams, ALPN integration.Session-level flow control and GOAWAY handling.
TCP protocolnode:netStream socket over TCP or IPC.Framing, backpressure, half-open behavior, idle timeouts.
UDP protocolnode:dgramDatagram socket for DNS-like, metrics, discovery, or game traffic.Packet loss, message size, no delivery guarantee.
DNS operationsnode:dns/promisesExplicit resolver methods and record types.lookup() and resolve*() have different implementation paths.
TLS controlsnode:tls and node:httpsCertificates, SNI, ALPN, mTLS, session behavior.Trust store, hostname verification, rotation, protocol selection.

DNS

Node exposes two different DNS families that look similar but behave differently.

APIBacking behaviorUses OS hosts and NSS policyUses libuv threadpoolBest for
dns.lookup()OS getaddrinfo style lookup.YesYesMatching platform behavior, honoring /etc/hosts, ordinary socket connection lookup.
dns.resolve*()DNS query through Node resolver APIs.NoNoExplicit DNS records, avoiding getaddrinfo threadpool pressure, service discovery.
dns.ResolverPer-instance resolver configuration.No for resolve*() methodsNo for network query methodsCustom DNS servers, test isolation, split-horizon lookups.
Socket implicit lookupMany network APIs call dns.lookup() unless a custom lookup is passed.YesYesConvenience, not high-volume custom DNS policy.

Production DNS guidance:

  • Decide whether you need OS semantics or DNS wire queries. They are not substitutes.
  • For high fan-out clients, measure UV_THREADPOOL_SIZE pressure before blaming the remote service.
  • Cache carefully. DNS TTL, negative caching, failover, and load balancer behavior are operational policy, not just speed knobs.
  • For dual-stack hosts, test IPv4, IPv6, and "happy eyeballs" behavior under partial failure.
  • If you pre-resolve and connect by address, preserve the original hostname for TLS SNI and certificate verification.

Example: use a resolver when DNS policy is part of the component contract.

import { Resolver } from 'node:dns/promises';

const resolver = new Resolver();
resolver.setServers(['1.1.1.1', '8.8.8.8']);

export async function resolveApiTargets(name) {
  const records = await resolver.resolve4(name, { ttl: true });
  return records
    .sort((a, b) => a.ttl - b.ttl)
    .map((record) => ({ address: record.address, ttl: record.ttl }));
}

Footguns:

SymptomLikely causeCheck
Random latency spikes before connectdns.lookup() queued behind other threadpool work.Compare lookup timing with resolve4; inspect threadpool users like fs, crypto, zlib.
Service ignores /etc/hosts in testsUsed dns.resolve*() instead of lookup().Replace with lookup() or inject resolver behavior.
TLS fails after pre-resolving addressConnection uses IP as server name.Pass servername for TLS or keep URL hostname in higher-level client.
IPv6-only or IPv4-only incidentsAddress family ordering assumption.Log attempted addresses and failure families.

TCP sockets with node:net

node:net exposes net.Server and net.Socket. TCP is a byte stream, not a message stream. Your application protocol must define framing, max frame size, timeouts, and close semantics.

Core events and methods:

SurfaceMeaningProduction use
net.createServer()Accepts TCP or IPC stream connections.Custom protocols, local agents, sidecars.
net.createConnection()Opens a client socket.Protocol clients, health probes, tunnels.
socket.write()Queues bytes and returns a backpressure signal.Stop writing when it returns false; resume on drain.
socket.setTimeout()Emits timeout after inactivity.Destroy or close idle sockets explicitly.
socket.end()Half-closes writable side after queued data.Graceful protocol close.
socket.destroy()Forcefully tears down socket.Deadline, protocol violation, resource exhaustion.
server.close()Stops accepting new connections.Shutdown path; existing sockets still matter.

Example: length-prefixed echo server with backpressure and idle timeout.

import net from 'node:net';

const MAX_FRAME = 64 * 1024;

function handleSocket(socket) {
  let buffer = Buffer.alloc(0);
  socket.setTimeout(30_000);

  socket.on('data', (chunk) => {
    buffer = Buffer.concat([buffer, chunk]);

    while (buffer.length >= 4) {
      const size = buffer.readUInt32BE(0);
      if (size > MAX_FRAME) {
        socket.destroy(new Error('frame too large'));
        return;
      }
      if (buffer.length < 4 + size) break;

      const payload = buffer.subarray(4, 4 + size);
      buffer = buffer.subarray(4 + size);

      const ok = socket.write(Buffer.concat([Buffer.from([0, 0, 0, payload.length]), payload]));
      if (!ok) socket.pause();
    }
  });

  socket.on('drain', () => socket.resume());
  socket.on('timeout', () => socket.destroy(new Error('idle timeout')));
  socket.on('error', (error) => {
    console.error({ remote: socket.remoteAddress, error: error.code }, 'socket failed');
  });
}

const server = net.createServer(handleSocket);
server.listen(9000, '127.0.0.1');

Socket production checklist:

ConcernRule
FramingNever parse TCP with "one data event equals one message".
MemorySet max frame and max buffered bytes.
BackpressureTreat write() returning false as a real signal.
Idle clientsSet inactivity deadlines and close.
ShutdownTrack accepted sockets so deploy shutdown can stop them.
ErrorsListen for error on every socket and server.
Half-openUnderstand whether the protocol supports one-sided close.

UDP datagrams with node:dgram

UDP gives you datagrams, not streams. Messages can be lost, duplicated, reordered, truncated by path MTU, or blocked by middleboxes. Node's node:dgram is appropriate when the protocol already tolerates datagram behavior.

Use UDP for:

  • Local discovery where loss is acceptable.
  • Metrics firehose where approximate delivery is acceptable.
  • DNS-like request/response protocols.
  • Game or media protocols with explicit sequencing.

Avoid UDP for:

  • Business commands that must be delivered once.
  • Large payloads without fragmentation strategy.
  • Protocols where NAT and firewall behavior are unknown.

Example: bounded UDP receiver.

import dgram from 'node:dgram';

const socket = dgram.createSocket('udp4');
const MAX_PACKET = 1400;

socket.on('message', (message, remote) => {
  if (message.length > MAX_PACKET) {
    console.warn({ remote, length: message.length }, 'dropping oversized datagram');
    return;
  }

  const text = message.toString('utf8');
  console.log({ from: `${remote.address}:${remote.port}`, text });
});

socket.on('error', (error) => {
  console.error({ code: error.code }, 'udp socket failed');
  socket.close();
});

socket.bind(8125, '0.0.0.0');

HTTP/1.1 servers

The node:http server presents request and response streams. Most production bugs are timeout and body-size bugs.

ControlWhy it matters
Header timeoutProtects against slow header attacks.
Request timeoutPrevents endless request body uploads.
Keep-alive timeoutControls idle connection retention.
Max headers count and sizeLimits parser memory pressure.
Body limitPrevents unbounded buffering in application code.
Graceful shutdownStops accepts, drains active responses, closes idle keep-alive sockets.

Example: strict JSON endpoint without framework assumptions.

import http from 'node:http';

const MAX_BODY = 1_000_000;

async function readJson(req) {
  let size = 0;
  const chunks = [];

  for await (const chunk of req) {
    size += chunk.length;
    if (size > MAX_BODY) {
      const error = new Error('body too large');
      error.statusCode = 413;
      throw error;
    }
    chunks.push(chunk);
  }

  return JSON.parse(Buffer.concat(chunks).toString('utf8'));
}

const server = http.createServer(async (req, res) => {
  try {
    if (req.method !== 'POST' || req.url !== '/events') {
      res.writeHead(404).end();
      return;
    }

    const event = await readJson(req);
    res.writeHead(202, { 'content-type': 'application/json' });
    res.end(JSON.stringify({ accepted: true, type: event.type }));
  } catch (error) {
    res.writeHead(error.statusCode ?? 500, { 'content-type': 'application/json' });
    res.end(JSON.stringify({ error: error.message }));
  }
});

server.requestTimeout = 30_000;
server.headersTimeout = 10_000;
server.keepAliveTimeout = 5_000;
server.listen(8080);

HTTP footguns:

FootgunConsequenceSafer practice
Buffering every request bodyMemory exhaustion under slow or large uploads.Stream, cap, or hand off to storage.
Not consuming response bodies in clientsConnection reuse can stall or leak.Consume, cancel, or destroy the body.
Missing error handlersProcess crash or silent connection loss.Attach handlers to request, response, and server.
No timeout strategyHung sockets survive incidents.Set connect, headers, body, idle, and total deadlines.
Assuming header caseHTTP headers are case-insensitive.Normalize at boundaries.

HTTP clients and agents

HTTP connection reuse is a performance feature and a resource risk. Node's http.Agent manages connection pooling for node:http. Undici manages pooling through dispatchers such as Agent, Pool, and Client.

Agent tuning questions:

QuestionWhy
How many origins can this service call?Pool counts multiply by origin.
What is the maximum concurrent request count per origin?Prevents local saturation and remote overload.
Are idle sockets closed on shutdown?Prevents hanging deploys.
Is the remote load balancer idle timeout lower than ours?Avoids reuse of stale sockets.
Are requests idempotent?Determines retry safety after socket reset.

Node http.Agent example:

import { Agent, request } from 'node:https';

const agent = new Agent({
  keepAlive: true,
  maxSockets: 64,
  maxFreeSockets: 16,
  timeout: 30_000,
});

export function closeHttpClient() {
  agent.destroy();
}

export function postJson(url, body) {
  return new Promise((resolve, reject) => {
    const req = request(url, {
      method: 'POST',
      agent,
      headers: { 'content-type': 'application/json' },
    }, (res) => {
      const chunks = [];
      res.on('data', (chunk) => chunks.push(chunk));
      res.on('end', () => resolve({ statusCode: res.statusCode, body: Buffer.concat(chunks) }));
    });

    req.setTimeout(30_000, () => req.destroy(new Error('request timeout')));
    req.on('error', reject);
    req.end(JSON.stringify(body));
  });
}

TLS

TLS in Node is built on OpenSSL. You normally use it through https, fetch, or reverse proxies, but direct node:tls matters for custom protocols, mTLS, ALPN, SNI, and certificate diagnostics.

TLS field guide:

TopicProduction guidance
TrustDo not disable certificate verification in production. Add the correct CA bundle or trust store.
HostnamePreserve the logical hostname for SNI and certificate validation, even if connecting by IP.
mTLSSeparate client certificate rotation from server certificate rotation. Log certificate subject and issuer only where privacy policy permits.
ALPNUse ALPN to negotiate HTTP/2 versus HTTP/1.1; test unsupported protocol behavior.
Session reuseUseful for latency, but verify behavior behind load balancers.
Key loggingUseful for packet debugging, but secrets must be protected and short-lived.

Example: TLS client with explicit CA and server name.

import tls from 'node:tls';
import { readFile } from 'node:fs/promises';

export async function openTlsSocket({ host, address, port }) {
  const ca = await readFile('/etc/my-service/ca.pem');

  return tls.connect({
    host: address,
    port,
    ca,
    servername: host,
    ALPNProtocols: ['h2', 'http/1.1'],
    rejectUnauthorized: true,
  });
}

TLS troubleshooting:

Error or symptomMeaning to investigate
UNABLE_TO_VERIFY_LEAF_SIGNATUREMissing intermediate or wrong CA bundle.
ERR_TLS_CERT_ALTNAME_INVALIDHostname does not match certificate SAN.
Handshake timeoutFirewall, protocol mismatch, client auth requirement, or overloaded peer.
HTTP/2 not selectedALPN not offered, proxy terminates TLS, or server lacks h2 support.
Works with curl -k onlyVerification is broken; do not ship the Node equivalent.

HTTP/2

HTTP/2 multiplexes streams over a session. That shifts the failure model from "one request equals one TCP connection" to "many request streams share session-level flow control and lifecycle".

HTTP/2 production concepts:

ConceptOperational meaning
SessionShared connection state. Failure can affect many streams.
StreamPer-request bidirectional channel. Must be closed or reset.
Flow controlReceiver controls how much data may be in flight.
GOAWAYPeer is draining or closing a session. New streams should move elsewhere.
ALPNTLS negotiation usually decides whether h2 is used.

Example: small HTTP/2 server.

import http2 from 'node:http2';

const server = http2.createServer();

server.on('stream', (stream, headers) => {
  if (headers[':path'] !== '/health') {
    stream.respond({ ':status': 404 });
    stream.end();
    return;
  }

  stream.respond({ ':status': 200, 'content-type': 'application/json' });
  stream.end(JSON.stringify({ ok: true }));
});

server.on('sessionError', (error) => {
  console.error({ code: error.code }, 'http2 session failed');
});

server.listen(8081);

Fetch and Undici

Node's global fetch() is browser-compatible and no longer experimental in modern Node. In Node, it is powered by Undici. The official docs expose a custom dispatcher option; installing undici also lets you set a global dispatcher that affects both Undici and Node's fetch.

Fetch strengths:

  • Simple outbound HTTP(S).
  • Standards-shaped Request, Response, Headers, FormData, Blob, and Web Streams.
  • AbortSignal cancellation.
  • Dispatcher-based control when using Undici-compatible dispatchers.

Fetch differences that matter in services:

Browser habitNode service reality
Let the tab own lifecycleA server process must close pools and abort work during shutdown.
Small user-triggered requestsServices can create thousands of concurrent calls and exhaust pools.
CORS dominatesServer-side fetch is not a browser security boundary.
Body size is user-interface boundedBody size must be explicitly capped.
Proxy is environment-dependentProxy behavior should be configured and tested.

Example: deadline and body cap around fetch.

const MAX_BYTES = 2_000_000;

export async function fetchJson(url, { signal } = {}) {
  const timeout = AbortSignal.timeout(10_000);
  const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;

  const response = await fetch(url, {
    headers: { accept: 'application/json' },
    signal: combined,
  });

  if (!response.ok) {
    await response.body?.cancel();
    throw new Error(`upstream returned ${response.status}`);
  }

  const reader = response.body.getReader();
  let bytes = 0;
  const chunks = [];

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    bytes += value.byteLength;
    if (bytes > MAX_BYTES) {
      await reader.cancel('response too large');
      throw new Error('response too large');
    }
    chunks.push(value);
  }

  const text = new TextDecoder().decode(Buffer.concat(chunks));
  return JSON.parse(text);
}

Undici dispatcher guidance:

NeedDispatcher approach
Per-call routingPass a dispatcher in fetch(url, { dispatcher }).
Process-wide pool policyUse setGlobalDispatcher() from the undici package.
Per-origin tuningUse Agent or Pool with explicit connection and pipelining settings.
TestsInject a dispatcher or use mock agent facilities from Undici.

Deadlines, retries, and cancellation

Every network operation needs a deadline. A timeout is not only a client concern; servers also need timeouts for headers, body upload, idle keep-alive, downstream calls, graceful shutdown, and forced termination.

Timeout taxonomy:

TimeoutApplies toFailure handling
DNS lookupHost resolution.Retry only if resolver policy says transient.
ConnectTCP or TLS establishment.Retry idempotent operations on another endpoint.
TLS handshakeSecure negotiation.Treat certificate errors as configuration, not transient.
HeadersWaiting for response headers.Circuit-break slow upstreams.
BodyStreaming response or upload.Abort and classify as partial IO.
IdleNo traffic on established socket.Close socket.
TotalEnd-to-end operation.Abort all nested work.

Retry checklist:

  • Retry only idempotent operations by default.
  • Add jittered backoff.
  • Cap total elapsed time.
  • Classify DNS, connect, TLS, HTTP status, and body errors separately.
  • Preserve trace context and idempotency keys.
  • Avoid retrying after partial request body write unless the protocol guarantees safety.

Observability

Minimum network telemetry:

SignalLabels
Outbound request countmethod, origin, route class, status class, error class.
Request durationDNS, connect, TLS, headers, body if available.
Pool stateactive sockets, idle sockets, queued requests, origin.
DNS resultfamily, resolver path, TTL if explicit.
Socket errorscode, local address family, remote address family.
TLS handshakeprotocol, cipher, authorized flag, authorization error class.
Server trafficactive requests, active sockets, keep-alive sockets, rejected requests.

Troubleshooting flow:

  1. Establish the failing layer: DNS, TCP connect, TLS, HTTP headers, HTTP body, or application payload.
  2. Capture error code and timeout type.
  3. Compare a single request with concurrency.
  4. Compare hostname connection with pre-resolved address.
  5. Compare Node client with curl while preserving SNI, CA, proxy, and HTTP version.
  6. Inspect pool limits and active handles.
  7. Verify remote load balancer idle timeout against client keep-alive reuse.

Security checklist

AreaCheck
TLSVerification enabled; CA bundle managed; old protocols disabled by policy.
SSRFOutbound URLs validated; private address ranges blocked after DNS resolution; redirects constrained.
Request bodySize caps, content type checks, streaming parser where needed.
Response bodySize caps and content type checks before parsing.
HeadersNo trust in x-forwarded-* unless proxy boundary is known.
DNS rebindingResolve and connect policy prevents hostname swapping to private ranges.
ProxyProxy credentials protected; no accidental environment leakage.
LogsNo tokens, cookies, private keys, or full PII payloads.

Official reference anchors checked