TCP, UDP & DNS

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were checked against the official documentation on 2026-10-04, and every example below was run on all three lines unless the text says otherwise. Below HTTP, everything a Node.js program says to another machine travels over a handful of transport primitives, and three built-in modules expose them: node:net (TCP streams and local IPC endpoints), node:dgram (UDP datagrams) and node:dns (turning names into addresses). All three are Stability 2 (Stable); a few members added recently are labelled below. node:http, node:https and node:http2 are built on net, which is why the knobs on this page (keep-alive, timeouts, maxConnections, address selection) reappear in HTTP Server Fundamentals and Server Timeouts, Limits & Hardening. The running scenario is the Bookshelf application described in Introduction & Architecture; here it gets a tiny line-based ISBN lookup service, a local control socket, a deny-list and a metrics collector.

The stability and version map

Table 1. APIs covered on this page
API Stability (Node.js 26 docs) Version notes

net.createServer(), net.Server, net.Socket, net.createConnection() / net.connect()

2 Stable

'drop' and server.maxConnections since v18.6.0 / v16.17.0; setting maxConnections to 0 drops every connection since v21.0.0 (before, it meant "no limit").

IPC endpoints (path instead of port)

2 Stable

Linux abstract socket paths such as \0abstract supported since v20.8.0.

net.BlockList, net.SocketAddress

2 Stable

BlockList added in v15.0.0 / v14.18.0. blockList.toJSON() and fromJSON() are 1.2 Release candidate (v24.5.0, v22.19.0).

BlockList.PRIVATE_RANGES, blockList.size, blockList.clear()

2 Stable

The API page tags these v26.8.0. Observed: present on Node.js 24.21.0, absent on 22.23.3, so check your patch release.

autoSelectFamily (Happy Eyeballs) and its events

2 Stable

Added in v19.3.0 / v18.13.0; on by default since v20.0.0 / v18.18.0. 'connectionAttempt*' events since v21.6.0 / v20.12.0.

server.listen({ reusePort }), server.dropMaxConnection

2 Stable

Added in v23.1.0 / v22.12.0.

net.BoundSocket, socket.setKeepAlive({ …​ }) with interval and count

2 Stable

Added in v26.4.0. Observed: BoundSocket present on 24.21.0, absent on 22.23.3.

node:dgram: createSocket(), send(), bind(), connect()

2 Stable

connect() since v12.0.0; reusePort since v23.1.0 / v22.12.0; receiveBlockList / sendBlockList options available (see below).

socket.bindSync(), socket.connectSync() (dgram)

2 Stable

Added in v26.4.0. Observed: present on 24.21.0, absent on 22.23.3.

dns.lookup() and dns.resolve*(), dns.Resolver, node:dns/promises

2 Stable

order option of lookup() since v22.1.0 / v20.13.0, replacing verbatim. resolveTlsa() added v23.9.0 / v22.15.0.

dns.setDefaultResultOrder(), dns.getDefaultResultOrder(), --dns-result-order

2 Stable

Default changed to verbatim in v17.0.0; ipv6first accepted since v22.1.0 / v20.13.0.

TCP servers and sockets

TCP gives you a reliable, ordered byte stream between two endpoints. In Node.js a net.Server accepts connections and hands each one to you as a net.Socket, which is a stream.Duplex: everything in Streams (backpressure, pipeline(), async iteration, 'end' versus 'close') applies to it. Under the hood the JavaScript classes wrap a libuv TCP handle; the event loop watches the file descriptor and Node.js calls you back when a connection arrives or bytes are readable (see The Event Loop).

The most common mistake with TCP is assuming message boundaries. The protocol delivers bytes, not messages: one write() can arrive as several 'data' chunks, and several writes can arrive as one chunk. The server below frames its protocol by newline using readline, and the client deliberately splits one request across two writes and packs two requests into one.

A line-based ISBN lookup service (Bookshelf)
import net from 'node:net';
import readline from 'node:readline';
import { once } from 'node:events';

const catalog = new Map([
  ['9781098145187', 'Efficient Node.js'],
  ['9781098173135', 'Node.js Projects'],
]);

const server = net.createServer({ keepAlive: true, noDelay: true }, (socket) => {
  socket.setEncoding('utf8');
  socket.setTimeout(5_000, () => socket.destroy()); // idle timeout: we must end it ourselves
  socket.on('error', (err) => console.error('socket error:', err.code));

  // TCP is a byte stream: one write can arrive as many chunks, many writes as one.
  // readline re-assembles the stream into lines.
  const lines = readline.createInterface({ input: socket });
  lines.on('line', (isbn) => {
    socket.write(`${catalog.get(isbn.trim()) ?? 'not found'}\n`);
  });
});
server.maxConnections = 100; // beyond this, new connections are dropped and 'drop' is emitted
server.on('drop', ({ remoteAddress }) => console.warn('dropped', remoteAddress));

