Node.js Request Path: DNS, TCP, libuv & Callbacks
A Node.js request reaches your application after a lot of lower-level work has already happened. A hostname may need resolution. An address has to be chosen. The operating system has to pick a route and a local port. TCP has to connect. On the server, the kernel has to match the packet to a listening socket, finish the TCP handshake, place the connection where Node can accept it, and then libuv has to deliver that work back into JavaScript.
Your application code sees a net.Socket, a 'connect' event, a 'connection' callback, or a 'data' event. Those are the JavaScript entry points. Underneath them, the request has already passed through DNS, routing, kernel socket state, TCP queues, libuv readiness, and Node's stream layer.
The path matters because failures have locations. DNS failure belongs to lookup. Connection refusal belongs to TCP setup. Accept-queue pressure belongs before the request handler. A 'data' event happens after the socket exists and Node has bytes ready to hand to JavaScript.
The Request Path from Client to Node.js Process
When you trace a request, you get better answers by placing each event on the path. DNS comes before TCP. ECONNREFUSED happens while the client is trying to connect. Backlog pressure happens before your server callback runs. HTTP starts after the transport path has already produced a connected byte stream.
A client call returns a net.Socket object before the TCP connection exists -
import net from 'node:net';
const socket = net.connect(3000, 'api.internal');
socket.on('connect', () => {
socket.write('ping\n');
});That first call creates JavaScript state. The object can hold listeners. It can queue writes. It can later emit an error. But the real TCP connection arrives only after Node resolves the name, chooses an address, asks the OS for a route, gets a local endpoint, and waits for the TCP handshake to complete.
So when you call net.connect(), you should think of it as starting work, not finishing work. The socket exists immediately. The connection does not.
The outbound path is the client-side path from a Node connection request to a connected socket. It includes the JavaScript net.Socket, Node's native socket wrapper, libuv, the OS resolver path when dns.lookup() behavior is used, the routing table, the kernel socket table, and the remote peer's response.
The server has its own path -
import net from 'node:net';
net.createServer(socket => {
console.log(socket.remoteAddress, socket.remotePort);
socket.end('pong\n');
}).listen(3000, '0.0.0.0');The inbound path starts when a packet reaches the server machine. The kernel matches that packet to a listening socket, advances TCP state, places a completed connection into the accept queue, and notifies libuv that the listening socket has work waiting. Node then accepts the connection, wraps the connected descriptor, creates the JavaScript net.Socket, and runs your callback.
Client and server are both working on the same TCP connection, but they observe it from their own side.
Keep the path in your head like this -
client JavaScript
-> lookup
-> address selection
-> route and local address
-> TCP connect
-> server TCP queues
-> libuv readiness
-> server JavaScript callbackThat path is the whole reason this topic is worth learning. DNS success only gives you candidate addresses. It does not prove that any server accepts TCP connections there. TCP success only gives you a byte stream. It does not prove that the peer speaks the application protocol you expect. The address the server sees may also be changed by NAT, a proxy, or a load balancer before it reaches your process.
From here on, short JavaScript examples reuse surrounding variables such as net, server, or socket when repeating setup would get in the way.
Outbound From Hostname to TCP Socket
net.connect() can accept a host and a port. If the host is a name, Node has to resolve that name before the kernel can connect -
const socket = net.connect({
host: 'example.com',
port: 80,
});A hostname is useful to humans and applications. The kernel needs something more specific - an IP address, a port, and an address family. Node gets there through lookup behavior covered earlier in this chapter. For this part, the useful version is simple. dns.lookup() style resolution uses the OS resolver path, and the order of returned addresses depends on Node options, OS resolver behavior, and the records returned for the name.
After Node has candidate addresses, the OS has to choose a local endpoint. That means a source IP address and a source port for the outbound connection. The OS chooses those from the destination address, address family, routing table, configured interfaces, and any explicit localAddress supplied by your code.
You can pin the local address yourself -
const socket = net.connect({
host: 'example.com',
port: 80,
localAddress: '192.168.1.20',
});That option tells the OS which source address you want. The OS still has to accept that address as local, match the address family, and find a route from that address to the destination. If the address is missing, bound to the wrong interface, or unusable for the chosen destination, the connection fails before the remote server is involved.
Most client code leaves localAddress unset. That is usually the right choice. The route lookup then drives source selection. The kernel checks the routing table for the destination IP. The selected route points at an output interface or next hop. The local address usually comes from that interface. The kernel also chooses an ephemeral port, which creates the client-side socket address.
At that point, TCP has enough information to send a SYN -
remote address - 93.184.216.34
remote port - 80
local address - 192.168.1.20
local port - 52744
protocol - TCP
state - connectingThe local port changes from run to run. The source address depends on the host's network setup. In a container, the first source address may be a container-side address, then NAT may rewrite it later. On a laptop with a VPN enabled, the selected route may use a tunnel interface. Node does not own those policies. Node asks the OS to connect. The OS builds the local endpoint.
The JavaScript object moves through a smaller set of visible states. Before connect completes, writes can queue inside Node. If TCP succeeds, the socket emits 'connect'. If lookup or connect fails, the socket emits 'error'.
Several pieces of state exist during the same operation -
JavaScript net.Socket
-> native TCP wrapper
-> libuv TCP handle
-> kernel socket table entry
-> route-selected local endpoint
-> remote endpoint candidateYour code holds the JavaScript object. Node's native wrapper connects that object to the C++ side. The libuv handle connects the socket to event-loop I/O readiness. The kernel socket table owns TCP state and the local descriptor. The local endpoint is the source address and port the OS selected. The remote endpoint candidate is one resolved address plus the requested port.
Each of those steps can fail.
Descriptor allocation can fail before DNS if the process cannot create more sockets. Lookup can fail before any packet reaches the remote network. A connect attempt can fail after the kernel created a local socket but before the peer accepted anything. A successful TCP connect can still be followed by an immediate close if the server accepts the socket and then rejects the session at the application level.
socket.pending exposes a small part of this from JavaScript -
const socket = net.connect(3000, '127.0.0.1');
console.log(socket.pending);
socket.on('connect', () => {
console.log(socket.pending);
});While the connection is still being set up, the socket is pending. After connect, it becomes a live connected stream. If lookup or connect fails, that socket never becomes useful for normal reads and writes. Your error handler receives the failure from that path.
The address family is also chosen before the kernel can make the socket call. IPv4 and IPv6 use separate socket families below Node. A hostname can produce both, so Node may try candidate addresses from more than one family. A numeric host skips DNS, but it still goes through route lookup and local endpoint selection -
net.connect(5432, '::1');
net.connect(5432, '127.0.0.1');Those calls target loopback addresses from separate families. A server listening only on IPv4 loopback will not receive the IPv6 connection. A dual-stack listener may receive both, depending on OS socket options from the previous subchapter. The client-side request has to match how the server is bound.
Explicit local ports are rare, but they show one more place where the OS can reject the request -
net.connect({
host: '127.0.0.1',
port: 3000,
localPort: 40000,
});Now the client asks for a specific local port instead of letting the kernel choose one. That can fail if another socket already uses the same local tuple or if the platform rejects the reuse pattern. Outbound clients usually leave localPort alone because ephemeral port selection is exactly the kind of job the kernel handles well.
Local address selection gets harder to reason about on hosts with multiple routes.
A developer laptop might have Wi-Fi, loopback, a VPN tunnel, and a container bridge. A cloud VM might have a primary interface, an extra private interface, and IPv6 on only one of them. A container might see a smaller network view that maps through host networking rules. The same net.connect() call runs against the route table visible to that process. Move the process into another network namespace, and the route table can change even when the JavaScript code stays the same.
The route lookup starts from the destination candidate. For a public IPv4 destination, the default IPv4 route may use Wi-Fi. For a private corporate address, a VPN route may win. For 127.0.0.1, loopback wins. For ::1, IPv6 loopback wins. The local address follows that choice unless your code pins localAddress.
Pinned local addresses can help in tests and multi-homed systems, but they also turn route selection into a promise your code has to keep. Pin an address from the wrong interface and connect can fail. Pin an IPv4 local address while the remote candidate is IPv6 and the families do not match. Pin an address that exists on the host but cannot reach the destination, and the failure can look like a remote connect problem even though the bad choice was local.
Here is a request that mixes address families -
net.connect({
host: '2001:db8::10',
port: 443,
localAddress: '192.168.1.20',
});The remote address is IPv6. The local address is IPv4. The OS cannot build one TCP socket from those two families, so the request fails during connection setup.
The local endpoint also affects what the server logs. Without NAT or proxying, the server sees the client's selected source address and ephemeral port. With NAT, the server sees the translated tuple. With a proxy, the server sees the proxy tuple. The client still chose a real local endpoint, but that endpoint may not be visible to the backend server.
For debugging, log the selected local address after connect -
const socket = net.connect({ host, port });
socket.on('connect', () => {
console.log('selected local', socket.address());
});Here, host and port are the endpoint values for the operation you are tracing.
That log is only available after connect because the kernel may not have selected the final local tuple before then. Before connect, your code has intent. After connect, the socket has assigned state.
At the end of a successful outbound path, the connected socket has four values -
local address
local port
remote address
remote portFor TCP, that tuple identifies the connection inside the host's TCP state. Many client sockets can connect to the same remote address and port because each one gets its own local port. Many remote clients can connect to one server port because each one has its own remote endpoint. The listening socket owns the local listen address and port. Accepted sockets own full connected tuples.
Lookup failures happen before TCP -
const socket = net.connect(80, 'bad.invalid');
socket.on('error', err => {
console.error(err.code);
});A failed name lookup commonly reports ENOTFOUND. A temporary resolver failure can report EAI_AGAIN. In both cases, no SYN went to the application server because Node never got a usable destination address.
A refused connection happens later -
const socket = net.connect(9, '127.0.0.1');
socket.on('error', err => {
console.error(err.code);
});If no process is listening on that port, the connection commonly reports ECONNREFUSED. The address existed. The route worked. TCP reached a host stack that rejected the connection for that port.
Timeouts sit in another part of the path. A connect timeout means the client waited long enough without a successful TCP connection. The cause might be packet loss, a firewall that drops traffic, routing failure, a remote host that never answered, or a middle system that absorbed the attempt. The first useful conclusion is limited - the connect path did not complete. The cause needs more evidence.
Address Racing
Resolved addresses are candidates. They are not a finished connection plan.
A hostname can return IPv6 and IPv4 addresses. If a client tries only the first address, it can sit on a broken path while another address family would have connected quickly. Address order helps, but it is a weak tool when one family works and the other is slow or broken.
Happy Eyeballs is a client connection strategy that avoids waiting too long on a bad candidate. The usual shape is easy to understand. Try one candidate, often IPv6 first. Wait a short delay. If it has not connected, try another candidate, often IPv4. Use the first one that connects. Close or abandon the rest.
A connection race means multiple connection attempts may be active at the same time, and the client commits to the first successful one. DNS gives Node the candidates. The connection logic decides how aggressively to try them.
Node's low-level net module has changed over time in this area. In current Node.js releases, connection attempts can use auto-selection behavior for multiple addresses depending on options and defaults. The exact timing knobs belong to the API reference, but the behavior is easy to reason about - Node may receive more than one candidate address, and it may try more than one before it emits the final result.
Debugging changes here.
const socket = net.connect({
host: 'localhost',
port: 3000,
});On one machine, localhost may resolve to ::1 before 127.0.0.1. On another, IPv4 may come first. If your server listens only on 127.0.0.1, an IPv6 attempt to ::1 can fail while the IPv4 attempt succeeds. The app may still work, but with a small delay. A trace can show a refused IPv6 attempt followed by a successful IPv4 connection.
The reverse can happen too. A service may listen on IPv6 only. The client may try IPv4 first and recover through IPv6. One address may route through a VPN while another uses a local network. The hostname is the same. The network path is not.
When every candidate fails, the error you see may summarize several attempts. One address can produce ECONNREFUSED, another can time out, and another can fail for a routing reason. The final JavaScript error depends on the policy that collected those attempts. Treat a final connect error as the result of candidate selection plus connection attempts. Do not assume it describes one DNS answer by itself.
The practical move is simple - log the selected local and remote endpoints after connect.
socket.on('connect', () => {
console.log(socket.address());
console.log(socket.remoteAddress, socket.remotePort);
});socket.address() shows the local address after the OS picked it. remoteAddress and remotePort show the peer for the connected socket. With address racing, those values tell you which candidate actually won.
Node also has to clean up the attempts that lose. A losing attempt may still be in progress when the winning attempt connects. Node closes or abandons that losing handle so your application sees one connected socket. The losing attempt may still leave traces below JavaScript - a SYN may have left the host, a refusal may have come back, or a timeout may have been pending.
That hidden work can show up in packet captures and server logs. A dual-stack server may see a short-lived attempt on one address family and the real session on another. A firewall may log blocked IPv6 attempts while the application works over IPv4. A local test may pass but still spend time on a doomed first candidate.
The timing is kept small on purpose. A long delay would bring back the user-visible stall Happy Eyeballs is meant to avoid. Starting every possible attempt at once would create more network noise and more connection churn. Modern client behavior usually uses staggered attempts. The exact delay is a policy knob, but the idea stays the same - overlap enough work to avoid waiting too long on a bad path.
When all candidates fail, reporting gets less tidy. Some APIs report the last error. Some aggregate. Node's low-level behavior depends on connect options and version. The reading discipline is the same - candidate generation and candidate connection are separate parts of the path. A resolver failure means Node did not get useful candidates. A connect failure means at least one candidate existed. A raced connect failure may represent several failed attempts.
A small script makes the result visible on machines where localhost resolves to both families -
import net from 'node:net';
const socket = net.connect(3000, 'localhost');
socket.on('error', err => console.error(err.code));
socket.on('connect', () => {
console.log(socket.remoteFamily, socket.remoteAddress);
});remoteFamily tells you which family won. If the server binds only to 127.0.0.1, a successful IPv4 127.0.0.1 result tells you the IPv4 path won or recovered. If the server binds only to ::1, an IPv6 result tells you the same thing for IPv6. Localhost tests become much less confusing when you print the family.
Inbound From SYN to connection
The server path starts before JavaScript sees anything -
const server = net.createServer(socket => {
console.log('accepted', socket.remoteAddress);
});
server.listen(3000, '0.0.0.0');listen() creates a listening socket in the kernel, with Node and libuv handles attached above it. From that point on, incoming TCP attempts are kernel state first. Your JavaScript callback is stored and waiting.
A normal inbound TCP path looks like this -
client SYN
-> server interface and TCP receive path
-> listening socket match
-> TCP handshake completion
-> accept queue
-> readiness notification
-> libuv I/O watcher
-> accept loop
-> JavaScript connection callbackA readiness notification is the kernel telling the event system that a descriptor can make progress. For a listening socket, readable readiness means there are completed connections waiting to be accepted. The notification does not carry application data into JavaScript. It tells libuv there is socket work to do.
An I/O watcher is libuv state that tracks interest in readiness for a descriptor or platform equivalent. For a TCP server, libuv watches the listening socket because a readable listening socket means accept() can return a connected socket.
When the kernel completes the TCP handshake, the new connected socket waits in the accept queue. The listening socket becomes readable from the event system's point of view. libuv receives that readiness during the event loop's I/O processing. Node's native TCP server code runs, accepts pending connections, wraps each accepted descriptor as a net.Socket, and emits the JavaScript event.
The accept loop is the native loop that pulls completed connections from the listening socket until the kernel says none are immediately available or Node reaches its own per-iteration limits. This loop exists because readiness means at least one connection is available. There may be many.
Under load, this detail becomes visible. A spike of incoming connections can fill the completed accept queue faster than JavaScript callbacks run. The backlog from the previous subchapter controls part of that capacity, with platform caps and SYN queue behavior below it. Once the accept queue is full, new handshakes may be delayed, reset, or dropped depending on OS policy and network conditions.
JavaScript sees the accepted socket after that native work -
server.on('connection', socket => {
console.log(socket.localAddress, socket.localPort);
console.log(socket.remoteAddress, socket.remotePort);
});By the time your callback runs, the socket already has local and remote endpoint metadata. It also already consumes a descriptor. If the callback immediately destroys the socket, the connection still existed. The process accepted it and then closed it.
server.maxConnections and application-level admission checks run above the kernel accept path. They can close a socket or refuse to do work after the process has accepted the connection. They do not change the fact that the TCP handshake already completed. This shows up in metrics - a server can accept connections and close them immediately while clients report resets or early EOFs.
The listening socket and accepted sockets have separate lifetimes -
const sockets = new Set();
const server = net.createServer(socket => {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
});Closing the server stops future accepts. Existing accepted sockets stay open unless your code ends or destroys them. During shutdown, a process often calls server.close() to stop accepting new connections, then drains or terminates the connected sockets it already owns.
There is one timing detail that trips people up. The kernel can complete the TCP handshake and place the connection in the accept queue before Node has run your JavaScript callback. From the client's point of view, connect can succeed during that window. From the server application's point of view, no JavaScript has handled the connection yet. The OS has accepted it for the listening socket. Node still has to pull it from the queue and wrap it.
That behavior explains a common load-test pattern. Clients report successful TCP connects. The server reports fewer application-level accepts at that exact moment. The missing time can come from accept queueing, event-loop delay, process CPU, descriptor pressure, or logging delay. TCP state and JavaScript callbacks are related, but they do not share one timestamp.
Descriptor pressure hits this path too. Each accepted TCP connection consumes a descriptor in the process. If the process is near its descriptor limit, accept can fail even though the listening socket is readable. Node may emit an error on the server or close accepted state depending on where the failure occurs. The client may see a reset or close after handshake. The lower-level fact is simple - the kernel had a connection, and the process could not attach all the user-space state cleanly.
The accept loop also competes with JavaScript work already queued. A burst of completed connections can produce many 'connection' callbacks. Each callback can attach listeners, allocate buffers, start timers, and add the socket to application structures. If the callback does heavy synchronous work, the event loop spends less time returning to I/O readiness. The kernel keeps receiving packets, queues fill, and the next batch of callbacks arrives later.
Keep connection callbacks lean when connection volume is high -
const server = net.createServer(socket => {
socket.setNoDelay(true);
socket.on('error', logSocketError);
handOff(socket);
});Here, logSocketError and handOff are application functions.
The callback sets socket policy, attaches failure handling, and hands the socket to the next part of your program. It does not parse a huge config file, run CPU-heavy authentication, or block on synchronous filesystem work. Higher protocols may need more work soon after accept, but the first callback should stay small under load.
pauseOnConnect gives you another control point -
const server = net.createServer({ pauseOnConnect: true }, socket => {
socket.resume();
});With pauseOnConnect, accepted sockets arrive paused. Node has accepted the descriptor and created the net.Socket, but readable data will not flow into your JavaScript code until the socket resumes. Process managers and handoff patterns can use this to transfer sockets or attach setup before data events begin. The TCP connection already exists. The read side is being held inside Node's stream layer.
The Readiness Path Inside Node
The deeper part sits between the kernel and your JavaScript callback.
libuv does not ask the kernel to run JavaScript. It registers native interest in descriptor readiness, then reports readiness to Node-owned callbacks inside the event loop. The operating system event API changes by platform. Linux commonly uses epoll. macOS and BSD use kqueue. Windows uses IOCP with completion behavior that does not look like Unix readiness. libuv gives Node a common handle and callback model above those platform APIs.
For a server socket on Unix-like systems, the listening descriptor is watched for readability. Readability on a listening socket means accept() can return a connected descriptor without blocking. libuv keeps a watcher associated with the TCP handle. When the event loop reaches the poll provider and the kernel reports the descriptor as readable, libuv runs the native connection callback associated with that handle.
Node's native TCP server code then accepts the connection through libuv. The accepted descriptor comes from the kernel. Node creates the native wrapper for that descriptor, associates it with a JavaScript net.Socket, initializes stream state, and emits the JavaScript event through the server object. The networking handoff is this sequence - readiness becomes accept, accept returns a descriptor, and that descriptor becomes a wrapped stream.
Reads follow the same readiness pattern with other work. A connected socket becomes readable when the kernel receive buffer has bytes, or when TCP state changes in a way the read path must surface, such as peer shutdown. libuv receives readability. Node asks for bytes. The kernel copies bytes into a buffer used by the runtime path. Node pushes those bytes through the readable side of net.Socket. Your 'data' listener or async iterator sees chunks.
TCP chunk structure is not message structure. One TCP segment can become several JavaScript chunks if reads are small. Several TCP segments can become one chunk if bytes have accumulated. Application protocols that need messages have to parse the byte stream above TCP. HTTP parsing belongs to Chapter 10. For raw net.Socket code, your protocol code owns framing.
Writes move in the other direction. socket.write() puts bytes into Node's writable path. If Node can hand them to libuv and the kernel accepts them into the socket send buffer, the write progresses. If user-space buffering grows past the stream threshold, socket.write() returns false. Later, 'drain' tells you the writable side has room again. TCP flow control and kernel send buffers sit below that JavaScript signal, so true from write() means the local writable path accepted the bytes. It does not mean the peer read them.
Shutdown is delivered through this path too. A peer FIN eventually becomes end-of-stream behavior on the readable side. A peer RST usually surfaces as an error or abrupt close. A local socket.end() asks Node to finish writes and send a graceful TCP close. A local socket.destroy() tears down local state more aggressively. The TCP chapter owns the full state machine. For this chapter, keep the path in mind - lower TCP state changes become readiness, libuv reports them, and Node translates them into stream events.
There is latency in that translation. If JavaScript is running CPU-heavy code, the kernel can keep receiving packets and filling buffers while callbacks wait. Readiness may already be recorded, but the event loop has to return to I/O processing before Node can run the handler. That is why networking bugs can look like remote slowness when the process is actually busy above libuv.
The watcher model also explains why one event-loop pass can process several accepts or reads. Readiness says work is available. Native code may loop until the operation would block. Then control returns upward. JavaScript callback order still follows Node's event loop rules, but the work came from descriptor state below JavaScript.
Kernel APIs differ in how they report readiness, and libuv smooths that into its own behavior. The operational idea is still visible. When readiness fires, native code should drain enough work that the descriptor no longer needs immediate service. If it leaves work sitting, the event provider may report readiness again. If it drains too much in one pass, other handles wait longer. Runtime code balances those concerns with per-handle loops and event-loop iterations.
JavaScript only sees the final scheduling point. A 'data' event can mean the kernel had bytes earlier. A 'connection' event can mean the TCP handshake completed earlier. A 'drain' event can mean user-space buffering dropped below the threshold after lower writes progressed. The callback time is when JavaScript observes the event, not when the network event first happened.
That becomes obvious when a process is CPU-bound -
server.on('connection', socket => {
const start = Date.now();
while (Date.now() - start < 200) {}
socket.end('late\n');
});Clients can complete TCP handshakes while the server is stuck in that loop from a previous callback. The kernel can queue them. libuv can deliver their JavaScript callbacks only after control returns to the event loop. A network trace shows packets on time. Application logs show late accepts. Both can be true.
Data After Both Sides Connect
After connect and accept, both processes have connected sockets. From here, the path is mostly byte movement and state changes.
const socket = net.connect(3000, '127.0.0.1');
socket.write('one\n');
socket.write('two\n');The peer may receive one chunk, two chunks, or another grouping. TCP carries ordered bytes. It does not preserve your write() calls as messages. Node streams expose chunks produced by reads from that byte stream. Write calls are sender-side application events, not receiver-side message boundaries.
On the server -
server.on('connection', socket => {
socket.on('data', chunk => {
console.log(chunk.toString());
});
});The chunk is a Buffer from Node's read side. It contains bytes that were available when Node read from the socket. If your protocol uses newline-delimited messages, length prefixes, or fixed-size frames, your code has to parse those. TCP gives ordered bytes. It does not give application messages.
Backpressure crosses several layers here. The receiving process can stop reading from the net.Socket, either directly with pause() or indirectly because downstream work slows down. Node's readable-side buffering grows. The kernel receive buffer may fill. TCP flow control can reduce the sender's usable window. On the sending side, write() may start returning false as Node's writable buffering grows.
Handle that signal before producing more bytes -
if (!socket.write(payload)) {
socket.once('drain', sendMore);
}Here, payload is the next bytes from your protocol code, and sendMore is the continuation that resumes production after Node reports writable room.
That code handles Node stream pressure. It leaves the peer's receive buffer to TCP. It gives the local process a sane rule - stop adding bytes when the writable side says it is backed up, then resume on 'drain'.
Reads can also report teardown -
socket.on('end', () => {
console.log('peer finished writes');
});
socket.on('close', hadError => {
console.log({ hadError });
});'end' means the readable side saw the peer's orderly finish. 'close' means the local socket handle closed. Those events answer separate questions. One is about incoming byte-stream completion. The other is about local resource teardown.
Errors follow the same path. ECONNRESET usually means the peer reset the connection or an intermediate device generated reset behavior. EPIPE can happen when code writes after the peer has closed enough state that the local write cannot continue. The exact event depends on timing, platform, and which operation notices the failure.
The useful debugging move is to place the error on the path. Resolver error. Connect error. Accepted then closed. Read error. Write error. Idle timeout. Reset. Those lead to separate investigations.
There is one more byte-path detail that surprises people. The first application bytes may arrive before your server callback has finished setup.
TCP allows the client to send data as soon as the connection is established. On the server, the kernel can receive those bytes and hold them in the socket receive buffer before JavaScript attaches every listener. Node stream state controls when those bytes move upward. If the socket is in flowing mode because a listener was attached, data events can fire quickly. If the socket is paused, bytes stay buffered until code reads or resumes.
Normal behavior looks like this -
server.on('connection', socket => {
socket.pause();
queueMicrotask(() => socket.resume());
});The pause does not stop the peer from sending. It stops Node from emitting readable data to your JavaScript code until resume. The kernel receive buffer can still fill. TCP flow control can still push back on the sender if the process waits too long. The pause is an application-side flow decision, not a network admission decision.
For servers that hand sockets into protocol parsers, the usual order is simple - accept, attach error and close handling, attach the parser or stream pipeline, then resume if needed. Missing the error handler can crash a process if an 'error' event fires unhandled. Missing parser setup can lose structure if code consumes bytes before parsing them. Node's buffering protects the common setup window, but careless flowing-mode code can still cause trouble.
One Connection, Two Observation Points
The client and server do not observe the same connection at the same instant.
Client code observes its outbound socket. Server code observes an accepted socket. The kernel and network observe packets and TCP state between those two JavaScript objects. A successful client 'connect' event and a server 'connection' event are related, but neither callback is a shared timestamp.
Use a tiny pair -
const server = net.createServer(socket => {
console.log('server accepted');
socket.end('ok\n');
});The server callback fires after the kernel has completed the accept path and Node has wrapped the descriptor. It is later than the TCP handshake. It is earlier than application protocol parsing.
Client side -
const socket = net.connect(3000, '127.0.0.1');
socket.on('connect', () => {
console.log('client connected');
});The client callback fires when the client-side connect path completes. The server process may already have accepted the socket, or the connected socket may still be waiting in the server's accept queue. Both situations can produce a successful client connect.
Add first-byte logging -
socket.on('data', chunk => {
console.log('client read', String(chunk));
});Now the trace has three JavaScript observations - client connected, server accepted, client read. The network path has more events below that - SYN sent, SYN-ACK received, ACK sent, accept queue insertion, libuv readiness, server write into the send buffer, packet transmission, client receive readiness, and JavaScript data delivery. Logs from the two processes can appear in several valid orders because scheduling on each side is independent.
Latency analysis depends on that split. A slow "request" can spend time before connect, during connect, in the server accept queue, waiting for server JavaScript, waiting in the server write path, crossing the network back, or waiting for client JavaScript to read. Higher-level timing often compresses all of that into one duration. Raw socket tracing lets you place the delay more carefully.
For a raw TCP service, the client can mark the point where the connection became usable -
socket.on('connect', () => {
socket.write('hello\n');
});The write happens after the client connect event. The peer may receive those bytes before its application code is ready to parse them because the kernel receive buffer sits below JavaScript. Node stream buffering then controls delivery upward. TCP has no concept of "the server callback finished setup." TCP only knows the connection can carry bytes.
Servers that expect a first message immediately after connect should set up reads before optional work -
server.on('connection', socket => {
socket.on('data', onData);
socket.on('error', onError);
startSession(socket);
});The handlers exist before startSession() runs. If startSession() performs synchronous work, data may still wait in Node or kernel buffers, but the socket already has an error path and a read path attached. For protocols with strict first-message timeouts, start the timer after accept and clear it after enough bytes arrive.
server.on('connection', socket => {
const timer = setTimeout(() => socket.destroy(), 5000);
socket.once('data', () => clearTimeout(timer));
});That timer measures application-level first-byte arrival at the server process. It does not measure DNS, route selection, the client TCP handshake, or the time the connection spent in the server accept queue before your callback. If you need those timings, collect client-side timestamps, server-side accept timestamps, and sometimes kernel or proxy data.
A load balancer adds another observation point. The client may connect to the balancer, and the balancer may connect to the backend. The backend's accept timestamp measures the balancer-to-backend TCP connection, not the original client-to-balancer connection. If the balancer waits for backend selection, health state, or a free upstream connection, the client can see connect success while the backend sees nothing yet. Later HTTP chapters cover headers and proxy behavior. At this layer, the useful fact is that the backend socket may represent a second TCP connection.
The same idea explains why remoteAddress can be accurate and still incomplete. It is accurate for the immediate TCP peer. It may not identify the user or device that started the request several hops away. Treat it as socket truth, not identity truth.
Connection lifetime has two observation points too. The client can call end() and believe it has finished writes. The server may still have unread bytes in its receive buffer. The server can call end() and the client may read the final bytes later. A reset can erase pending delivery. Logs that say "closed at 10:00:00.100" on one side and "read at 10:00:00.120" on the other side can both be plausible when clocks, buffers, and scheduling are involved.
For local debugging, keep a compact timeline -
client lookup start
client connect start
client connect event
server connection event
server first data
client first data
client close
server closeYou rarely need every line in production logs. During a local failure, this sequence shows where the path disappears. Missing client connect points at lookup or connect. Missing server connection points at routing, bind, firewall, backlog, or middleboxes. Missing server first data points at client writes, buffering, or early close. Missing client first data points at the server write path, peer close, or response-side routing.
Middleboxes Change What Each Side Sees
The direct client-to-server path is the easiest one to understand. Production paths often place other systems between the two processes.
NAT, or Network Address Translation, rewrites packet addresses or ports as traffic crosses a network boundary. A client process may bind a local address such as 10.0.0.20:52744, while the server sees a source such as 203.0.113.7:61002. The connection is still TCP across the translated path, but the visible address tuple changes before it reaches the server.
That changes logs -
server.on('connection', socket => {
console.log(socket.remoteAddress, socket.remotePort);
});Those fields show the peer address visible to the server's kernel. Behind NAT, that may be the translated address, not the original client host address. With raw TCP, Node cannot recover the pre-translation address unless a higher protocol or infrastructure passes it along. HTTP forwarded-address headers belong to later chapters.
A firewall is policy that permits, rejects, or drops traffic based on packet fields, connection state, process rules, or host configuration. For the Node process, firewall behavior often appears as a refused connection, a timeout, or traffic that works in one direction but fails in the other. The observed error depends on whether the firewall actively rejects or silently drops.
Proxy hops change ownership more strongly. In a proxy hop, the client connects to an intermediate process, and that process creates or manages another connection toward the next destination. The client TCP connection ends at the proxy. The backend TCP connection begins from the proxy or another proxy layer. HTTP proxying, CONNECT, and reverse proxy behavior belong to Chapter 10. For this chapter, the useful point is enough - the backend sees the proxy as its TCP peer.
A load balancer edge is where traffic enters a balancing system before it reaches a backend process. At the TCP layer, the balancer may pass through connections, terminate and create new ones, or use platform-specific forwarding. Load balancing algorithms come later. Here, the useful facts are smaller. The backend may see the balancer as the peer. The client may connect to an address owned by the balancer. Connection failures can happen before the backend process receives anything.
Source addresses can also change more than once. A laptop behind home NAT connects to a cloud load balancer. The load balancer forwards to a backend. The backend Node process sees the balancer-side address. If the application needs the original client address, that address must be carried above TCP by a protocol or side channel with clear trust rules.
Middle systems also affect timeouts. A client-side timeout may come from the client's own timer. A load balancer may close idle connections. A firewall may drop idle flow state. A backend may destroy sockets during shutdown. The same JavaScript error code can come after several lower-level events, so timing and address evidence become more useful than the code alone.
NAT creates state outside both endpoint processes. The translator remembers how an internal tuple maps to an external tuple. Idle mappings can expire. After expiration, later packets may be dropped or mapped in a new way. Long-lived TCP connections usually rely on real traffic, TCP keep-alive, or application-level pings to keep middle state alive. TCP keep-alive was covered in the previous subchapter. Here it belongs as a path fact - a connection can die because middle state disappears while both endpoint processes still have socket objects.
Firewalls add another ambiguity. A reject policy sends a response that the client stack can turn into a quick error. A drop policy stays silent, so the client waits until its timeout or TCP retransmission policy gives up. Both policies can protect the same port. The timing of the client error often tells you which behavior is more likely.
Proxy hops break endpoint assumptions. The backend server sees the proxy's TCP connection. The original client may be represented only inside protocol metadata, and that metadata has to be trusted according to the deployment's trust model. Raw TCP has no standard field for "original client." Some proxy protocols add one before the application bytes. HTTP uses headers in many deployments. Those details come later. The network-level conclusion is already useful - socket.remoteAddress is the immediate TCP peer.
Load balancers can hide backend absence. A client can connect to the balancer while no healthy backend is available. The client-side TCP connection may succeed, then the balancer may close, reset, or hold the connection depending on product behavior and protocol mode. From the backend Node process, no 'connection' event fires because the connection never reached it. From the client, the remote endpoint was reachable. The failure sits between the balancer and backend.
Middle systems can also affect MTU behavior, route selection, and idle policies. Those details get platform-specific quickly. Keep the debug question smaller - identify the TCP peer your process actually connected to, then identify which middle system could have changed the tuple before it arrived.
Placing Errors on the Path
Error codes are more useful when you attach them to the operation that produced them.
DNS stage -
net.connect(80, 'missing.invalid')
.on('error', err => console.error(err.code));ENOTFOUND means the name did not resolve to a usable answer. EAI_AGAIN points at a temporary resolver failure. Both happen before TCP connects to the target service. Changing server listen code will not fix these.
Bind stage -
net.createServer().listen(3000);
net.createServer()
.on('error', err => console.error(err.code))
.listen(3000);EADDRINUSE means the local bind conflicted with existing socket state. EADDRNOTAVAIL means the requested local address is not available for binding in that host or namespace. These errors belong to server startup or explicit local client binding.
Connect stage -
net.connect(65000, '127.0.0.1')
.on('error', err => console.error(err.code));ECONNREFUSED means the remote stack rejected the connection for that address and port. A common local repro is connecting to a port with no listener. ETIMEDOUT means the connect path did not complete before the timeout policy fired. The cause can be routing, firewall behavior, packet loss, or an unresponsive endpoint.
Accept pressure has no clean single JavaScript error. Clients may see timeouts, resets, or slow connects. Server logs may show fewer 'connection' callbacks than incoming attempts. Local socket-table tools can show queue depths or many half-open states, depending on OS and permissions. The backlog chapter owns queue details. Here, just place the symptom before the JavaScript connection callback.
Read and write stage -
ECONNRESET usually lands during read, write, or idle handling after a connection existed. EPIPE usually lands when writing to a socket whose peer side has already gone away far enough for the local stack to reject the write. Timing changes what you see. A reset can arrive while your code is doing unrelated work, then surface on the next read or write.
Close stage -
'end', 'close', and 'error' are separate signals. A clean peer FIN can produce readable end and then close. A reset can produce error and close. A local destroy can produce close with local intent. Close logs without endpoint addresses and socket state are weak evidence.
Timeout stage -
socket.setTimeout(5_000, () => {
socket.destroy(new Error('idle socket'));
});setTimeout() on a socket is an inactivity timer at Node's socket layer. It is separate from TCP keep-alive probes and separate from a load balancer's idle timeout. When it fires, your callback decides what to do. Destroying the socket creates local teardown, which the peer may observe as an abrupt close depending on pending data and platform behavior.
A useful error report includes the operation, local address, remote address, and stage. "connect to 203.0.113.10:443 timed out from 10.0.0.5" points at a very different investigation from "write to accepted socket reset after 12 minutes idle."
Here is the same placement as a compact table -
stage common signal
lookup ENOTFOUND, EAI_AGAIN
bind EADDRINUSE, EADDRNOTAVAIL
connect ECONNREFUSED, ETIMEDOUT
accept missing callback, reset, slow connect
read/write ECONNRESET, EPIPE, unexpected close
idle socket timeout, keep-alive failure, middlebox closeThe table is not a full list of every possible code. It gives you a starting position for the next command you run. Lookup errors send you to resolver configuration. Bind errors send you to local socket state. Connect errors send you to route, firewall, listener, and address-family checks. Accept pressure sends you to backlog, descriptor limits, CPU, and event-loop delay. Read and write errors send you to peer teardown and protocol state.
One code can appear in more than one place depending on timing. ECONNRESET during connect means the attempt was reset before it became a usable stream. ECONNRESET during a write means an already connected peer or middle system reset the connection. The code matches the reset. The operation tells you where your process observed it.
For raw net.Socket services, add stage context close to the operation -
socket.on('error', err => {
console.error('socket error', {
stage: socket.connecting ? 'connect' : 'connected',
code: err.code,
});
});That small field prevents a lot of false leads. A connect-stage reset points at reachability or listener behavior. A connected-stage reset points at session lifetime, peer process behavior, or middlebox teardown.
Making the Path Visible
Start tracing from inside Node. You usually need less tooling than you think for the first pass -
const server = net.createServer(socket => {
console.log('local', socket.localAddress, socket.localPort);
console.log('remote', socket.remoteAddress, socket.remotePort);
});
server.listen(0, '127.0.0.1', () => {
console.log('listen', server.address());
});Port 0 asks the OS to choose an available port. server.address() prints the bound socket address after listen() succeeds. The accepted socket prints the endpoint tuple visible to the server.
Client side -
const socket = net.connect(server.address().port, '127.0.0.1');
socket.on('connect', () => {
console.log('client local', socket.address());
});The output shows the ephemeral local port selected for the outbound connection. It also confirms the address family and source address the OS chose for that route.
On Linux, ss shows kernel socket table state -
ss -tanpDo not start with every flag. Start with the fields - local address, peer address, TCP state, and process ownership when permissions allow it. A listening socket appears with local address and port. Connected sockets show both endpoints. Many sockets in TIME-WAIT, SYN-SENT, or ESTAB place the process at separate parts of the path.
Routing is visible too -
ip route get 93.184.216.34That command asks the kernel which route it would use for a destination. On Linux, the output commonly includes the selected interface and source address. Compare that source with socket.address() after connect. If they differ because NAT happens later, the Node value still tells you what the local kernel chose.
For DNS, log both the original name and the final socket endpoint. A resolved address list without a connected endpoint leaves out address racing and fallback. A connected endpoint without the original hostname loses resolver context.
socket.on('connect', () => {
console.log({
host: 'example.com',
local: socket.address(),
remote: `${socket.remoteAddress}:${socket.remotePort}`,
});
});Those fields are often enough for local debugging. Packet capture can confirm lower packet flow, but it creates volume quickly and belongs in a narrower debugging task. Start with process logs, socket table state, route lookup, and the exact error stage.
A small end-to-end local trace can be more useful than a large framework test -
const server = net.createServer(socket => {
socket.end('ok\n');
});
server.listen(0, '127.0.0.1', connectBack);The server listens on a kernel-chosen port. The callback runs after bind and listen succeed. At that moment, server.address() has real data.
function connectBack() {
const { port } = server.address();
const socket = net.connect(port, '127.0.0.1');
socket.on('data', chunk => console.log(String(chunk)));
}The client connects through loopback. Route, source address, remote address, connect, accept, read, and close all happen on one host. If this fails, the problem is inside local process or local socket state. If this works and the remote version fails, look at name resolution, routing, firewall policy, middle systems, or remote listener state.
Add endpoint logging once the minimal case works -
socket.on('connect', () => {
console.log('client', socket.address());
console.log('server', socket.remoteAddress);
});For a real remote connection, pair those logs with ss while the socket is established. Process output tells you what Node wrapped. Kernel output tells you what the OS owns. Route output tells you how the host chose the path. Those three views should line up before you start blaming the application protocol.
Containers add another network context. A process inside a container may see another interface list, route table, and local address than the host. 127.0.0.1 inside the container is the container's loopback. A host port mapping or bridge can rewrite the visible path. The raw Node code is the same, but the kernel context around it is not. Run route and socket-table commands from the same network namespace as the process whenever you can.
When you cannot run host tools, log what Node can see - server.address(), socket.address(), socket.remoteAddress, socket.remotePort, and error codes with stages. Those values do not reveal every network hop, but they expose many bad assumptions.
There is one more useful local-trace question - did TCP work, or did the protocol work?
A listening TCP socket can accept a connection before the application is ready to handle the protocol cleanly. A process might be starting, warming caches, loading configuration, or waiting for a dependency. The port is open. The accept path works. The higher-level service may still fail to answer meaningful bytes.
Raw TCP tooling proves only socket-layer reachability -
const socket = net.connect(port, host);
socket.on('connect', () => {
console.log('tcp ok');
});Here, host and port are the exact endpoint under test.
That log proves the outbound and inbound TCP paths completed. It says the client picked a local endpoint, the route worked, the server accepted, and JavaScript saw a connected socket. It says nothing about the parser, request handler, database call, or response rule that comes next.
Server-side readiness checks often blur this line. A port check verifies listener reachability. A protocol check verifies that the service can parse and answer a valid request. A dependency check verifies more of the application graph. Those checks belong to deployment chapters later, but the TCP layer explains why they do not mean the same thing. One uses the path from this chapter. The others start after it.
For a raw service, a tiny protocol check can be enough -
socket.write('ping\n');
socket.once('data', chunk => {
console.log(String(chunk));
});Now the trace crosses into the byte-stream layer. The client sent application bytes. The server read them, applied some protocol rule, and wrote bytes back. Once ping has meaning, the discussion has moved beyond pure TCP and into application protocol behavior.
The Edge Before HTTP
After the inbound accept path completes, Node has a connected TCP byte stream.
That is where this networking foundation stops.
The stream may carry an HTTP request, a PostgreSQL startup packet, a Redis command, a custom binary protocol, or arbitrary bytes. TCP does not know. net.Socket does not know until code above it parses the bytes. The next chapter owns HTTP wire format, request semantics, parsing, agents, pools, proxies, and streaming bodies.
Keep the boundary like this -
DNS resolved
-> TCP connected
-> socket accepted
-> bytes readable
-> protocol parser runsChapter 9 owns everything through "bytes readable on a connected socket." Chapter 10 starts when those bytes have HTTP meaning.