Get E-Book
Network Fundamentals with Node.js

Node.js net Module: TCP Servers, Sockets & IPC

Ishtmeet Singh @ishtms/May 11, 2026/44 min read
#nodejs#networking#tcp#sockets#net

The net module gives you direct access to stream-oriented sockets, the raw bytes that move over TCP or local IPC. You reach for it when you want those bytes plain, before HTTP, WebSocket, or TLS wrap them in their own framing rules.

Two objects do most of the work. net.Server listens for new connections. net.Socket is one connection once it exists. You call net.createServer() to build the JavaScript server object, then server.listen() to ask the operating system to claim an endpoint and start accepting. Each connection the server accepts arrives as a net.Socket, backed by native state that lives partly in Node, partly in libuv, and partly in the operating system.

The net Module in Node.js

A net.Socket is a stream. Reads come in as stream data. Writes go into local buffers first, then move through Node, libuv, and the kernel socket layer. end() starts a graceful shutdown from your side. destroy() tears down local socket state. server.close() stops accepting new connections, but existing sockets keep running until their own lifecycle ends.

Those are two separate jobs. A net.Server accepts new sockets. A net.Socket carries bytes for one connection to one peer.

js
import net from 'node:net';

const server = net.createServer(socket => {
  socket.end(socket.remoteAddress + '\n');
});

server.listen(3000, '127.0.0.1');

net.createServer() creates a JavaScript net.Server object. At that point, no port has been claimed. The server stores your connection callback, sets up EventEmitter behavior, and prepares internal fields that will later point at native state.

server.listen() is the call that asks the operating system for the real listening endpoint. Node creates native TCP state, hands that state to libuv, asks the OS for a TCP socket, binds it to 127.0.0.1:3000, and marks it as listening. After that succeeds, the process has a listening endpoint.

Underneath, the layers stack from your JavaScript object down to the kernel.

text
net.Server
  -> TCPWrap
  -> libuv TCP handle
  -> OS listening socket

Each layer handles a specific part of the behavior. net.Server is the JavaScript API you call. TCPWrap is Node's native wrapper around the TCP handle. libuv connects that native handle to the event loop. The operating system holds the actual socket state, the file descriptor or platform handle, the bound address, the listen state, and the accept behavior.

A socket is the process endpoint for a network connection or a local IPC connection. The common case in this chapter is TCP. A TCP socket carries protocol state, kernel buffers, local endpoint data, and remote endpoint data. Node wraps that lower socket in JavaScript so your code works with events, streams, and methods instead of raw system calls.

Two roles are worth separating.

text
listening socket
  -> accepts new TCP connections

connected socket
  -> reads and writes bytes for one peer

A net.Server wraps a listening socket. A net.Socket usually wraps a connected socket, the one moving bytes for a single peer. Many node:net bugs get easier to read once you keep those two jobs apart.

Raw Streams from node:net

node:net is Node's built-in module for raw stream-oriented endpoints. It gives you TCP servers, TCP clients, UNIX domain sockets on Unix-like systems, and Windows named pipes on Windows.

It sits underneath HTTP, TLS, WebSocket libraries, database clients, Redis clients, and most custom binary protocols. Those tools add their own parsing and protocol rules on top. node:net only gives you the connected byte stream.

Use it when you want to control the protocol yourself, meaning the raw reads, raw writes, connection lifecycle, backpressure, timeouts, and shutdown behavior.

js
import net from 'node:net';

const server = net.createServer(socket => {
  socket.write('hello\n');
  socket.end();
});

The callback receives a net.Socket. That socket is a Duplex stream. The readable side receives bytes from the peer. The writable side accepts bytes that should go to the peer.

Backpressure, the mechanism that slows a fast producer when the consumer falls behind, works here the way it does for other streams. One extra detail comes with sockets. Some buffering happens in Node's stream queues. Some happens lower, in the kernel socket buffers. TCP flow control can also slow the write path when the peer or the network cannot keep up.

net.Socket is also an EventEmitter. Events such as connect, data, end, error, timeout, and close are JavaScript events created from lower socket activity.

Below the stream, the path runs from JavaScript down to the OS endpoint.

text
JavaScript net.Socket
  -> native TCPWrap or PipeWrap
  -> libuv handle
  -> OS socket or pipe endpoint

For TCP, the libuv handle is a uv_tcp_t. It gives libuv an object it can register with the event loop. On Unix-like systems, that handle is tied to a file descriptor. On Windows, it is tied to a Windows socket handle and Windows I/O behavior. Node keeps the JavaScript API mostly aligned across both platform families.

TCPWrap is an internal Node binding object. You do not program against it directly. You may see the name in stack traces, diagnostics, or native references. Its job is to connect the public JavaScript object to libuv's TCP handle. JavaScript calls a method, native code turns that into a libuv operation, libuv reports readiness or completion, and Node emits JavaScript events or runs callbacks.

For local IPC endpoints, Node uses pipe-oriented native handles instead of TCP handles. The endpoint format changes, and the OS primitive changes, but the JavaScript stream behavior stays familiar.

You start from one of two entry points.

js
const server = net.createServer(onConnection);
const client = net.createConnection(3000, '127.0.0.1');

net.createConnection() and net.connect() are aliases for client creation. They create a net.Socket and call socket.connect() for you. Server code usually starts with net.createServer(). Client code usually starts with net.connect() once it has a host and port.

The Native Handle Path

A net.Server or net.Socket is the JavaScript object your code touches. It stores listeners, stream state, public flags, and a reference to a native handle. The native handle is where Node leaves JavaScript and reaches libuv.