server.listen({ port: 0, host: '127.0.0.1' }); // port 0: the OS picks a free port
await once(server, 'listening');
const { port } = server.address();
console.log('listening on', server.address());

const client = net.createConnection({ port, host: '127.0.0.1' });
client.setEncoding('utf8');
client.write('978109814'); // a request split across two writes ...
client.write('5187\n9999999999999\n'); // ... and two requests in one write
const answers = [];
client.on('data', (chunk) => answers.push(chunk));
await new Promise((resolve) => setTimeout(resolve, 200));
console.log(JSON.stringify(answers.join('').split('\n').filter(Boolean)));

client.end();
await once(client, 'close');
await server[Symbol.asyncDispose](); // server.close() as a promise (stable since v24.2.0)

Output:

listening on { address: '127.0.0.1', family: 'IPv4', port: 63110 }
["Efficient Node.js","not found"]

The port in the output differs on every run, because port: 0 asks the operating system for a free one; always do this in tests. The options and members that matter in practice:

Table 2. Server and socket options worth knowing
Option or member What it does

server.listen({ port, host })

Without host, the server binds the unspecified address (:: when IPv6 is available, otherwise 0.0.0.0), which is reachable from other machines. Bind 127.0.0.1 explicitly for local-only services. The default backlog (queue of pending connections) is 511; the OS may cap it.

reusePort, exclusive, ipv6Only

reusePort: true lets several sockets on one host bind the same port, with the OS distributing connections (Linux 3.9+, FreeBSD 12+ and a few others; it raises an error elsewhere). exclusive: true stops cluster workers from sharing the listening handle; see Cluster & Scaling.

signal

Aborting the controller closes the server. Using it to dispose of a long-lived server as a cleanup mechanism is deprecated (documentation only, DEP0209, v26.8.2); it remains fine for cancellation. Prefer await server[Symbol.asyncDispose](), which is close() as a promise (no longer experimental since v24.2.0).

server.close()

Stops accepting new connections but waits for existing ones to end, so it can take long; end or destroy open sockets yourself during shutdown (see Process, Signals & Exit Codes).

server.maxConnections, 'drop'

Past the limit the server drops new connections and emits 'drop' with the peer’s address. In cluster mode the connection is routed to another worker unless server.dropMaxConnection is true.

keepAlive, keepAliveInitialDelay, socket.setKeepAlive()

Enables TCP keep-alive probes on idle connections so half-dead peers are noticed. Since v26.4.0 the interval and count of the probes can be set too (defaults 1000 ms and 10). Values are applied in whole seconds.

noDelay, socket.setNoDelay()

Nagle’s algorithm is on by default and trades latency for throughput. Disable it for small, latency-sensitive messages such as RPC frames.

socket.setTimeout(ms), 'timeout'

A timeout only reports idleness; the connection stays open until you call end() or destroy(), as the example does.

allowHalfOpen, pauseOnConnect

allowHalfOpen: true keeps the writable side open after the peer half-closes. pauseOnConnect: true delivers sockets paused, so a handle can be passed to another process without reading any of its data first.

socket.resetAndDestroy()

Closes a TCP connection with an RST packet instead of an orderly FIN, useful for abusive peers.

onread

Reads incoming data into a buffer you supply, bypassing the stream machinery, for allocation-free protocols (it is an advanced option; the usual 'data' path is simpler).

Three behaviours deserve a sentence each. A socket without an 'error' listener turns any connection reset into an uncaught exception, so attach one to every socket (the example does); 'close' always follows 'error'. A listening server keeps the process alive, but you can call server.unref() for a server that should not block exit. Finally, a connected net.Socket (or a listening net.Server) can be moved to a worker thread by listing it in the transferList of postMessage(), which lets one thread accept connections and a pool handle them; see Worker Threads.

IPC sockets: Unix domain sockets and named pipes

Pass path instead of port and the same net API speaks to a local peer. On Linux and macOS the endpoint is a Unix domain socket; on Windows it is a named pipe whose path must live under \\?\pipe\ or \\.\pipe\. There is no network hop, so latency is lower and access is governed by file permissions rather than a firewall, which makes IPC sockets a good fit for a control channel between a sidecar and its application, or between a reverse proxy and a backend on the same host.

