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
| API | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
|
2 Stable |
|
IPC endpoints ( |
2 Stable |
Linux abstract socket paths such as |
|
2 Stable |
|
|
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. |
|
2 Stable |
Added in v19.3.0 / v18.13.0; on by default since v20.0.0 / v18.18.0. |
|
2 Stable |
Added in v23.1.0 / v22.12.0. |
|
2 Stable |
Added in v26.4.0. Observed: |
|
2 Stable |
|
|
2 Stable |
Added in v26.4.0. Observed: present on 24.21.0, absent on 22.23.3. |
|
2 Stable |
|
|
2 Stable |
Default changed to |
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.
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"]
Source: Node.js API: net.createServer()
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:
| Option or member | What it does |
|---|---|
|
Without |
|
|
|
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 |
|
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). |
|
Past the limit the server drops new connections and emits |
|
Enables TCP keep-alive probes on idle connections so half-dead peers are noticed. Since v26.4.0 the |
|
Nagle’s algorithm is on by default and trades latency for throughput. Disable it for small, latency-sensitive messages such as RPC frames. |
|
A timeout only reports idleness; the connection stays open until you call |
|
|
|
Closes a TCP connection with an RST packet instead of an orderly FIN, useful for abusive peers. |
|
Reads incoming data into a buffer you supply, bypassing the stream machinery, for allocation-free protocols (it is an advanced option; the usual |
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.
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
Source: Node.js API: net, IPC support
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 nextlisten()fails withEADDRINUSE; remove it before listening. -
On Linux a path starting with
\0selects 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;
readableAllandwritableAllopen it up. Leave themfalseunless that is the point, and create the directory with restrictive permissions, asmkdtemp()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.
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
Source: Node.js API: net.BlockList
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.rulesis not guaranteed; the example output above was identical on 26 and 24, while 22 printed the same rules in a different order. -
Inbound: the
blockListoption ofnet.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
receiveBlockListondgram. Behind a proxy, filter on the forwarded address at the proxy instead (see Server Timeouts, Limits & Hardening). -
Outbound: the
blockListoption ofnet.Socketstops 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()andfromJSON()(Release candidate) let you store rules in configuration or send them to a worker. -
net.SocketAddressis the value object behind these APIs (address,family,port,flowlabel, andSocketAddress.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.
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().
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.
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()orunref()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 anECONNREFUSEDmay 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:
reuseAddrandreusePort(several processes on one port, with the OS distributing datagrams),recvBufferSizeandsendBufferSize,ipv6Only, a customlookup, anAbortSignal, andreceiveBlockList/sendBlockList. The receive list does not help behind NAT or a proxy, for the same reason as the TCPblockList. -
Broadcast and multicast:
setBroadcast(),addMembership()anddropMembership(),setMulticastTTL(),setMulticastLoopback()andsetMulticastInterface(); 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'.
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
Source: Node.js API: socket.bindSync()
A receiveBlockList drops datagrams from denied peers before 'message' fires:
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.
dns.lookup() |
dns.resolve(), resolve4(), resolve6(), resolveMx(), reverse(), … |
|
|---|---|---|
Mechanism |
The operating system’s |
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 |
|
The DNS servers in |
Result |
One address (or all with |
The record type asked for: A, AAAA, MX, TXT, SRV, CAA, NAPTR, TLSA and more, with TTLs available on |
Used implicitly by |
|
Only when you call it, or install a custom |
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.
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' ]
Source: Node.js API: dns.Resolver
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().
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.
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
| Version | Change |
|---|---|
v26.8.0 (observed also on 24.21.0) |
|
v26.4.0 |
|
v25.6.0 |
|
v24.5.0, v22.19.0 |
|
v24.2.0 |
|
v23.1.0, v22.12.0 |
|
v22.1.0, v20.13.0 |
|
v21.6.0, v20.12.0 |
|
v20.0.0, v18.18.0 |
|
v17.0.0 |
|
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
localhostchanged 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
verbatimoption is superseded byorder, anddns.setDefaultResultOrder()now accepts'ipv6first'. -
The
signaloption ofserver.listen()andawait usingstyle disposal (Symbol.asyncDispose) are recent additions; DEP0209 changes how disposal should be written. -
The built-in
BlockList(withPRIVATE_RANGESon 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.