For TCP, that native object is backed by a libuv TCP handle. For local pipe endpoints, it is backed by a pipe handle.

The TCP handle is the event-loop object for TCP. libuv can initialize it, bind it, connect it, read from it, write to it, stop it, and close it. It also has lifecycle state such as initialized, active, closing, and closed. Node's JavaScript stream has its own state too, so Node has to keep the stream state and native handle state aligned.

Many confusing bugs happen around that alignment.

When you call server.listen(), Node eventually asks libuv to bind and listen. libuv performs the platform-specific work. On Unix-like systems, the lower socket is represented by a descriptor and watched through the platform polling backend. On Linux, that usually means epoll through libuv. On macOS and BSD systems, kqueue is common. On Windows, libuv uses Windows I/O completion behavior. Your JavaScript code still sees the same connection event.

The polling backend watches for socket readiness. When the listening socket is ready for accept work, Node runs native accept logic. The accepted lower socket gets its own handle. Node creates a new JavaScript net.Socket and attaches it to that handle.

The handle layout is straightforward.

text
server JS object
  -> one native listen handle

accepted socket JS object
  -> one native connected handle

The server handle and the accepted socket handle are separate lower objects. Closing one touches only that object, which is the reason server.close() and socket.end() do different jobs.

Reads follow their own path. When a connected socket becomes readable, libuv reports readiness to Node. Node asks libuv to read bytes from the lower socket. libuv reads into memory provided through allocation callbacks. Node then exposes those bytes as Buffers and pushes them into the readable side of the net.Socket. Your data event happens after all of that.

Writes move in the other direction. A JavaScript write enters the writable side of the stream. Node creates native write requests for the chunks that need to move downward. libuv submits the write to the OS. Completion comes back later. Node then runs callbacks, updates writable state, and may emit drain.

A write request has its own lifetime. It can outlive the JavaScript call that created it, completing long after the socket.write() stack has returned. It can fail once the peer resets the connection. It can also be discarded outright if local code destroys the socket before the lower write finishes.

Close also has JavaScript state and native state. Calling socket.end() schedules a normal writable-side finish. Calling socket.destroy() marks the JavaScript stream as destroyed and starts handle teardown. libuv close callbacks run after the lower handle has closed. Node then emits close at the JavaScript layer.

Close timing can look delayed for that reason. The JavaScript object can report destroyed before the native handle has fully closed. The close event is the later signal, fired once Node has finished tearing the handle down.

You will see TCPWrap in a stack trace, but it is not a public API. Keep application logic on the public surface, meaning net.Server, net.Socket, events, stream methods, and documented options.

One more lifecycle detail. A net.Socket can exist before it has a working native connection. A socket built with new net.Socket() has JavaScript stream state right away, and the lower connection work starts only after connect(). A socket that a server accepts arrives already connected. A socket from net.connect() gets created and connected in one helper call.

It is the same class either way. Only the entry point into its lifecycle changes.

The lifecycle reads in stages.

text
created
connecting
connected
ending
closed
destroyed

Those stage names do not map to internal field names. They are a way to read the public flags and events that do exist, like connecting, pending, destroyed, closed, readyState, connect, end, and close.

readyState can help during debugging. It reports string states such as opening, open, readOnly, writeOnly, and closed. Use it in logs and diagnostics. For application protocol logic, your own state machine is usually cleaner.

The internal path also explains why a socket error can arrive long after the operation that caused it. Your code writes bytes, native code submits the write, and the call returns. The kernel reports a failure later, libuv delivers that completion, and Node emits error then, with the original function long gone from the stack. This is ordinary asynchronous I/O. Sockets make it especially visible, because the peer can change state at any moment.

One native fact sits under all of this. A net.Socket is a stream object wrapped around a lower handle, and that handle can change state after your JavaScript call stack is gone.

Before and After listen()

A net.Server starts life as a JavaScript object. It can carry listeners and options before any OS socket exists behind it.

js
const server = net.createServer(socket => {
  socket.end('ok\n');
});

console.log(server.address());
server.listen(0, '127.0.0.1');

Before the bind finishes, server.address() returns null. There is no bound socket address yet. After the server starts listening, server.address() returns an AddressInfo object for TCP servers.

js
server.on('listening', () => {
  console.log(server.address());
});

AddressInfo is the small object Node returns for a TCP server address. It has address, family, and port fields. An IPv4 loopback bind gives you these fields.

js
{ address: '127.0.0.1', family: 'IPv4', port: 41891 }

If you bind to port 0, the OS chooses a free port. After listening, server.address().port is the value clients should use.

server.listen() binds the server to an endpoint and starts accepting. For TCP, it takes a port and optional host, or an options object. The operation can complete asynchronously because Node crosses into native code and the OS can reject the bind.

js
const server = net.createServer();

server.on('error', err => console.error(err.code));
server.listen({ host: '127.0.0.1', port: 3000 });

Bind failures appear through the server's error event. EADDRINUSE means the requested endpoint conflicts with existing socket state or platform rules. EADDRNOTAVAIL means the local address is not available in the current host or network namespace. Permission errors can appear for privileged ports or platform policy.

The success path emits listening.

js
server.listen(0, '127.0.0.1', () => {
  const address = server.address();
  console.log(address.port);
});

The listen callback runs once the server is listening. It behaves like a one-time listening listener. At that point, the listening socket exists below JavaScript. The OS has claimed the endpoint. Incoming TCP handshakes can complete and wait for Node to accept them.

A listening socket is OS state for accepting connection attempts on a local endpoint. It has a protocol, address family, local address, local port, and listen state. It also participates in kernel queues that hold incoming connection work before user space accepts it. Backlog behavior belongs to Chapter 9.6. The point to hold onto here is the split. The server object is JavaScript. The listening socket is OS state.