A local control socket for Bookshelf
import net from 'node:net';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { once } from 'node:events';

// A private directory keeps the socket file away from other local users.
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'bookshelf-'));
const endpoint = process.platform === 'win32'
  ? String.raw`\\.\pipe\bookshelf-control` // Windows: a named pipe in the flat pipe namespace
  : path.join(dir, 'control.sock'); // Unix: a Unix domain socket (a file system entry)

const server = net.createServer((socket) => {
  socket.end('{"status":"ok"}\n');
});
server.listen({ path: endpoint }); // readableAll/writableAll stay false: owner-only access
await once(server, 'listening');
console.log('listening on a', typeof server.address(), '->', path.basename(server.address()));

const client = net.createConnection({ path: endpoint });
client.setEncoding('utf8');
let reply = '';
for await (const chunk of client) reply += chunk;
console.log('reply:', reply.trim());

await server[Symbol.asyncDispose](); // close() unlinks the socket file it created
console.log('socket file still there?', await fs.access(endpoint).then(() => true, () => false));
await fs.rm(dir, { recursive: true });

Output:

listening on a string -> control.sock
reply: {"status":"ok"}
socket file still there? false

The rules the documentation sets out:

  • A Unix socket path is a file system entry and must fit sizeof(sockaddr_un.sun_path): about 107 bytes on Linux and 103 on macOS. Longer paths throw.

  • When Node.js creates the socket, server.close() unlinks it. If the process crashes, or something else created the file, a stale file remains and the next listen() fails with EADDRINUSE; remove it before listening.

  • On Linux a path starting with \0 selects the abstract namespace: no file, nothing to clean up (supported since v20.8.0). It is not available on other platforms.

  • Windows named pipes disappear when the owning process exits, and the pipe namespace is flat.

  • Created as root, a socket may be inaccessible to other users; readableAll and writableAll open it up. Leave them false unless that is the point, and create the directory with restrictive permissions, as mkdtemp() does, so other local users cannot connect.

Inside containers, an IPC socket only works between processes that share the file system path (a shared volume); to reach another container use TCP, as described in Docker networking.

BlockList and SocketAddress

net.BlockList is a set of IP rules (single addresses, inclusive ranges, subnets given as a prefix length or CIDR) that you can attach to a server, a socket, or a UDP socket. It is a plain deny-list; build it once and reuse it.

Building, checking and attaching a BlockList
import net from 'node:net';
import { once } from 'node:events';

const deny = new net.BlockList();
deny.addAddress('203.0.113.7'); // one address
deny.addRange('198.51.100.10', '198.51.100.20'); // an inclusive range
deny.addSubnet('2001:db8::', 32, 'ipv6'); // a subnet (prefix length)

console.log(deny.check('198.51.100.15')); // true
console.log(deny.check('8.8.8.8')); // false
console.log(deny.check('2001:db8::1', 'ipv6')); // true: the family must be given for IPv6
console.log(deny.rules); // human-readable rule list

// Rules survive a round trip through JSON (Release candidate).
const copy = new net.BlockList();
copy.fromJSON(deny.toJSON());
console.log(copy.check('203.0.113.7'));

// Inbound: the server refuses matching peers before 'connection' fires.
const server = net.createServer({ blockList: deny }, (socket) => socket.end('hello\n'));
server.listen(0, '127.0.0.1');
await once(server, 'listening');

// Outbound: the client refuses to dial matching addresses (SSRF guard).
const loopbackDeny = new net.BlockList();
loopbackDeny.addSubnet('127.0.0.0', 8);
const blocked = net.connect({ port: server.address().port, host: '127.0.0.1', blockList: loopbackDeny });
const [err] = await once(blocked, 'error');
console.log(err.code); // ERR_IP_BLOCKED

await server[Symbol.asyncDispose]();

Output:

true
false
true
[
  'Address: IPv4 203.0.113.7',
  'Subnet: IPv6 2001:db8::/32',
  'Range: IPv4 198.51.100.10-198.51.100.20'
]
true
ERR_IP_BLOCKED

