Node.js TCP/IP Networking: OS Sockets & Ports
In Node.js, networking starts with a simple handoff.
Your JavaScript code creates objects, calls methods, reads events, and writes bytes. The operating system owns the actual sockets, routes, buffers, interfaces, packet handling, and connection state. Node and libuv sit in between. They translate your JavaScript operations into operating-system networking calls, then bring the results back as callbacks, events, streams, and errors.
That is the main idea for this chapter. When you call listen(), connect(), or socket.write(), Node starts the operation. The OS decides how the socket behaves, which address is valid, which interface gets used, whether a port is already busy, whether a route exists, and when the socket is ready again.
TCP/IP Networking in Node.js
A lot of common Node networking errors make more sense once you know where the decision came from.
EADDRINUSE usually means the OS could not bind your socket because that address and port are already occupied or unavailable. ECONNREFUSED means the remote endpoint actively rejected the connection setup. Localhost, wildcard binds, IPv4, IPv6, containers, VPNs, and multiple network interfaces all come back to the same lower layer - the host network state.
A listening Node server has two important parts. The first part is the JavaScript object you interact with. The second part is the operating-system socket that actually receives connection attempts.
Before listen(), Node has a server object and a callback. After listen() succeeds, the kernel has a bound listening endpoint attached to your process.
import net from 'node:net';
const server = net.createServer(socket => {
socket.end('hi\n');
});
server.listen(3000, '127.0.0.1');net.createServer() builds JavaScript state. It stores the callback that should run for each accepted connection. It also prepares a server object with stream and event behavior.
listen() is the point where that setup becomes real operating-system socket state. Node asks libuv to create a TCP handle. libuv asks the OS for a socket. The OS creates kernel socket state, assigns a descriptor to the process, binds the socket to 127.0.0.1:3000, then marks it as a listening socket.
One line creates state in several places.
The process now has JavaScript objects inside V8, native objects inside Node and libuv, and socket state inside the kernel. These pieces stay connected through handles and descriptors. JavaScript sees a net.Server. libuv watches for readiness. The kernel owns the socket that receives connection attempts.
server.on('listening', () => {
console.log(server.address());
});server.address() shows the part Node can expose cleanly - the address, port, and address family. The deeper state lives outside the JavaScript object. You can remove listeners, keep references around, or close the server object. The kernel socket table still owns the actual bind state while the descriptor remains open.
The kernel socket table is the operating system's bookkeeping for sockets in the current host or container network context. It tracks listening sockets, connected sockets, local and remote addresses, protocol state, buffers, and descriptor ownership. Node reads and changes that state through native calls.
A syscall transition happens when user-space code asks the kernel to do work. JavaScript does not call the kernel directly. It goes through Node's native layer and libuv. Those native layers call platform networking APIs such as socket(), bind(), listen(), accept(), connect(), read(), write(), and close(), with platform-specific calls underneath.
That handoff is the reason Node networking can feel simple at the surface and still produce very OS-shaped errors. Node gives you a JavaScript object. The OS owns the socket primitive.
The Edge Below listen()
On Unix-like systems, a socket consumes a file descriptor. Earlier file-system chapters covered descriptors for files. The same process-level idea applies to sockets. The descriptor is a small integer owned by the process. The target object is no longer an open file. It is a kernel socket object.
The call path for the server above looks roughly like this -
net.Server.listen()
-> Node TCP binding
-> libuv TCP handle
-> OS socket
-> bind 127.0.0.1:3000
-> listenThe names vary across operating systems, but the ownership pattern stays the same. JavaScript keeps a server object. Node's native layer keeps a wrapper that can talk to libuv. libuv keeps a handle that integrates with the event loop. The operating system keeps the socket state and tells libuv when something changes.
For network I/O, JavaScript runs after lower layers report readiness. The kernel notices a socket state change. libuv observes it. Node turns that readiness into callbacks and stream events.
For a listening TCP server, the kernel can hold completed incoming connections until the process accepts them. Node performs the accept work in its native path, wraps each accepted socket in a JavaScript net.Socket, then emits the connection event. By the time your callback runs, the OS has already created the connected socket.
const server = net.createServer(socket => {
console.log(socket.remoteAddress, socket.remotePort);
});
server.listen(3000, '127.0.0.1');The socket passed to the callback is a JavaScript wrapper around a connected OS socket. remoteAddress and remotePort come from the peer. localAddress and localPort describe your side of the connection.
Descriptors make some network bugs feel familiar. A process can run out of descriptors. A socket can remain open because a JavaScript object or native handle still owns it. A server can fail to bind because another process already owns the endpoint in the kernel socket table. Node reports the error, but the OS made the decision.
import net from 'node:net';
net.createServer().listen(3000);
net.createServer().listen(3000).on('error', err => {
console.error(err.code);
});The second server usually reports EADDRINUSE. The OS rejected the bind because the requested local socket address conflicted with existing socket state. Socket options add more nuance later, but the first lesson is enough here - bind errors are kernel answers surfaced through Node errors.
A listening TCP socket moves through a few states worth keeping in your head -
created socket
-> bound local address
-> listening socket
-> accepted connected socketA created socket has a protocol family and type. For this server, that means an IPv4 or IPv6 TCP socket. Before binding, it has no local port attached to it. After bind, the kernel has attached a local socket address. After listen, the socket accepts connection attempts for that address. After accept, the kernel returns a new connected socket descriptor while the original listening socket keeps listening.
That is why net.Server and net.Socket represent separate OS objects. The server object wraps the listening socket. The socket passed to your connection callback wraps an accepted connected socket. Closing an accepted socket ends that one connection. Closing the server stops accepting new connections. Existing accepted sockets can keep running unless your code closes them too.
const sockets = new Set();
const server = net.createServer(socket => {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
});That set is ordinary JavaScript ownership around lower socket state. The set tracks accepted socket wrappers. The kernel tracks connected socket objects. During shutdown, servers often close the listening socket first, then drain or close accepted sockets. Those are separate operations because they target separate descriptors.
The listening socket also has queue state below JavaScript. When TCP connection setup finishes, the OS can hold accepted connections until the process accepts them. Node's native accept loop pulls from that kernel state when libuv reports readiness. Backlog and accept-queue behavior come later, but one rule belongs here - a connection event means the kernel already has a connected socket for that peer.
Your callback runs after that lower work. By then, the remote endpoint is known, the local endpoint is known, and the connected descriptor is open. Your code can read, write, pause, destroy, or hand the socket to another part of the program. It cannot undo the accept that already happened.
Network writes make the same handoff visible.
socket.write(Buffer.from('hello\n'));socket.write() accepts bytes from JavaScript. The stream layer may buffer before native code receives them. Node hands those bytes to libuv. libuv asks the OS to send them on the socket. The kernel may copy the bytes into a send buffer and return before the peer receives anything. Later, the TCP/IP stack packages those bytes and sends them through an interface when routing and link-layer state allow it.
Now we can name the pieces involved in that lower path.
The Stack Shape Node Depends On
The TCP/IP stack is the operating system's implementation of Internet networking. It owns transport protocol state, IP addressing, route selection, packet construction, link-layer handoff, receive processing, and the socket interface exposed to processes.
For backend Node work, this is the useful path -
JavaScript bytes
-> TCP segment or UDP datagram
-> IP packet
-> Ethernet frame or another link-layer frame
-> network interfaceApplication bytes are the bytes your code writes. They might come from a string, a Buffer, a serialized request, a response body, or a custom protocol message.
A packet is a bounded unit of data handled by the network stack. Developers often use the word loosely, but debugging gets easier when you name the units more carefully.
A TCP segment is a TCP protocol unit. It contains TCP header fields plus a slice of application bytes. TCP owns connection state, ordering, retransmission, and flow control. When a Node TCP socket sends bytes, those bytes eventually become one or more TCP segments.
A UDP datagram is a UDP protocol unit. It contains a UDP header and one message payload. UDP belongs to a later chapter, but the term is useful here because it sits at the same transport layer as TCP segments.
An IP packet is an IP header plus transport payload. For TCP, the payload is a TCP segment. For UDP, the payload is a UDP datagram. The IP header carries source and destination IP addresses and the metadata needed to move the packet toward the destination.
An Ethernet frame is a link-layer unit used on Ethernet networks. It wraps an IP packet with link-layer headers and a trailer for delivery on the local link. Wi-Fi and other link types have their own frame formats, but Ethernet names show up often in packet captures, MTU conversations, and Linux tools.
IPv4 and IPv6 use separate packet header formats. For this chapter, their role is the same - source address, destination address, protocol metadata, and payload. You do not need to memorize every header field to write Node networking code, but you do need to care about address family because IPv4 and IPv6 use separate addresses, socket structures, route tables, and packet headers.
TCP presents a byte stream to the peer. Your application does not receive one callback per TCP segment. One socket.write() can become many segments. Several writes can also arrive at the peer as one data chunk. Node's data events come from stream reads out of receive buffers. They do not mirror your write calls.
UDP keeps message framing at the socket API. One send call creates one UDP datagram payload, subject to size limits and IP behavior. The full UDP chapter covers the consequences. For now, keep this simple rule in mind - TCP carries a byte stream, UDP carries individual messages.
The IP layer treats both as payload. It does not care whether Node wrote an HTTP request, a Redis command, a custom binary protocol, or an empty payload. It sees a transport protocol number, source and destination IP addresses, and bytes that need to move toward the next hop.
Layer names can get academic fast, so keep the backend path practical. Node hands bytes to a socket. The socket uses a transport protocol. The transport protocol uses IP for host addressing. IP uses a route and a network interface to leave the machine. A local link format carries the packet across the immediate network segment.
For a TCP write, the path is roughly -
socket.write(Buffer)
-> Node stream and native write queue
-> kernel TCP send buffer
-> TCP segment
-> IP packet
-> interface transmit queue
-> link-layer frameBackpressure appears here too. A Node writable stream can signal that its own queue crossed highWaterMark. The kernel socket also has a send buffer with finite space. TCP can also apply flow control based on the peer's receive window. These are separate pressure points owned by separate layers.
JavaScript sees socket.write() return false when the Node stream layer wants the producer to wait for drain. That signal only tells you about Node's writable-side buffering. The peer may still have received nothing. The packet may still be waiting inside the local network stack. The stream is telling your producer to slow down.
The kernel's TCP send buffer sits lower. Node can hand bytes to the OS and get a successful local write because the kernel accepted those bytes into its buffer. The bytes may still be waiting for segmentation, congestion window space, link availability, or retransmission. A successful socket write means the local stack accepted bytes for transmission. It does not mean the remote application read them.
Receives move upward through the same owners in reverse. A network interface receives a frame. The link-layer code extracts the IP packet. The IP layer checks destination addressing and protocol. TCP or UDP receives the transport unit. A matching socket gets data or state. The kernel reports readability. libuv sees readiness. Node reads bytes and pushes them into a JavaScript stream.
interface receive
-> link-layer frame
-> IP packet
-> TCP segment
-> socket receive buffer
-> libuv readiness
-> net.Socket dataNode starts at the top of that path. Many networking bugs start lower.
Interfaces and Local Addresses
A network interface is one way the host can send and receive packets. It can be physical hardware, a virtual device, a tunnel, a bridge, a container interface, or the loopback interface. The OS attaches addresses and link-layer properties to interfaces.
Node exposes interface data through node:os -
import os from 'node:os';
for (const [name, entries] of Object.entries(os.networkInterfaces())) {
console.log(name, entries.map(e => `${e.address}/${e.family}`));
}The output depends on the machine. You will usually see a loopback interface and one or more non-loopback interfaces. Laptops have Wi-Fi. Servers may have several network cards. Containers may see virtual interfaces. VPNs add tunnel interfaces. Cloud hosts expose names chosen by the guest OS, not the provider's dashboard.
An IP address is the network-layer address assigned to an interface or used as a destination. Node code usually treats it as a string. The OS treats it as structured address data with an address family.
One interface can have several addresses. One host can have several interfaces. IPv4 may be enabled on one interface and absent from another. IPv6 may be present alongside IPv4. That is normal host state, and Node inherits it.
os.networkInterfaces() may return entries shaped like this -
{
address: '127.0.0.1',
netmask: '255.0.0.0',
family: 'IPv4',
internal: true,
cidr: '127.0.0.1/8'
}The cidr and netmask fields describe the local address range attached to the interface. Subnet design is outside this chapter, but the practical effect is simple. The OS can tell whether a destination is on a directly connected network by comparing it against interface routes derived from these address ranges.
On a normal workstation, your Wi-Fi interface might have an IPv4 address such as 192.168.1.25/24. A destination such as 192.168.1.40 likely matches the same local network route. A destination such as 203.0.113.10 likely uses the default route. Your Node code names the destination. The OS applies the address and route state.
IPv4 is the 32-bit address family written in dotted decimal form, such as 127.0.0.1 or 192.0.2.10. IPv6 is the 128-bit address family written in hexadecimal groups, such as ::1 or 2001:db8::10. Both can exist on the same host at the same time.
The loopback interface is local to the host. Traffic sent to loopback addresses stays inside the host's network stack. The common IPv4 loopback address is 127.0.0.1. The IPv6 loopback address is ::1.
server.listen(3000, '127.0.0.1');Binding to 127.0.0.1 exposes the server on IPv4 loopback. Other machines cannot reach that address on your host because loopback is host-local. Another process on the same host can connect to it.
server.listen(3000, '0.0.0.0');0.0.0.0 is the IPv4 wildcard bind address. It tells the OS to accept connections for that port on suitable IPv4 local addresses. The local address for each accepted connection depends on which interface address the client used.
This is a common source of local-only bugs. A process bound to 127.0.0.1 can pass every local test and still be unreachable from another container, another VM, or another host. A process bound to 0.0.0.0 may be reachable through every IPv4 address assigned to the host, subject to firewall and routing policy.
IPv6 adds another common surprise -
server.listen(3000, '::1');::1 is IPv6 loopback. It is a separate address family from 127.0.0.1. A client connecting to IPv4 loopback reaches IPv4 sockets. A client connecting to IPv6 loopback reaches IPv6 sockets. Dual-stack behavior and IPv6-only options come later. For now, treat the address family as part of the endpoint.
localhost adds name resolution. On many machines, it resolves to both ::1 and 127.0.0.1, with ordering controlled by OS and runtime policy. DNS and lookup ordering belong to the next chapter. Here, use numeric addresses when you want to understand the socket handoff without name-resolution noise.
Address family selection can change the error you see. A server bound to 127.0.0.1 has an IPv4 listener. A client trying ::1 targets IPv6 loopback. The port number can match and the connection can still fail because the address family points at another socket-table entry. When a bug report says "port 3000 is open," ask which address family and which local address.
Interfaces also matter for outgoing connections. Code usually provides a remote address and port -
import net from 'node:net';
const socket = net.connect(80, '93.184.216.34');
socket.on('connect', () => {
console.log(socket.localAddress, socket.localPort);
});Unless you provide them, the OS chooses the local address and local port. The local address usually comes from the outbound interface selected by route lookup. The local port is usually ephemeral. Together, they form the local endpoint for the connection.
Binding and connecting answer separate questions. A server bind chooses where the process receives traffic. A client connect chooses a remote endpoint, then the OS chooses a local endpoint that can reach it.
Ports and Socket Addresses
A port is a 16-bit transport-layer number used by TCP and UDP. It identifies a local endpoint within an IP address and protocol. Ports range from 0 through 65535. Operating systems usually reserve low ports for privileged use or policy-controlled binding. The exact rule depends on the OS and configuration.
A socket address is an IP address plus a port, with an address family. In Node, you usually see it as { address, port, family }, or as separate host and port arguments. At the OS boundary, it becomes structured binary data passed into calls such as bind() and connect().
server.listen({ host: '127.0.0.1', port: 3000 }, () => {
console.log(server.address());
});The local socket address is 127.0.0.1:3000 in the IPv4 family. For a listening server, that address is where the OS accepts incoming connection attempts. For a connected socket, there are two endpoints - local and remote.
A TCP connection is commonly identified by these fields -
protocol
local IP
local port
remote IP
remote portThe protocol belongs there because TCP and UDP use separate transport spaces. The local and remote sides belong there because one server port can have many connected TCP sockets at the same time. A web server can listen on local 0.0.0.0:443, then accept many connections from many remote IP and port pairs. Each accepted connection gets its own tuple.
socket.on('connect', () => {
console.log(socket.localAddress, socket.localPort);
console.log(socket.remoteAddress, socket.remotePort);
});The local endpoint identifies your side. The remote endpoint identifies the peer. TCP uses the full tuple to tell connections apart. A single Node process can have many outbound connections to the same remote server because each connection gets its own local ephemeral port.
An ephemeral port is a temporary local port selected by the operating system for an outbound connection or for a bind to port 0. The OS picks from a configured ephemeral range and tracks active use in the socket table.
const server = net.createServer();
server.listen(0, '127.0.0.1', () => {
console.log(server.address());
});Port 0 asks the OS to choose an available port. Test code uses this a lot because it avoids hard-coding 3000 across parallel runs. The selected port still lives in the kernel socket table. Read it only after the server is listening, then pass that exact value to clients.
Ephemeral ports also show up on the client side -
const socket = net.connect(3000, '127.0.0.1');
socket.on('connect', () => {
console.log(socket.localPort);
});The printed local port came from the OS. If a test suite creates thousands of short-lived outbound connections quickly, it can put pressure on the ephemeral port range. TCP state after close can keep ports unavailable for reuse for a while. The TCP chapter covers TIME_WAIT and teardown, but the first symptom often appears here - local tests fail with address or connect errors even though the remote server is fine.
Port ownership depends on protocol and address family. A TCP listener and a UDP socket use separate transport protocols. IPv4 and IPv6 can also have separate bind behavior depending on socket options and platform defaults. A wildcard bind can conflict with a specific-address bind because the OS has to decide which socket would receive traffic for the same address and port.
Use exact addresses while debugging binds. 127.0.0.1:3000, ::1:3000, and 0.0.0.0:3000 are separate local binding requests. localhost:3000 is a name plus a port, and the name may resolve to more than one address.
Privileged ports are host policy. On Unix-like systems, binding ports below 1024 has traditionally required elevated privileges, though capabilities and container settings can change that. Node does not grant extra privilege. It calls the OS and reports the result. A bind to 80 can fail because of permission policy even when no process owns the port.
Port 0 has one special bind meaning - ask the OS to allocate a real port. Port 0 is not a normal reachable service port for clients. After the bind succeeds, the socket has a real port from the OS, and server.address().port is the value to use inside your process or test.
The Kernel Path for One Write
One write gives a useful trace.
socket.write('GET / HTTP/1.0\r\n\r\n');The string becomes bytes using the stream's encoding rules. Node accepts those bytes into the writable side of net.Socket. If the write can proceed, Node creates or extends native write state and asks libuv to submit the operation. libuv uses the OS socket descriptor. The call reaches the kernel. The kernel receives a pointer to user-space memory and a byte length, then copies or stages those bytes according to the platform path.
After the system call returns, JavaScript continues. A write callback, when supplied, reports local write completion. It does not mean the remote process has read the data. The remote process has its own receive buffers, scheduling, and application code.
The kernel socket object tracks the protocol, local endpoint, remote endpoint when connected, send buffer, receive buffer, error state, and protocol-specific fields. For TCP, connected state also includes sequence tracking, retransmission state, timers, congestion state, and peer window information. Those TCP fields come back later. For now, keep the handoff clear - Node owns the JavaScript object and native wrapper, while the kernel owns transmission state.
Segmentation happens below Node. Your socket.write() call can pass 20 bytes or 200 KiB. TCP decides how to segment that byte stream under current network constraints. The IP layer wraps each segment in an IP packet. The link layer frames the packet for the selected interface. The network interface driver queues it for transmission.
Routing is already involved before a packet leaves. For a connected TCP socket, the OS has selected a route to the remote address. That choice affects the source address, outbound interface, and next hop. If the remote address is loopback, the path stays inside the host. If the remote address is on a directly attached network, the packet goes through that interface to the peer's link-layer address. If the remote address is elsewhere, the packet goes to a gateway chosen by the routing table.
The receive side follows the same ownership pattern in the other direction. Node can read only after the kernel has accepted data into the socket receive buffer and reported readability. If JavaScript stops reading, data can collect in Node stream buffers and kernel receive buffers. TCP may reduce the advertised receive window, which slows the peer. Backpressure crosses layers, but each layer uses its own signal.
Errors move upward too. A failed route lookup can produce a connection error. A peer reset can surface as a socket error. A write after teardown can report a broken-pipe style error. The exact code depends on platform and timing. Node carries those errors into JavaScript with code values, but the cause is often a kernel socket transition that happened before your callback ran.
UDP has its own path because UDP keeps one payload per datagram. The stack shape still applies. Node hands bytes to a datagram socket. The OS creates UDP datagrams. IP packets carry them. An interface sends frames.
Routing Decides the Interface
A routing table is the host's rule set for choosing where an IP packet goes next. It maps destination address ranges to local delivery, an interface, or a next-hop gateway. The kernel checks it for outbound packets.
On Linux, ip route shows the IPv4 table -
ip routeTypical output includes a default route plus local network routes. The default route is used when no more specific route matches the destination. It usually points at a gateway through one interface.
The exact output depends on the host, but the shape often looks like this -
default via 192.0.2.1 dev wlan0
192.0.2.0/24 dev wlan0 proto kernel src 192.0.2.10The second line says the host can reach 192.0.2.0/24 directly through wlan0, using 192.0.2.10 as the preferred source address for that route. The default line says other IPv4 destinations go to 192.0.2.1 through wlan0.
Route lookup prefers the most specific matching route. A loopback destination matches a local loopback route. A local subnet destination matches the directly connected network route. A public remote address likely matches the default route on a small host. Servers, containers, VPNs, and policy routing can add more rules, but the local model stays simple - destination address in, route result out.
Linux can show the route result for one destination -
ip route get 93.184.216.34That command often prints the selected interface, source address, gateway, and cache-related fields. It shows the same kind of decision the kernel makes during connect.
IPv6 has its own route table -
ip -6 routeDual-stack hosts can have working IPv4 routing and broken IPv6 routing, or the other way around. Node sees both as network operations. The address family picked before routing decides which table the OS uses.
For a normal net.connect() call, the kernel chooses the outbound interface. Node asks to connect to a remote socket address. The OS picks a source address and route based on the destination and any local bind settings.
const socket = net.connect({
host: '93.184.216.34',
port: 80,
localAddress: '192.0.2.10'
});localAddress constrains the source address. The OS still checks that the address exists on the host and can be used for that route. If the local address is absent, the connect fails. If the address exists but policy blocks the route, the connect still fails.
Loopback routing stays inside the host stack -
net.connect(3000, '127.0.0.1');The destination matches loopback. The packet path uses host-local delivery instead of a physical network card. The socket still uses TCP. The kernel still tracks endpoints. Link-layer resolution and external network devices are not involved because the destination belongs to the same host.
Container networking makes this behavior easier to see. Inside a container, 127.0.0.1 means the container's own network namespace. A service bound to loopback inside one container is local to that namespace. Reaching another container or the host requires an address and route that leave that namespace. Kubernetes details come later, but this rule is already useful - loopback belongs to the network namespace doing the lookup.
Wildcard binds interact with routing on the receive side. A server bound to 0.0.0.0:3000 can accept connections addressed to any suitable local IPv4 address. The incoming packet's destination address becomes the local address for that accepted connection. server.address() on the listening server may print 0.0.0.0, but each accepted socket has its own localAddress.
net.createServer(socket => {
console.log(socket.localAddress, socket.localPort);
}).listen(3000, '0.0.0.0');That callback prints the address the client reached. On a host with several interfaces, clients can produce several local addresses on the same listening server.
Routing failures often show up as timeouts, unreachable errors, or connections that never reach the server you expected. Use the destination address, not the URL string, when checking route behavior. Name resolution can choose an address family first, then routing decides how that address moves. The DNS chapter covers the name-resolution half.
Route lookup also explains source-address surprises on hosts with several active interfaces. A process can run with Wi-Fi, Ethernet, VPN, and container interfaces at the same time. The destination address chooses a route. The route chooses a preferred source address. If application logs record only the remote URL, that lower choice disappears. Recording socket.localAddress during connection setup makes the chosen route visible without packet capture.
Local delivery is also a route result. If the destination is one of the host's own addresses, the kernel can deliver internally. That can happen beyond 127.0.0.1. Connecting to the machine's own non-loopback address from the same machine may still stay inside local delivery paths depending on OS behavior. Inspecting local and remote socket fields is better than guessing from interface names.
ARP, Neighbor Discovery, and MTU
Routing chooses an interface and maybe a next hop. The link layer still needs a delivery target on the local link.
ARP, the Address Resolution Protocol, maps an IPv4 address on the local link to a link-layer address such as a MAC address. If the route says an IPv4 packet should go directly to a peer or gateway on an Ethernet-like link, the host needs the link-layer address for that next hop. ARP supplies it and caches the result.
Neighbor Discovery is the IPv6 mechanism that handles neighbor address resolution and related local-link tasks. For this chapter, its debugging role is similar to ARP - IPv6 local delivery needs neighbor information before frames can be sent on the link.
Node usually sees ARP and Neighbor Discovery through symptoms. A connect call may wait while the OS tries to resolve the next hop. A packet capture may show ARP requests before TCP packets. A route can be correct while the next-hop neighbor is unreachable. JavaScript sees delayed connection progress or an error after the OS gives up.
MTU means Maximum Transmission Unit. It is the largest packet size a link can carry at that layer without fragmentation. Ethernet commonly has an MTU of 1500 bytes for IP packets, though many environments use other values.
MTU shows up because Node writes byte streams, while the network sends bounded units. A large socket.write() does not become one huge packet. The stack breaks bytes into pieces that fit transport, IP, and link constraints. If a packet is too large for some path segment, it may be fragmented or dropped with an error signal, depending on protocol, flags, and network behavior.
Backend developers usually meet MTU through production symptoms. Small requests work. Larger payloads stall. VPN paths behave another way. Packet captures show retransmissions around a payload-size threshold. Node sees the result as slow writes, stalled reads, connection resets, or timeouts. The fix may sit below Node in interface MTU, tunnel settings, firewall policy, or path MTU discovery behavior.
Application payload size and packet size can be very far apart -
socket.write(Buffer.alloc(64 * 1024));That write submits 65,536 bytes to the socket path. Ethernet with a 1500-byte MTU cannot carry that as one IP packet. TCP segmentation and offload features may affect what packet capture shows, and the interface driver may perform some work late in the transmit path. The JavaScript byte count remains the application byte count.
Packet capture tools can confuse this further because modern network cards and kernels use offloads. A capture taken before segmentation offload may display large pseudo-packets that exceed the physical MTU. A capture taken elsewhere may show smaller on-wire frames. Treat each capture as an observation from one point in the stack.
Keep packet-capture vocabulary precise when you debug this layer -
Ethernet frame
-> IP packet
-> TCP segment
-> application bytesIf someone says "the packet is 1514 bytes on the wire," they may include Ethernet framing. If someone says "MTU 1500," they usually mean the IP packet size on Ethernet. If someone says "TCP payload," they mean application bytes inside the TCP segment after TCP and IP headers. The numbers vary because each layer adds headers.
Node's Buffer length is application byte count. It is not packet size. A 64 KiB Buffer may become many TCP segments. Several small writes may also be combined lower down depending on buffering and TCP behavior. Nagle, delayed ACK, and socket options come later. The current lesson is simple - application byte ranges and packet framing are owned by separate layers.
ARP and Neighbor Discovery also use caches. A host usually resolves a next hop once, stores the result, and reuses it until the cache entry expires or changes. The first connection attempt to a peer can pay resolution cost while later attempts avoid it. Stale neighbor state can also create failures that clear after cache expiration or interface changes. Node has no special net API for that cache. Use OS tools when the symptom points below IP routing.
Observing the Host From Node
os.networkInterfaces() gives JavaScript a view of interface addresses. It does not show the full routing table, neighbor cache, or socket table. It is still a good first check because it shows which addresses this host currently exposes to Node.
import os from 'node:os';
console.dir(os.networkInterfaces(), { depth: null });Each entry includes fields such as address, netmask, family, mac, internal, and CIDR information where available. internal: true marks loopback-style addresses. The family value tells you whether the address is IPv4 or IPv6.
That data helps explain bind behavior -
import net from 'node:net';
const host = process.argv[2] || '127.0.0.1';
net.createServer().listen(3000, host, () => {
console.log(`listening on ${host}:3000`);
});Run it with 127.0.0.1, then with an actual interface address from os.networkInterfaces(), then with 0.0.0.0. The server code barely changes. The kernel bind request changes completely.
Port conflicts need a socket-table view. On Linux, ss can show listening TCP sockets -
ss -ltnpThe output names local addresses and ports. With permissions, it can also show process information. That is the view you want when Node reports a bind error. Start with the socket address already present in the kernel table, then find which process owns it.
Node can show its own chosen address after binding -
const server = net.createServer();
server.listen(0, '127.0.0.1', () => {
console.log(server.address());
});That pattern is better for parallel tests than fixed ports. Let the OS choose. Read the selected port. Pass it to the client. Close the server when the test ends. Fixed ports make tests depend on global host state.
Remote connections expose the local state selected by routing -
const socket = net.connect(80, '93.184.216.34');
socket.on('connect', () => {
console.log(socket.localAddress, socket.localPort);
socket.end();
});The local address came from route selection. The local port came from ephemeral allocation. If you run the same snippet on Wi-Fi, a VPN, a container, and a CI runner, the values can change because the host's interface and routing state changed.
Accepted sockets show both sides of inbound state -
net.createServer(socket => {
console.log({
local: `${socket.localAddress}:${socket.localPort}`,
remote: `${socket.remoteAddress}:${socket.remotePort}`
});
}).listen(3000, '0.0.0.0');That log is usually better than a generic "client connected" line. It records which local address received the connection and which remote endpoint the kernel reported. In environments with proxies, NAT, or port publishing, those fields may show the immediate peer at this layer. Later chapters handle proxy headers and higher-level identity.
Errors need the same endpoint discipline -
server.on('error', err => {
console.error(err.code, err.address, err.port);
});Bind errors often include the attempted address and port. Log them. A service that only prints "failed to start" throws away the exact socket address the OS rejected.
A few local errors point back to the socket layer -
bind failed
-> local address absent, busy, or blocked by policy
connect failed
-> remote path, peer state, or local route problem
write failed
-> connected socket state changed below JavaScriptThe exact code value depends on the operation and platform. EADDRINUSE on bind points at an address already occupied in the socket table or made unavailable by socket state and options. EADDRNOTAVAIL points at a local address the host cannot use for that bind or connect request. ECONNREFUSED on connect means the destination host actively rejected the connection attempt at the transport layer, commonly because no listener accepted that endpoint. Later subchapters give these errors their full TCP and socket-option context. At this level, they all tell you that the OS rejected or changed the socket operation below JavaScript.
Print the fields Node gives you -
socket.on('error', err => {
console.error({
code: err.code,
address: err.address,
port: err.port
});
});The fields are sometimes absent because the error surfaced after the operation moved beyond the original address arguments. When they exist, they tell you which endpoint request failed. Log the endpoint, then inspect the host state that owns it.
Loopback gives a clean local trace -
const server = net.createServer(s => s.end('ok\n'));
server.listen(0, '127.0.0.1', () => {
const { port } = server.address();
const client = net.connect(port, '127.0.0.1');
client.on('close', () => server.close());
client.pipe(process.stdout);
});No remote network is needed. The server binds to IPv4 loopback on an ephemeral port. The client connects to that exact socket address. TCP still runs. The accepted socket still has local and remote endpoints. Routing selects loopback. Link-layer resolution stays out of the path.
IPv6 loopback needs its own address -
const server = net.createServer(s => s.end('ok\n'));
server.listen(0, '::1', () => {
const { port } = server.address();
const client = net.connect(port, '::1');
client.on('close', () => server.close());
client.pipe(process.stdout);
});If this fails on a machine, inspect IPv6 availability and local policy before blaming Node. ::1 and 127.0.0.1 are separate socket addresses. localhost may choose either one after lookup.
A simple debugging path at this layer works well -
numeric destination address
-> address family
-> local bind address, if any
-> route result
-> socket table entry
-> interface and neighbor stateStay numeric until the route and bind behavior make sense. Add names later. DNS adds another moving piece, and the next chapter gives it its own path.
Wildcards, Localhost, and Other Sharp Edges
Wildcard bind addresses are receive-side instructions.
0.0.0.0 means all suitable IPv4 local addresses for that port. :: means the IPv6 unspecified address. Depending on platform and socket options, an IPv6 wildcard socket may or may not also accept IPv4-mapped connections. Treat that as platform and option dependent. Dual-stack behavior belongs to the socket-options chapter.
The listening server's address can look less specific than the traffic it accepts -
const server = net.createServer(socket => {
console.log('accepted on', socket.localAddress);
});
server.listen(3000, '0.0.0.0');server.address() reports the listening bind address. socket.localAddress on each accepted socket reports the local address used for that connection. Use the accepted socket when you need to know which interface address the client reached.
localhost is a local hostname that resolves to one or more addresses. It usually resolves through host files and resolver policy, and it may produce both IPv6 and IPv4 answers. If your server binds only to 127.0.0.1 and your client tries localhost, the client may try ::1 first and fail before trying IPv4, depending on lookup behavior and client policy. The next chapter covers that lookup behavior. The bind-side lesson is already visible - match the address family you intend to use, or bind in a way that supports both families under your platform policy.
Ephemeral port exhaustion is another local-state bug. It appears when a process or test suite creates outbound connections faster than the OS can recycle local ports. The remote service can be healthy while the local machine is out of usable endpoint combinations. Symptoms vary by OS and timing, but the root cause is the same - the local endpoint tuple needs a unique ephemeral port, and the kernel socket table still has too much active or recently closed state.
Tests that bind servers to port 0 avoid fixed listen-port conflicts, but they can still create many outbound client sockets. Closing the server does not automatically drain every client connection. A test that starts and tears down hundreds of local TCP connections should close accepted sockets, wait for close events, and avoid assuming a port becomes reusable the instant JavaScript drops a reference.
Containers add address-scope problems. A service inside a container bound to 127.0.0.1 listens inside that container's namespace. Publishing a port through a container runtime does not automatically make a loopback-only service listen on every container interface unless the runtime or proxy creates a forwarding path. Binding to 0.0.0.0 inside the container exposes the service on the container's IPv4 interfaces, then host publishing rules decide what reaches it from outside.
VPNs and multiple interfaces add route surprises. A destination that worked yesterday may use a tunnel route today. The Node code did not change. The routing table did. socket.localAddress is often the first clue because it shows the source address selected for the connection. If that address belongs to a VPN or container interface, route lookup is already steering traffic away from the interface you expected.
Packet size bugs rarely announce themselves as MTU bugs. They look like partial progress. Small payloads succeed. Larger payloads hang or reset. TLS and HTTP can make the symptom look higher-level, but the lower path may involve link MTU or fragmentation behavior. Keep MTU in the candidate set when payload size lines up with the failure, especially across tunnels.
Three Paths From the Same Code
The same Node call can take several host paths depending on the destination address.
import net from 'node:net';
const socket = net.connect(3000, process.argv[2]);
socket.on('connect', () => socket.end('ping\n'));Run that code with 127.0.0.1, with another address on the same local network, and with a remote public address. The JavaScript code stays the same. The socket address changes. The kernel path after connect() changes with it.
For IPv4 loopback, the route result is local -
127.0.0.1:random -> 127.0.0.1:3000
-> loopback route
-> local TCP processing
-> peer socket in the same hostNo ARP. No gateway. No physical interface transmission. The packet still goes through IP and TCP logic, but the host delivers it internally. That path is why loopback tests are fast and stable compared with remote tests. It is also why loopback success proves only a limited thing. It proves the process can bind, route locally, and speak through the local TCP stack.
For a destination on the same local network, route lookup usually selects a directly connected interface -
192.0.2.10:random -> 192.0.2.40:3000
-> route matches local subnet
-> ARP for 192.0.2.40
-> frame leaves wlan0 or eth0The source address likely comes from the same interface. ARP resolves the peer's link-layer address if the cache does not already have a usable entry. The frame leaves the host through the selected interface. The peer's kernel receives it and matches it to a listening or connected socket.
For a remote destination, route lookup usually selects a gateway -
192.0.2.10:random -> 203.0.113.20:3000
-> default route via 192.0.2.1
-> ARP for the gateway
-> frame leaves toward the gatewayThe link-layer target is the gateway. The IP destination remains the remote address. That is why packet captures can show a frame going to the router's MAC address while the IP packet is still addressed to the final destination. Routers repeat their own forwarding decisions until the packet reaches the destination network or fails.
Node does not see those lower steps directly. It sees connection progress, errors, and readable or writable socket state. A timeout can mean the remote host never answered, a gateway dropped packets, a firewall blocked the path, a route sent traffic into a tunnel, neighbor resolution failed, or TCP moved into retry behavior. The JavaScript error arrives later and often lacks the lower detail.
Logging the endpoint tuple gives you a useful starting point -
socket.on('connect', () => {
console.log(socket.localAddress, socket.localPort);
console.log(socket.remoteAddress, socket.remotePort);
});The local address tells you which source address the kernel selected. The remote fields tell you which numeric peer the socket reached after any lookup step. Pair that with ip route get for the remote address and ss for local socket state, and the host path becomes inspectable without changing application logic.
Inbound traffic has the same idea. A server bound to 0.0.0.0:3000 can receive a loopback connection, a same-LAN connection, or a routed connection through a gateway or port-forwarding hop. JavaScript gets the same connection callback shape each time. The accepted socket fields carry the local and remote endpoints for that specific path.
net.createServer(socket => {
console.log(socket.localAddress);
console.log(socket.remoteAddress);
socket.end();
}).listen(3000, '0.0.0.0');Connect from the same host through 127.0.0.1, then from the same host through its non-loopback address, then from another host. The printed addresses show separate routing and delivery paths under the same server object. That is why per-socket address fields exist. The listening socket's wildcard address is the receive policy. The connected socket records the actual endpoint pair.
What Node APIs Hide on Purpose
Node's networking APIs expose enough state for application code. They hide enough kernel detail to keep the API portable.
net.Socket exposes the resulting local and remote addresses. OS tools expose the route chosen for the connection. os.networkInterfaces() exposes interface addresses. OS tools expose neighbor cache state. server.listen() reports success or an error and gives you the bound address. Platform tooling exposes the socket options and kernel state behind that result.
That is deliberate API design. Node runs on Linux, macOS, Windows, BSD variants, containers, and managed environments. Socket APIs share common ideas across them, but route tables, neighbor caches, interface names, privilege models, and dual-stack defaults vary. Node keeps the JavaScript surface centered on stable cross-platform operations - bind, connect, read, write, close, and inspect endpoint addresses.
The missing fields still exist. Use OS tools for them.
Node API Host detail
os.networkInterfaces() interface addresses
ss -ltnp listening TCP sockets
ip route get ADDRESS route decision
ip neigh neighbor cacheThis keeps debugging honest. Use Node to inspect what the process asked for and which endpoint it received. Use the OS to inspect how the host decided to move packets. Use packet capture when the question reaches frames, packets, segments, and retransmission. Keep each tool attached to the layer that owns the state.
Timing also matters. Node can read a socket address after bind or connect. Route and neighbor state can change after that. Interfaces can go down. VPN routes can appear. A gateway can stop responding. DNS can later return another address. A socket that connected successfully can still fail on a later write because lower state changed after setup.
Long-lived services need that mindset. Startup checks prove startup state only. The OS keeps making route, neighbor, buffer, and interface decisions for every packet after your server logs "listening."
Where Node Ends
Node's low-level networking APIs give you objects, events, streams, buffers, addresses, ports, and errors. They sit above the host networking stack. The kernel socket table decides whether a bind is valid. The routing table chooses the outbound interface. ARP or Neighbor Discovery resolves the next hop on local links. MTU constrains packet size. The interface sends and receives frames.
Keep application code honest about the layer it owns.
If the server never emits connection, inspect the listening address and socket table before changing application logic. If the client connects from an unexpected source address, inspect route selection before changing retry code. If localhost works but a container cannot reach the service, inspect loopback scope and bind address before touching HTTP. If large writes stall, inspect buffering, backpressure, and MTU before blaming serialization.
Higher protocols sit on top of these primitives. DNS turns names into candidate addresses. TCP adds connection lifecycle, flow control, retransmission, and teardown. node:net exposes socket APIs with stream behavior. UDP exposes message-oriented datagrams. HTTP and TLS add their own state above transport.
The base path stays the same. JavaScript hands bytes to Node. Node goes through libuv and native bindings. The kernel owns sockets, addresses, routes, packet construction, and interfaces. Your process gets control again when the lower layers report a result that Node can turn into a callback, event, stream chunk, or error.