The listen path runs roughly through these steps.

text
server.listen()
  -> create TCPWrap
  -> create libuv TCP handle
  -> create OS socket
  -> bind local address
  -> mark socket as listening

Node hides those lower operations behind one method. The lower layers still show up through errors, address fields, and timing.

server.listen() has several overloads, and they all reach the same lower operation. The numeric form is compact.

js
server.listen(3000, '127.0.0.1');

The object form is easier to extend.

js
server.listen({
  port: 3000,
  host: '127.0.0.1'
});

A path form exists for local socket endpoints.

js
server.listen('/tmp/nodebook.sock');

The TCP forms return an AddressInfo object from server.address() after binding. The path form returns the path string. Code that supports both endpoint types should branch on what comes back.

js
const address = server.address();

if (typeof address === 'string') console.log(address);
else console.log(`${address.address}:${address.port}`);

That branch shows up in libraries that support both TCP and local IPC. Treating server.address() as if it always has .port works in TCP tests, then breaks when deployment uses a UNIX domain socket.

The listen callback only tells you that the listening endpoint was created. It says nothing about future connection health. A server can emit listening, run for an hour, then accept clients that immediately reset.

An error event before listening usually means startup failed. Most servers should fail fast or report a clear startup error.

js
server.once('error', err => {
  console.error('listen failed', err.code);
});

After the server is listening, server-level errors can still happen. Per-connection failures belong on each accepted net.Socket. A common mistake is handling server.on('error') and expecting it to catch socket failures too.

js
net.createServer(socket => {
  socket.on('error', err => console.error(err.code));
}).on('error', err => {
  console.error('server', err.code);
});

Those two handlers listen to separate emitters. The server handler covers listen and server-handle failures. The socket handler covers one connection.

The host you bind to controls which local addresses can receive traffic.

js
server.listen(3000, '127.0.0.1');

That listens on IPv4 loopback. Same-host clients can connect to 127.0.0.1:3000. Remote machines cannot reach it through a non-loopback network address.

js
server.listen(3000, '0.0.0.0');

That listens on all suitable IPv4 addresses for the current host or network namespace. The accepted socket later records the actual local address the client reached. server.address() can report the wildcard bind address, while socket.localAddress reports the address for one accepted connection.

If you omit the host, Node and the OS choose default bind behavior. The result can involve IPv6 wildcard behavior and platform defaults. Use an explicit host while debugging. Numeric addresses also remove DNS from the test.

The Accept Path

A client connects. The kernel handles the TCP handshake. The listening socket becomes ready for accept work. libuv observes that readiness. Node accepts the connection and creates a JavaScript socket.

The accept path runs in this order.

text
incoming TCP handshake
  -> OS accepted connected socket
  -> libuv accept callback
  -> new TCPWrap
  -> new net.Socket
  -> server emits connection

The connection event is the server event Node emits after an inbound peer has been accepted and wrapped as a net.Socket. The callback passed to net.createServer() is registered as a listener for that event.

js
const server = net.createServer();

server.on('connection', socket => {
  console.log(socket.remoteAddress, socket.remotePort);
});

By the time that listener runs, the connected socket already exists. The lower TCP connection is established. The socket has a local endpoint and a remote endpoint. Node has created the JavaScript wrapper and attached stream state.

A connected socket represents one established TCP connection. It has local and remote address fields, readable and writable stream state, kernel send and receive buffers, and TCP connection state below Node.

One listening server can accept many connected sockets.

js
const sockets = new Set();

net.createServer(socket => {
  sockets.add(socket);
  socket.on('close', () => sockets.delete(socket));
}).listen(3000);

The set tracks JavaScript socket wrappers. The kernel tracks connected socket state. Each accepted TCP socket consumes its own lower socket handle. On Unix-like systems, that means another file descriptor. A busy server can run out of descriptors even though it has only one net.Server object.

The accepted socket carries endpoint fields.

js
net.createServer(socket => {
  console.log(socket.localAddress, socket.localPort);
  console.log(socket.remoteAddress, socket.remotePort);
}).listen(3000, '0.0.0.0');

localAddress and localPort describe your side of the connection. remoteAddress and remotePort describe the peer address reported by the TCP layer. If a proxy, NAT, or port forward sits in front, these fields describe the immediate TCP peer at this layer. Higher-level identity belongs to later protocol layers.

Data arrives as stream chunks.

js
net.createServer(socket => {
  socket.on('data', chunk => {
    socket.write(chunk);
  });
}).listen(3000);

The server above echoes bytes straight back, leaving any message parsing to your code.

A data chunk is whatever bytes Node read from the socket receive path for that event. TCP preserves byte order, but it does not preserve the edges of your application writes. One client write can arrive split across several chunks. Several client writes can arrive merged into one chunk. Chapter 9.3 covers TCP byte-stream behavior in depth. node:net exposes that behavior directly.

Raw TCP protocols need framing above net.Socket. Framing is the rule that tells your code where one logical message ends and the next begins. Length prefixes, delimiters, fixed-size records, and parser state machines are common choices. The socket gives you bytes. Your protocol decides how to group them.

Here is a tiny delimiter parser.

js
let pending = '';

socket.on('data', chunk => {
  pending += chunk;
  const lines = pending.split('\n');
  pending = lines.pop();
});

The parser above skips a lot. It ignores encoding details and unbounded memory growth. The basic idea still comes through, which is to hold leftover data across data events, because chunk edges come from reads rather than from your protocol.

Buffers are usually safer for binary protocols.