Details to keep in mind:

  • check() defaults to IPv4; pass 'ipv6' for IPv6 addresses. IPv4-mapped IPv6 notation (::ffff:123.123.123.123) matches the IPv4 rules.

  • The order of blockList.rules is not guaranteed; the example output above was identical on 26 and 24, while 22 printed the same rules in a different order.

  • Inbound: the blockList option of net.createServer() refuses matching peers. Observed on all three lines: the client’s TCP handshake still completes, and the server then closes the connection without ever emitting 'connection'. The block happens in your process, not in the kernel, so it is not a replacement for a firewall.

  • Behind a reverse proxy or NAT the address that is checked is the proxy’s, not the client’s; the same caveat applies to receiveBlockList on dgram. Behind a proxy, filter on the forwarded address at the proxy instead (see Server Timeouts, Limits & Hardening).

  • Outbound: the blockList option of net.Socket stops connections to matching destinations. This is a useful layer against server-side request forgery (SSRF), but it checks the address the socket connects to after name resolution, so also validate URLs and redirects; see Security Best Practices.

  • toJSON() and fromJSON() (Release candidate) let you store rules in configuration or send them to a worker.

  • net.SocketAddress is the value object behind these APIs (address, family, port, flowlabel, and SocketAddress.parse()); check() also accepts one.

On Node.js 26.8.0 and later, BlockList.PRIVATE_RANGES is a frozen array of the loopback, RFC 1918, link-local and IPv6 unique-local ranges that is handy for the SSRF case. It is also present on 24.21.0 (observed), but not on 22.

Blocking every private range
import net from 'node:net';

// Added in v26.8.0: a ready-made list of non-routable ranges.
const privateOnly = new net.BlockList();
privateOnly.addCIDRs(net.BlockList.PRIVATE_RANGES);
console.log(privateOnly.size);
console.log(privateOnly.check('10.1.2.3'), privateOnly.check('169.254.169.254'), privateOnly.check('93.184.216.34'));

Output (Node.js 26 and 24.21.0; on 22 it throws TypeError because the constant is undefined):

8
true true false

Happy Eyeballs: autoSelectFamily

A host name can have both AAAA (IPv6) and A (IPv4) records. If the IPv6 address is unreachable, a naive client waits for a full connect timeout before trying IPv4. Node.js implements a variant of the Happy Eyeballs algorithm (RFC 8305, section 5) for this: with autoSelectFamily, the socket asks the resolver for all addresses, then tries them in turn, alternating families (first AAAA, then first A, then second AAAA, and so on), moving on when an attempt fails or when autoSelectFamilyAttemptTimeout milliseconds pass. It is on by default since v20.0.0 (v18.18.0), is ignored when family is not 0 or when localAddress is set, and applies to net.connect() and to the clients built on it, such as http.request().

Watching address selection with connection-attempt events
import net from 'node:net';
import { once } from 'node:events';

console.log(net.getDefaultAutoSelectFamily(), net.getDefaultAutoSelectFamilyAttemptTimeout());

// Only IPv4 is listening; the IPv6 loopback address has nothing behind it.
const server = net.createServer((socket) => socket.end('hi\n'));
server.listen(0, '127.0.0.1');
await once(server, 'listening');

// A custom lookup stands in for a host name with an AAAA and an A record (all: true).
const lookup = (host, options, callback) =>
  setImmediate(callback, null, [{ address: '::1', family: 6 }, { address: '127.0.0.1', family: 4 }]);

const socket = net.connect({
  host: 'bookshelf.test', port: server.address().port,
  lookup,
  autoSelectFamily: true, // default since v20.0.0 / v18.18.0
  autoSelectFamilyAttemptTimeout: 250,
});
socket.on('connectionAttempt', (ip, port, family) => console.log('trying  ', ip, 'IPv' + family));
socket.on('connectionAttemptFailed', (ip, port, family, err) => console.log('failed  ', ip, err.code));
socket.on('connectionAttemptTimeout', (ip) => console.log('timeout ', ip));
socket.on('connect', () => console.log('connected to', socket.remoteAddress));
socket.resume();
await once(socket, 'close');

// With every attempt failing, a single AggregateError carries all the errors.
const dead = net.connect({ host: 'bookshelf.test', port: 1, lookup }); // port 1: nothing listens
const [err] = await once(dead, 'error');
console.log(err.constructor.name, err.errors.map((e) => `${e.address}:${e.code}`));
await server[Symbol.asyncDispose]();

Output (Node.js 26; see the next paragraph for the 24 and 22 differences):

true 500
trying   ::1 IPv6
failed   ::1 ECONNREFUSED
trying   127.0.0.1 IPv4
connected to 127.0.0.1
AggregateError [ '::1:ECONNREFUSED', '127.0.0.1:ECONNREFUSED' ]