js
let pending = Buffer.alloc(0);

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

This version pays for Buffer.concat(), which copies every time. High-throughput parsers tend to keep a list of chunks, track offsets, and copy only when a complete frame needs contiguous storage. Chapter 2 covered Buffer ownership and copying. node:net puts those costs right on the hot path.

Pausing reads is stream behavior with network consequences lower down. socket.pause() stops the JavaScript readable side from flowing. Data may still sit in Node buffers and kernel receive buffers. If the application stays paused and the peer keeps sending, TCP flow control eventually slows the peer through the receive window.

js
socket.pause();

setTimeout(() => socket.resume(), 1000);

Pausing changes local read behavior only. The peer gets no custom message from pause(). It sees slower progress, or writes that block once TCP pressure builds.

Use pause() when a local parser or downstream consumer needs time. Use protocol messages when the peer needs a semantic response.

Accepted sockets can start reading immediately. If the server needs setup before processing input, attach handlers early and pause deliberately.

js
const server = net.createServer(socket => {
  socket.pause();
  setup(socket).then(() => socket.resume(), err => socket.destroy(err));
});

The socket exists while setup runs. The peer can already send bytes. Pausing prevents flowing data events before setup is ready, while lower buffers still have finite capacity. Long setup work needs timeouts or early rejection.

Accepted sockets can outlive the server's listening socket.

js
const server = net.createServer(socket => {
  socket.write('connected\n');
});

server.listen(3000);

If the listening server later closes, existing connected sockets can keep reading and writing until they end, error, or get destroyed. Server lifetime and connection lifetime are separate because they wrap separate lower sockets.

Client Sockets

Client code starts with a net.Socket before it has a remote peer. socket.connect() starts the outbound connection attempt.

js
import net from 'node:net';

const socket = new net.Socket();

socket.connect(3000, '127.0.0.1');

For TCP, socket.connect() tells Node to connect to a remote endpoint. Node resolves hostnames when needed, creates or uses a TCP handle, asks the OS to connect, and emits connect after the lower connection is established.

Most code uses net.connect().

js
const socket = net.connect(3000, '127.0.0.1', () => {
  socket.write('ping\n');
});

The callback is a one-time connect listener. It runs after the socket is connected. Writes made before connection are queued by Node and flushed after connect succeeds, as long as the socket does not fail first.

The options form reads better once you start choosing addresses.

js
const socket = net.connect({
  host: 'example.com',
  port: 80,
  localAddress: '192.0.2.10'
});

host and port describe the remote endpoint. localAddress constrains the local source address. The OS validates that local address against interface and route state. DNS lookup behavior belongs to Chapter 9.2. At this level, remember that a hostname must become at least one numeric address before TCP can connect.

The options object also supports family and lookup. family constrains the address family. lookup supplies a custom lookup function with the same basic signature as dns.lookup().

js
const socket = net.connect({
  host: 'localhost',
  port: 3000,
  family: 4
});

Passing family: 4 asks for IPv4 results. That helps when localhost resolves to both IPv6 and IPv4 but the server listens on only one family. The broader lookup-order behavior belongs to Chapter 9.2.

A custom lookup function gives you a lot of control, and it is easy to misuse.

js
net.connect({
  host: 'service.local',
  port: 9000,
  lookup: dns.lookup
});

Here the default lookup function gets passed in explicitly. Real custom lookup functions tend to add caching, metrics, overrides, or service discovery. Keep the contract tight, because the connect path expects an address and family answer it can use for TCP.

Node can try connection candidates according to its lookup and connection logic. Full connection racing details belong to Chapter 9.7. At the net.Socket level, your code usually waits for one of two outcomes, connect or error.

js
socket.on('connect', () => {
  console.log(socket.localAddress, socket.localPort);
  console.log(socket.remoteAddress, socket.remotePort);
});

After connect, endpoint fields are populated. The local port is often ephemeral. The local address usually comes from route selection unless you constrained it. The remote address may be the resolved numeric address rather than the hostname string you passed.

Connection failures surface as errors.

js
socket.on('error', err => {
  console.error(err.code, err.address, err.port);
});

ECONNREFUSED means the destination rejected the connection attempt at the transport layer. ETIMEDOUT means the attempt timed out according to lower networking behavior. DNS errors such as ENOTFOUND can appear before TCP starts. The same event channel carries errors from several layers, so log the code and endpoint fields.

Writes before connect are allowed because Node queues them.

js
const socket = net.connect(3000, '127.0.0.1');

socket.write('hello before connect\n');

That convenience helps small clients, but it can hide ordering mistakes. If the connect fails, the queued write has nowhere useful to go, and the socket reports an error. For protocols with setup state, waiting for connect keeps the local sequence easier to follow.

js
socket.once('connect', () => {
  socket.write('HELLO\n');
});

Now protocol startup happens after the transport is established. The peer might still close immediately, but the local order stays clear, with the connect completing before the write goes out.

Client sockets also expose readyState and pending, which are useful in connection logs.

js
console.log(socket.pending, socket.readyState);

pending tells you whether the socket is still waiting for connection. readyState reports readable and writable status at the stream layer. Use these fields for diagnostics. Keep protocol state in your own state machine.

The client socket emits close after the socket has closed. The event may include a boolean telling you whether an error happened before close.

js
socket.on('close', hadError => {
  console.log({ hadError });
});

Treat error and close as separate signals. error reports failure. close reports final socket closure. Cleanup that must always happen usually belongs in close, because it runs after normal endings and after error paths.

Writing Bytes and Backpressure

socket.write() accepts bytes into the socket's writable path. It can accept a string, Buffer, TypedArray, or DataView. Strings are encoded before they move lower.

js
socket.write('hello\n');
socket.write(Buffer.from([0x6f, 0x6b, 0x0a]));

The return value is the stream backpressure signal. true means the writable side is still below its configured threshold. false means the writable side has buffered enough that the producer should wait for drain.

js
if (!socket.write(chunk)) {
  socket.once('drain', sendMore);
}

The signal reflects local stream state only. A true return means Node took the chunk into its writable path. A write callback means the chunk moved out of Node's write queue toward the lower layer. Neither one proves the peer has processed the bytes.

The write path has several places where bytes may wait.

text
JavaScript call
  -> Node writable queue
  -> libuv write request
  -> kernel send buffer
  -> TCP transmission

The kernel send buffer is below Node. TCP may wait on peer receive-window space, congestion state, retransmission timers, route state, or interface availability. socket.write() gives you a local signal. The network still has work to do after that.

This is important when you implement a protocol.

js
socket.write('DONE\n', () => {
  socket.end();
});

The callback orders local operations. It makes Node ask to end after the write has moved through Node's write queue. It does not prove that the peer understood the message. If the peer protocol requires acknowledgment, the peer must send bytes back and your code must read them.

Node can batch chunks before they move downward. The public behavior stays the same. You write chunks, and the stream implementation decides how to stage them for native writes.

cork() and uncork() from Writable streams also work here.

js
socket.cork();
socket.write('A');
socket.write('B');
socket.uncork();

Corking batches small writes in the stream layer until uncork() runs. TCP behavior below that still depends on socket options and kernel state. Nagle and TCP_NODELAY belong to Chapter 9.6. The narrow claim for now is that corking changes Node's writable buffering before any bytes move lower.

The write callback order follows the chunks you submitted through the stream path. It is useful for freeing per-chunk memory or advancing a local send queue.

js
socket.write(payload, err => {
  if (err) return onWriteFailure(err);
  release(payload);
});

The callback can receive an error when the write fails. A socket can fail between enqueue and completion, so callback code should handle that argument.

Large writes deserve care. Passing a 100 MiB Buffer to socket.write() asks Node to accept that memory into the stream path. Backpressure can tell you to stop after the call, but the allocation already happened. Streaming a large payload in chunks gives the runtime more room to apply pressure.

js
source.on('data', chunk => {
  if (!socket.write(chunk)) source.pause();
});

socket.on('drain', () => source.resume());

The loop above is the older stream pattern. stream.pipeline() tends to give cleaner error propagation when both sides are streams, though raw socket protocols often need parser state and protocol decisions sitting between reads and writes.

socket.write() can also fail synchronously for invalid arguments or invalid local state. Network failures usually surface later through error events or write callbacks.

js
socket.on('error', err => {
  if (err.code === 'EPIPE') console.error('write after close');
});

EPIPE usually means your code wrote after the peer or local stack closed the write path. ECONNRESET usually means a reset arrived from the peer or from lower network state. Timing decides where you see the failure. A peer can reset the connection, and your next write can be the call that discovers it.

Backpressure needs special care because net.Socket is both a stream and a network endpoint. If you ignore false from write(), Node can keep buffering data in memory. If the peer stops reading, the kernel receive window can shrink and the local send path can stop making progress. Your JavaScript producer can outrun both the Node queue and the network.

Small loopback tests hide this, because loopback is fast and kernel buffers are generous. The process can finish before any pressure shows up. The same code can fail badly against a slow client or a congested path.

The safe version is ordinary stream code.

js
function send(socket, chunks) {
  const queue = Array.from(chunks);

  const pump = () => {
    while (queue.length && socket.write(queue.shift())) {}

    if (queue.length) socket.once('drain', pump);
    else socket.end();
  };

  pump();
}

The function above keeps things minimal so the control point stands out. You stop producing when write() returns false, then resume on drain. The queue lives in the closure because drain calls pump with no arguments. Real code also needs cleanup, error handling, and protocol state.

Ending the Writable Side

socket.end() finishes the writable side of the socket. It can also send a final chunk.

js
socket.end('bye\n');

For TCP, a graceful end uses the FIN path described in Chapter 9.3. Node queues the optional final data, then closes the write side. The peer can still send data until its own write side closes. With the default allowHalfOpen: false behavior, Node usually ends the writable side after the readable side ends, giving you the common full-close behavior.

The readable side and writable side have separate events.

js
socket.on('end', () => {
  console.log('peer ended writes');
});

socket.on('close', () => {
  console.log('socket closed');
});

end means the peer has finished sending data to you. close means the socket handle has closed. There can be pending work between those moments depending on timing and state.

For simple request-response protocols, end() is often the right shutdown call.

js
socket.write('result\n');
socket.end();

That pair of calls sends the bytes, then finishes your writable side. Queued writes still go through the normal stream path, and TCP gets a chance to close cleanly.

Half-open behavior exposes a TCP detail through Node. If you create a server with allowHalfOpen: true, Node leaves your writable side open after the peer ends its writable side.

js
const server = net.createServer({ allowHalfOpen: true }, socket => {
  socket.on('end', () => socket.end('ack\n'));
});

The end event means the peer sent FIN. With allowHalfOpen: true, your code decides when to send its own FIN by calling socket.end(). Chapter 9.3 covered half-open connections as TCP state. Here you see the Node option that exposes that state.

Only use half-open support when the protocol needs it. Most application protocols prefer a clear close after the response. A socket left writable after the peer has ended can keep descriptors, timers, and application state alive longer than intended.

The finish event belongs to the writable side. It fires when the writable stream has ended and all data has been flushed from the stream implementation.