Notes. The 'connectionAttempt', 'connectionAttemptFailed' and 'connectionAttemptTimeout' events fire once per address. The custom lookup function defers its callback with setImmediate() because a synchronous one would run the first attempt before the listeners were attached. When every address fails you get a single AggregateError whose errors array lists each attempt. The default attempt timeout, as returned by net.getDefaultAutoSelectFamilyAttemptTimeout(), was observed to be 500 ms on 26.10.0 and 250 ms on 24.21.0 and 22.23.3; the Node.js 26 API page itself states both values (500 in the getter, 250 in the setter), so set it explicitly if you depend on it, or use --network-family-autoselection-attempt-timeout. To switch the whole behaviour off, pass autoSelectFamily: false, call net.setDefaultAutoSelectFamily(false), or start with --no-network-family-autoselection.

This feature exists because of a real regression. Node.js 17 changed dns.lookup() to return addresses in the order the resolver gave them (verbatim), so on many machines localhost started resolving to ::1 first, and a server that listened only on 127.0.0.1 suddenly refused connections from a Node.js client. autoSelectFamily (v18.13 and v19.3 onward, default since v20) retries the next address, so the same code works again; the order setting described below remains the lever for choosing which family wins when both work.

UDP with dgram

UDP is the opposite trade-off to TCP: no connection, no ordering, no retransmission and no delivery guarantee, but also no handshake and no head-of-line blocking. Each send() is one datagram and each 'message' event is one whole datagram, so there is no framing problem. Typical uses are metrics (StatsD style), service discovery, game and media traffic, and simple DNS-like protocols.

A fire-and-forget metrics collector
import dgram from 'node:dgram';
import { once } from 'node:events';

// A tiny "metrics collector": fire-and-forget datagrams, no connection, no delivery guarantee.
const collector = dgram.createSocket('udp4');
collector.on('message', (msg, rinfo) => {
  // One datagram = one whole message; there is no framing problem as on TCP.
  console.log(`got ${JSON.stringify(msg.toString())} from ${rinfo.address} (${rinfo.size} bytes)`);
  collector.send('ack', rinfo.port, rinfo.address); // reply to the sender
});
collector.bind(0, '127.0.0.1'); // port 0: any free port
await once(collector, 'listening');
const { port } = collector.address();

const client = dgram.createSocket('udp4');
client.connect(port, '127.0.0.1'); // optional: fixes the peer, so send() needs no address
await once(client, 'connect');
client.send('books.opened:1|c'); // callback optional; errors surface as 'error' events
const [ack] = await once(client, 'message');
console.log('reply:', ack.toString());

// Datagrams larger than the path MTU may be fragmented or silently dropped:
// keep payloads small (the docs give 65,507 bytes as the hard IPv4 ceiling).
console.log('recv buffer bytes:', client.getRecvBufferSize() > 0);

await client[Symbol.asyncDispose]();
await collector[Symbol.asyncDispose]();

Output:

got "books.opened:1|c" from 127.0.0.1 (16 bytes)
reply: ack
recv buffer bytes: true

The points to remember:

  • dgram.createSocket() takes 'udp4' or 'udp6' (or an options object). A bound socket keeps the process alive; close() or unref() releases it. bind() is asynchronous and emits 'listening'; a failed bind emits 'error'.

  • socket.connect() fixes the default peer and makes the socket accept datagrams only from that peer; it does not probe reachability, so an ECONNREFUSED may only surface on a later send.

  • The practical size limit: the hard ceiling for an IPv4 payload is 65,507 bytes, but anything beyond the path MTU (typically 1500 bytes on Ethernet) risks fragmentation or silent loss, so keep datagrams small and tolerate loss.

  • Options: reuseAddr and reusePort (several processes on one port, with the OS distributing datagrams), recvBufferSize and sendBufferSize, ipv6Only, a custom lookup, an AbortSignal, and receiveBlockList / sendBlockList. The receive list does not help behind NAT or a proxy, for the same reason as the TCP blockList.

  • Broadcast and multicast: setBroadcast(), addMembership() and dropMembership(), setMulticastTTL(), setMulticastLoopback() and setMulticastInterface(); source-specific membership has its own methods.

  • Inspect pressure with getSendQueueSize(), getSendQueueCount() and the buffer-size getters; unlike TCP there is no automatic backpressure, so a producer that outruns the network just fills the queue.

Name resolution is part of send() and bind(): unless you pass a numeric address, dgram calls dns.lookup() (on the thread pool, see below) for every host name. Since v26.4.0 bindSync() and connectSync() perform the bind and the association inline, because a bind(2) or UDP connect(2) to a numeric address is local and non-blocking. They accept numeric IP addresses only, never host names, and throw on failure instead of emitting 'error'.