js
socket.end('done\n');
socket.on('finish', () => console.log('write side ended'));

finish is local writable-stream completion. end is peer write-side completion from your point of view. close is handle closure. The names are easy to mix up because they often happen close together.

A simple server may need all three.

js
socket.on('end', onPeerEnded);
socket.on('finish', onLocalEnded);
socket.on('close', onClosed);

Those handlers watch separate states. When a bug report says the socket ended, ask which event actually fired, because the answer changes what you inspect next.

Destroying a Socket

socket.destroy() closes the socket immediately from Node's point of view. It tears down the stream, closes the underlying handle, and discards queued writes still held by Node.

js
socket.destroy();

Use it for failure paths, protocol violations, shutdown cuts, and cleanup after a timeout. It is the call you use when local code is done with this socket now.

You can pass an error.

js
socket.destroy(new Error('bad frame'));

That error is emitted on the socket as part of teardown. The socket then closes. This pattern is useful when a parser detects invalid input and you want downstream listeners to receive a reason.

destroy() tells Node to tear down local socket state. The OS decides the packet-level behavior from socket state, unread data, pending writes, platform behavior, and options. The practical result for your code is that queued userland writes can be dropped, the handle closes, and further reads or writes stop.

That makes destroy() the wrong call for normal protocol completion.

js
socket.write('ok\n');
socket.destroy();

The write may still be sitting in Node's queue when destroy() runs. Depending on timing, the peer can receive nothing, receive partial bytes, or receive the data and then a reset right behind it. Code that cares about local write progress should use end(), or wait for the write callback before tearing down.

Failure paths are different.

js
socket.on('data', chunk => {
  if (chunk.length > 1024) socket.destroy();
});

The handler above rejects oversized input by closing the socket at once. It stops reading and writing for that peer and releases the lower resources once close finishes.

Destroying twice is harmless in normal use. After a socket enters destroyed state, additional calls become no-ops. The destroyed property tells you the JavaScript stream state.

js
if (!socket.destroyed) {
  socket.destroy();
}

The closed and destroyed fields help when you debug lifecycle bugs, but the events usually read more clearly. error fires on failure, end when the peer finishes its writes, timeout after inactivity, and close once the handle is fully closed.

destroySoon() also exists. It is a legacy-ish socket method that ends the writable side after queued writes drain, then destroys the socket. Most new code reads better with explicit end() for graceful completion and destroy() for failure. The explicit calls make the shutdown path obvious.

resetAndDestroy() exists for TCP sockets too. It closes the connection by sending a TCP reset where possible, then destroys the stream.

js
if (typeof socket.resetAndDestroy === 'function') {
  socket.resetAndDestroy();
}

Use that only when a reset is the protocol behavior you want. It is stronger than ordinary destroy() because it asks for a TCP reset directly. On a pipe, Node throws ERR_INVALID_HANDLE_TYPE. The usable TCP states are connecting and connected. Closed TCP sockets get destroyed with ERR_SOCKET_CLOSED. Normal servers rarely need it.

The Event Timeline

The happy server path has only a few steps.

text
server.listen()
  -> listening
  -> connection
  -> socket data
  -> socket end
  -> socket close

Real timelines branch. A client can reset, a parser can destroy the socket, a timeout can fire, or a write can discover a broken pipe. A server can also stop accepting while connected sockets keep running.

A client socket follows this normal path.

text
socket.connect()
  -> connect
  -> data
  -> end
  -> close

Errors can happen before connect, after connect, during write, during read, or during close. Use one error handler per socket. Unhandled error events on EventEmitter instances still crash the process.

js
socket.on('error', err => {
  console.error(err.code);
});

A socket failure usually emits error, then close. Put cleanup that must always run in close. Put diagnosis and logging in error.

The data event is only one read style. You can also use stream methods, pipe(), or async iteration.

js
for await (const chunk of socket) {
  console.log(chunk.length);
}

The loop above ends when the readable side ends or errors. It uses stream async iteration from Chapter 7, reading from the same lower socket receive path as the data event.

The server has its own lifecycle events.

js
server.on('listening', () => console.log('ready'));
server.on('close', () => console.log('closed'));
server.on('error', err => console.error(err.code));

listening means the listening socket is active. close means the server handle has closed. error means a server-level operation failed, often bind or listen setup.

Events are JavaScript observations of lower socket state. When timing is confusing, log both layers where you can. Socket events in your process, and the host socket table with OS tools.

Socket Timeouts

A socket timeout in node:net is an inactivity timer, nothing more. It fires when the socket has had no activity for the interval you set.

js
socket.setTimeout(30_000);

socket.on('timeout', () => {
  socket.destroy();
});

The timeout event is only a notification. Node leaves the socket open. Your code decides what to do next. Most raw socket servers destroy idle sockets after the event because an idle connected socket still consumes memory, a lower handle, and protocol state.

The timer resets on socket activity. Reads and writes count as activity. The exact accounting belongs to Node's stream and socket implementation, so treat it as idle detection rather than a complete protocol deadline.

js
const server = net.createServer(socket => {
  socket.setTimeout(10_000);
  socket.on('timeout', () => socket.end('idle\n'));
});

That handler sends a final message and ends gracefully. It can still hang if the peer stops reading and your final write makes no progress. A hard idle policy often calls destroy() after the timeout. A gentler protocol can try end() first, then set a second timer that destroys the socket if it still has not closed.

Outbound connection deadlines are a broader design topic. socket.setTimeout() can detect inactivity while connecting, but retry budgets, cancellation, AbortController, and request deadlines belong to later chapters. The local rule for raw net.Socket code is simple. The timeout event fires, and the close policy is yours to decide.

The common bug is a timeout handler that logs but leaves the socket alive.

js
socket.setTimeout(60_000);
socket.on('timeout', () => console.warn('idle socket'));

The handler above records the idle state and keeps the descriptor open. Under steady traffic, those sockets pile up. If the intent is cleanup, do the cleanup inside the handler.

Local Socket Endpoints

node:net also supports local socket endpoints. On Unix-like systems, that means UNIX domain sockets. On Windows, that means named pipes.

A UNIX domain socket is a local IPC socket addressed by a filesystem path. It uses socket APIs and stream semantics, but traffic stays on the same host. The address is a path instead of an IP address and port.

js
import net from 'node:net';

const path = '/tmp/nodebook.sock';

net.createServer(socket => {
  socket.end('local\n');
}).listen(path);

For a UNIX domain socket server, server.address() returns the path string. AddressInfo describes a TCP address, so a local socket path comes back as a plain string instead.

Clients connect with the same path.

js
const socket = net.connect('/tmp/nodebook.sock');

socket.on('data', chunk => {
  process.stdout.write(chunk);
});

The JavaScript socket still behaves as a Duplex stream. The lower endpoint is local IPC. Permissions, path cleanup, and OS limits become the common failure points.

Stale socket path files cause local development bugs. If a Unix server crashes after binding a path, the filesystem entry may remain. A later listen(path) can fail because the path already exists.

js
import fs from 'node:fs';

try { fs.unlinkSync('/tmp/nodebook.sock'); } catch {}
server.listen('/tmp/nodebook.sock');

That unlink-first pattern shows up all over examples, but production code should not blindly remove a path that another live process is using. A safer startup checks whether something is already listening there before clearing stale state. Full IPC design and supervisor behavior come later.

Windows named pipes use a pipe name instead of a filesystem socket path.

js
const pipe = '\\\\.\\pipe\\nodebook';

net.createServer(socket => {
  socket.end('pipe\n');
}).listen(pipe);

A Windows named pipe is a local named IPC endpoint. Node exposes it through the same net.Server and net.Socket objects where possible. The naming rules and security model are Windows-specific.

Local socket endpoints are useful for same-host services, sidecars, test fixtures, and supervisor-managed daemons. They avoid TCP port allocation and remote network exposure. They still need lifecycle cleanup, permissions, and timeout policy.

Use TCP when the endpoint must be reachable by IP address and port. Use a local socket or named pipe when both peers are on the same host and you want an OS-local endpoint. IPC handle passing is a separate topic and belongs to Chapter 14.

Closing the Server

server.close() stops the server from accepting new connections. Existing connected sockets keep their own lifecycle.

js
const server = net.createServer(socket => {
  socket.write('still alive\n');
});

server.listen(3000, () => {
  server.close();
});

After server.close(), the listening socket is closing or closed. New clients cannot connect through that server. Accepted sockets that already exist can continue until they end or get destroyed.

The close callback runs when the server has closed.

js
server.close(err => {
  if (err) console.error(err.code);
  else console.log('server closed');
});

If the server was never open, the callback receives an error. The close event has no error argument. Use the callback when code needs to know whether close completed normally or was requested while the server was already closed or never started.

Tracking sockets is your job when shutdown needs connection cleanup.

js
const sockets = new Set();

const server = net.createServer(socket => {
  sockets.add(socket);
  socket.on('close', () => sockets.delete(socket));
});

The set gives your code a way to end or destroy accepted sockets after the listener closes.

js
server.close(() => console.log('listener closed'));

for (const socket of sockets) {
  socket.end();
}

The loop above is a local shutdown sketch. Production draining needs deadlines, readiness changes, supervisor behavior, HTTP semantics, and deployment rules, all of which Chapter 31 covers. The node:net piece is narrower. Closing the server stops new accepts, and connected sockets stay separate objects with their own lifecycle.

Connection cleanup should be explicit in raw net servers. Frameworks often hide it by managing the request lifecycle for you. A node:net server hands you the socket and lets your code decide what happens next.

One clean rule helps here. The code that accepts the socket should also register that socket's terminal handlers.

js
const server = net.createServer(socket => {
  socket.on('error', logSocketError);
  socket.on('close', () => sockets.delete(socket));
  sockets.add(socket);
});

Register error immediately. A socket can fail before your parser finishes setup. Register close immediately too, because every path should release tracking state in the same place.

Per-socket state usually belongs next to the socket.

js
const state = { bytes: 0 };

socket.on('data', chunk => {
  state.bytes += chunk.length;
});

That state should disappear with the socket. Delete it from any shared map in close, clear any timer it set in close, and drop any parser buffers it holds in the same place. Once the lower handle is gone, application state for that peer should stop holding memory.

Timers are easy to leak.

js
const timer = setInterval(() => socket.write('.'), 1000);

socket.on('close', () => clearInterval(timer));

The interval keeps a reference to the socket through the callback. Missing close cleanup lets JavaScript keep trying to write to a dead socket and can retain state that should have disappeared. The socket API leaves that cleanup with your code.

Backpressure state also needs a terminal path. If a producer is paused because socket.write() returned false, a socket error should resume or destroy that producer intentionally. Otherwise, the producer may wait forever for a drain event that will never fire.

js
socket.on('error', err => {
  source.destroy(err);
});

The exact line is application-specific, but the pattern is general. Tie the socket lifecycle to the lifecycle of whatever feeds it and whatever consumes it. Raw TCP code has fewer abstractions, so cleanup bugs surface as retained Buffers, open descriptors, stuck producers, and sockets that keep a process alive.