Synchronous bind and connect (Node.js 26; also observed on 24.21.0, not on 22)
import dgram from 'node:dgram';

const socket = dgram.createSocket('udp4');
const addr = socket.bindSync({ address: '127.0.0.1', port: 0 }); // v26.4.0+: numeric IP only
console.log(addr.family, typeof addr.port);
socket.connectSync(addr.port, '127.0.0.1');
console.log(socket.remoteAddress().address);
await socket[Symbol.asyncDispose]();

Output:

IPv4 number
127.0.0.1

A receiveBlockList drops datagrams from denied peers before 'message' fires:

Discarding datagrams from a denied range
import dgram from 'node:dgram';
import net from 'node:net';
import { once } from 'node:events';

const deny = new net.BlockList();
deny.addSubnet('127.0.0.0', 8);
const rx = dgram.createSocket({ type: 'udp4', receiveBlockList: deny });
rx.on('message', () => console.log('received (should not)'));
rx.bind(0, '127.0.0.1');
await once(rx, 'listening');
const tx = dgram.createSocket('udp4');
tx.send('hi', rx.address().port, '127.0.0.1', () => console.log('sent'));
await new Promise((r) => setTimeout(r, 200));
await tx[Symbol.asyncDispose](); await rx[Symbol.asyncDispose]();

Output (the callback prints sent; no received line follows):

sent

DNS: dns.lookup versus dns.resolve

The node:dns module has two families of functions that look alike and behave very differently. The documentation calls this out under Implementation considerations.

Table 3. dns.lookup versus dns.resolve*
dns.lookup() dns.resolve(), resolve4(), resolve6(), resolveMx(), reverse(), …​

Mechanism

The operating system’s getaddrinfo(3), the same call ping and most programs make.

A real DNS protocol query, always sent over the network.

Where it runs

A synchronous call executed on libuv’s thread pool (size 4 by default). It looks asynchronous from JavaScript.

Natively asynchronous; it does not use the thread pool.

Configuration used

/etc/hosts, nsswitch.conf, resolv.conf, and platform facilities such as mDNS or the Windows resolver.

The DNS servers in dns.getServers() (changeable with setServers() or a per-instance Resolver); it never reads /etc/hosts.

Result

One address (or all with { all: true }), ordered by order.

The record type asked for: A, AAAA, MX, TXT, SRV, CAA, NAPTR, TLSA and more, with TTLs available on resolve4() and resolve6() via { ttl: true }.

Used implicitly by

net.connect(), dgram, http, https and most clients, whenever you pass a host name.

Only when you call it, or install a custom lookup that uses it.

The differences are observable. The first example asks both functions about localhost: lookup() consults the machine’s configuration and returns the loopback address, while a Resolver pointed at a small stub DNS server (built with dgram, so no internet access is needed) answers resolve4() from the wire, ignoring /etc/hosts.

lookup() against resolve4(), with a throw-away DNS server
import dgram from 'node:dgram';
import dns from 'node:dns/promises';
import { once } from 'node:events';

// A throw-away DNS server (answers every A query with 192.0.2.1) so the demo needs no internet.
const stub = dgram.createSocket('udp4');
stub.on('message', (query, rinfo) => {
  let end = 12; // the header is 12 bytes; the question follows
  while (query[end] !== 0) end += query[end] + 1; // walk the labels of the name
  end += 5; // root label + QTYPE + QCLASS
  const answer = Buffer.from([0xc0, 0x0c, 0, 1, 0, 1, 0, 0, 0, 60, 0, 4, 192, 0, 2, 1]);
  const header = Buffer.from(query.subarray(0, 12));
  header[2] = 0x81; header[3] = 0x80; // QR=1 (response), RD+RA
  header[6] = 0; header[7] = 1; // ANCOUNT = 1
  stub.send(Buffer.concat([header, query.subarray(12, end), answer]), rinfo.port, rinfo.address);
});
stub.bind(0, '127.0.0.1');
await once(stub, 'listening');

// lookup(): getaddrinfo(3) on the libuv thread pool; honours /etc/hosts, nsswitch.conf...
console.log('lookup  :', await dns.lookup('localhost'));

// resolve*(): a real DNS query over the network, never /etc/hosts, no thread pool.
const resolver = new dns.Resolver({ timeout: 1000, tries: 1 });
resolver.setServers([`127.0.0.1:${stub.address().port}`]); // this resolver only
console.log('resolve4:', await resolver.resolve4('localhost')); // the stub answers, not /etc/hosts
console.log('servers :', resolver.getServers());