You can observe server and socket lifecycle separately.

js
const server = net.createServer(socket => {
  server.close();
  socket.write('connected after close call\n');
});

The accepted socket can still write because it is a connected socket with its own handle. The server's listening handle is the part being closed. The connection callback already received a separate socket.

Repeated close calls can produce errors depending on timing and server state. Treat close as a state transition, and guard it with a flag if several parts of a program can request shutdown.

js
let closing = false;

function stop() {
  if (closing) return;
  closing = true;
  server.close();
}

The guard keeps shutdown logs quiet and keeps the transition clear. One path moves the server from accepting to closing.

One Local Trace

A compact server and client can show the whole object path without DNS or remote routing.

js
const server = net.createServer(socket => {
  socket.end('pong\n');
});

server.listen(0, '127.0.0.1');

Those two calls create the server object, then bind a TCP listening socket on IPv4 loopback with an OS-selected port. The port stays unknown until the listen operation completes.

js
server.on('listening', () => {
  const { port } = server.address();
  const client = net.connect(port, '127.0.0.1');
});

The client connect call starts after the server reports listening. It targets the exact port the OS selected. The numeric address keeps hostname lookup out of the path.

The events usually fire in the following order.

text
server listening
client connect starts
server connection
client connect
client data
client end
client close

The exact order around connection and client connect depends on scheduling. Both events represent lower socket work that has already happened. The server accepted a connected socket. The client established its connected socket.

Add logs to make the endpoint identities visible.

js
server.on('connection', socket => {
  console.log('server local', socket.localPort);
  console.log('server remote', socket.remotePort);
});

The server's accepted socket has a local port equal to the listening port. Its remote port is the client's ephemeral port.

The client sees the same connection from its side.

js
client.on('connect', () => {
  console.log('client local', client.localPort);
  console.log('client remote', client.remotePort);
});

The client's local port is ephemeral. Its remote port is the server's listening port. It is one TCP connection seen from two ends.

Now add cleanup.

js
client.on('close', () => {
  server.close();
});

The client closes after reading pong\n and receiving the peer's FIN. Then the server closes the listening socket. The accepted socket has already gone through its own close path. Server close now only needs to close the listener.

The trace contains three socket roles.

text
server listening socket
server accepted socket
client connected socket

JavaScript has one net.Server and two net.Socket objects. The OS has one listening socket and one TCP connection represented from both endpoint perspectives inside the same host stack. Loopback keeps the traffic local, but the socket roles are the same as in a remote connection.

This is also why raw TCP tests should use port 0. The OS picks a free port. The test reads it after listening. The client connects to that value. Parallel test runs avoid fighting over a fixed global port.

A tiny failure version makes connect errors visible.

js
const client = net.connect(9, '127.0.0.1');

client.on('error', err => {
  console.error(err.code);
});

Port 9 is usually closed on local machines. The likely result is ECONNREFUSED. That error belongs to connection establishment. Accept never happened. A data event cannot follow from that failed client attempt.

Changing only the address family can change the result.

js
net.connect(3000, '::1')
  .on('error', err => console.error(err.code));

If the server listens on 127.0.0.1, that client targets IPv6 loopback and can fail even while the IPv4 listener is healthy. The port number matches, but the socket address does not. Address family is part of the endpoint.

The same trace works for a UNIX domain socket with a different endpoint type.

js
const path = '/tmp/nodebook-trace.sock';

const server = net.createServer(socket => socket.end('pong\n'));
server.listen(path);

The server address is now a path string. The accepted object is still a net.Socket. The lower handle is a local pipe or socket endpoint instead of TCP. Code that only needs stream reads and writes barely changes. Code that logs endpoint details must handle the address format.

Debugging Raw Socket Code

The fastest way to debug raw socket code is to log each point where state moves from one layer to the next.

For a server, log the bind result and accepted endpoint data.

js
server.on('listening', () => console.log(server.address()));

server.on('connection', socket => {
  console.log(socket.localAddress, socket.remoteAddress);
});

For a client, log connect result, errors, and close.

js
socket.on('connect', () => console.log(socket.address()));
socket.on('error', err => console.error(err.code));
socket.on('close', hadError => console.log({ hadError }));

socket.address() returns the local address for a TCP socket as an AddressInfo object. It is the client-side partner to server.address(). The remote fields live on the socket as remoteAddress, remotePort, and remoteFamily.

When a server never receives connection, check the listener first. Confirm the address family the client uses. Confirm whether the server is bound to loopback while the client runs outside that namespace. Check whether server.listen() emitted an error. Inspect the host socket table for the port.

When a client never reaches connect, inspect name lookup, route selection, and TCP establishment. Use a numeric address to remove DNS. Log err.code, err.address, and err.port. Check whether the server is listening on the same family and address.

When writes appear to vanish, inspect the close path. A write followed by destroy() can drop queued data. A write callback confirms local queue progress, not peer processing. A protocol acknowledgment has to come from bytes read back from the peer.

When idle sockets pile up, inspect timeout handlers. setTimeout() emits an event. It leaves cleanup policy to your code. A timeout handler that only logs is a leak under steady traffic patterns.

Raw node:net code looks compact because Node already wrapped the native socket work for you. The hard part is keeping track of which layer holds which state. net.Server handles accepting. net.Socket handles one connection. TCPWrap and libuv tie those JavaScript objects to the event loop. The operating system holds the actual socket state.

Once you can name which layer holds which state, the API gets much easier to debug. You know where to look when listen fails, when accept never happens, when writes stall, when sockets leak, and when close events show up later than the code that caused them.