stub.close();

Output (the stub’s port differs per run):

lookup  : { address: '::1', family: 6 }
resolve4: [ '192.0.2.1' ]
servers : [ '127.0.0.1:51603' ]

Resolver options are timeout, tries (default 4) and maxTimeout; a resolver’s setServers() leaves the global servers and other resolvers untouched, and resolver.cancel() aborts its pending queries. The promise API lives in node:dns/promises, where lookup() with a single result resolves to an { address, family } object.

Why the thread pool matters

Because lookup() is a blocking getaddrinfo(3) call parked on a pool thread, it competes with every other pool user: the asynchronous fs functions, crypto.pbkdf2(), scrypt() and randomBytes(), zlib, and so on (the UV_THREADPOOL_SIZE documentation lists them). When the pool is saturated by slow work, every connection by host name waits too. The experiment below fills the four default threads with hashing jobs and then times one lookup() and one resolve4().

A saturated thread pool delays lookup() but not resolve4()
import crypto from 'node:crypto';
import dgram from 'node:dgram';
import dns from 'node:dns/promises';
import { once } from 'node:events';
import { promisify } from 'node:util';

const pbkdf2 = promisify(crypto.pbkdf2);

// Same throw-away DNS server idea as before, reduced to the essentials.
const stub = dgram.createSocket('udp4');
stub.on('message', (q, r) => {
  let end = 12; while (q[end] !== 0) end += q[end] + 1; end += 5;
  const h = Buffer.from(q.subarray(0, 12)); h[2] = 0x81; h[3] = 0x80; h[7] = 1;
  const a = Buffer.from([0xc0, 0x0c, 0, 1, 0, 1, 0, 0, 0, 60, 0, 4, 192, 0, 2, 1]);
  stub.send(Buffer.concat([h, q.subarray(12, end), a]), r.port, r.address);
});
stub.bind(0, '127.0.0.1');
await once(stub, 'listening');
const resolver = new dns.Resolver({ timeout: 2000, tries: 1 });
resolver.setServers([`127.0.0.1:${stub.address().port}`]);

const timed = async (label, fn) => {
  const t = performance.now();
  await fn();
  console.log(label.padEnd(8), Math.round(performance.now() - t), 'ms');
};

// Occupy every thread of the default pool (4) with slow work, then ask for names.
const busy = Array.from({ length: 4 }, () => pbkdf2('pw', 'salt', 3_000_000, 64, 'sha512'));
await timed('lookup', () => dns.lookup('localhost')); // waits for a free pool thread
await timed('resolve4', () => resolver.resolve4('example.test')); // does not use the pool
await Promise.all(busy);
stub.close();

Output (observed, Node.js 26.10.0; the first run uses the default pool of 4 threads, the second sets UV_THREADPOOL_SIZE=8):

lookup   550 ms
resolve4 0 ms
lookup   3 ms
resolve4 1 ms

The numbers are illustrative and vary by machine, but the shape is stable. The remedies, in order of preference: keep CPU-heavy work off the pool where you can (see Don’t Block the Event Loop and Worker Threads), raise UV_THREADPOOL_SIZE before the process starts (the variable is read at startup; see CLI Options, Environment & Config File), resolve a host name once with dns.resolve() and connect to the address, or pass a custom lookup function to net.connect() or dgram.createSocket(). lookup() has no built-in cache: a service that connects to many short-lived hosts either relies on the operating system’s resolver cache, keeps connections alive, or adds its own cache.

Two more lookup() options matter. hints takes getaddrinfo flags that can be OR-ed: dns.ADDRCONFIG (return only families the machine has non-loopback addresses for), dns.V4MAPPED and dns.ALL. And the error code ENOTFOUND is reported not only for names that do not exist but also when the lookup fails in other ways, such as running out of file descriptors, so do not treat it as proof that a record is missing.

setDefaultResultOrder and --dns-result-order

lookup() can return both IPv4 and IPv6 addresses, and the order decides which one a client tries first. The order option accepts 'verbatim' (the resolver’s order, the default since v17.0.0), 'ipv4first' and 'ipv6first' (the latter since v22.1.0 and v20.13.0). The default for the process is set with dns.setDefaultResultOrder() or the --dns-result-order flag, read back with getDefaultResultOrder(); the function takes priority over the flag, and a call from the main thread does not change the default inside worker threads.

Choosing the order of lookup results
import dns from 'node:dns';
import dnsPromises from 'node:dns/promises';

console.log('default:', dns.getDefaultResultOrder());
console.log('verbatim :', await dnsPromises.lookup('localhost', { all: true }));
console.log('ipv4first:', await dnsPromises.lookup('localhost', { all: true, order: 'ipv4first' }));

dns.setDefaultResultOrder('ipv6first'); // process-wide default for lookup() (not in worker threads)
console.log('now      :', dns.getDefaultResultOrder(), (await dnsPromises.lookup('localhost')).family);

// 'ipv4first' is also available as a startup flag:  node --dns-result-order=ipv4first app.js

Output (a machine whose /etc/hosts lists localhost for both 127.0.0.1 and ::1):

default: verbatim
verbatim : [ { address: '::1', family: 6 }, { address: '127.0.0.1', family: 4 } ]
ipv4first: [ { address: '127.0.0.1', family: 4 }, { address: '::1', family: 6 } ]
now      : ipv6first 6

With Happy Eyeballs on, ipv4first is rarely needed for correctness; it is mainly a tool for environments with broken IPv6 routes (where it saves the attempt timeout on each new connection) and for deterministic tests. Local development servers are the other common trap: localhost may resolve to ::1 first, so a server bound to 127.0.0.1 and a client dialling localhost can disagree. Bind the address you mean, or listen without a host to accept both.

What changed recently

Table 4. Changes that affect networking code, newest first
Version Change

v26.8.0 (observed also on 24.21.0)

BlockList.PRIVATE_RANGES, blockList.size and blockList.clear(); DEP0209 (v26.8.2, documentation only) deprecates using an AbortSignal to dispose of long-lived resources such as a server.

v26.4.0

net.BoundSocket (synchronous port reservation and control of the local egress address), dgram bindSync() and connectSync(), and setKeepAlive() with an options object plus interval and count.

v25.6.0

typeOfService option and socket.getTypeOfService() / setTypeOfService() for the IP TOS field.

v24.5.0, v22.19.0

BlockList toJSON() and fromJSON() (Release candidate).

v24.2.0

server[Symbol.asyncDispose]() and socket[Symbol.asyncDispose]() (dgram too) are no longer experimental.

v23.1.0, v22.12.0

reusePort for TCP servers and UDP sockets; server.dropMaxConnection.

v22.1.0, v20.13.0

dns.lookup() gets order, with ipv6first accepted by the setters and the flag; verbatim is deprecated in its favour.

v21.6.0, v20.12.0

'connectionAttempt', 'connectionAttemptFailed' and 'connectionAttemptTimeout' events.

v20.0.0, v18.18.0

autoSelectFamily is on by default; the flag is --network-family-autoselection.

v17.0.0

dns.lookup() returns addresses verbatim, no longer IPv4 first.

Where the books are outdated

Neither book treats these three modules in depth. Efficient Node.js (Samer Buna, O’Reilly, 2025) lists node:net and node:dns in its overview of built-in modules, mentions net and dgram as sources of event emitters, and discusses TCP sockets as an example of streams; Node.js Projects (Jonathan Wexler, O’Reilly, 2025) works at the HTTP and framework level. Consequently the material on this page is mostly absent from both, and the following operational details, all documented above, are worth taking from the official documentation rather than from memory or older tutorials:

  • The IPv4/IPv6 behaviour of localhost changed in v17 and was partly repaired in v18.18 and v20 (autoSelectFamily); advice written for earlier lines, such as "`dns.lookup()` prefers IPv4", no longer applies.

  • dns.lookup() is not asynchronous I/O. It occupies a thread-pool thread, which is why pool size appears in performance advice for services that open many outbound connections.

  • The verbatim option is superseded by order, and dns.setDefaultResultOrder() now accepts 'ipv6first'.

  • The signal option of server.listen() and await using style disposal (Symbol.asyncDispose) are recent additions; DEP0209 changes how disposal should be written.

  • The built-in BlockList (with PRIVATE_RANGES on current releases) replaces many small third-party IP-filtering packages for simple allow and deny logic.

Where to go next

Encrypted transports are covered in HTTPS, TLS & HTTP/2; the HTTP layer built on net in HTTP Server Fundamentals and Fetch & HTTP Clients; byte handling in Buffers & Binary Data. For TLS, remember that tls.connect() and tls.createServer() are the secure counterparts of net.connect() and net.createServer(), and that an authentication mechanism, not an IP deny-list, is what protects a service.