Get E-Book
Network Fundamentals with Node.js

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

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

The net module gives you direct access to stream-oriented sockets. You use it when you want to work with raw bytes over TCP or local IPC, without HTTP, WebSocket, TLS, or any higher-level protocol doing framing for you.

The main pieces are net.Server and net.Socket. A server listens for new connections. A socket represents one connection. net.createServer() creates the JavaScript server object. server.listen() asks the operating system to claim an endpoint and start accepting connections. Every accepted connection becomes a net.Socket backed by native state inside Node, libuv, and 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.

That is the first thing to get comfortable with. A net.Server is responsible for accepting new sockets. A net.Socket is responsible for one conversation with 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 owns a listening endpoint.

The path looks like this -

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

Each layer owns a specific part of the behavior. net.Server owns the JavaScript API. TCPWrap is Node's native wrapper around the TCP handle. libuv connects that native handle to the event loop. The operating system owns 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 or local IPC conversation. In this chapter, the common case is TCP. A TCP socket has protocol state, kernel buffers, local endpoint data, and remote endpoint data. Node wraps that lower socket in JavaScript so your code can work with events, streams, and methods instead of system calls.

Keep these two shapes in your head -

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 server receives new peers. The socket talks to one peer. Most node:net bugs get easier to understand once you keep those two jobs separate.

node:net Owns Raw Streams

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 - 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 still works like it does with other streams, but there is one extra detail here. Some buffering happens in Node's stream queues. Some buffering happens lower in the kernel socket buffers. TCP flow control can also slow down 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.

The lower path looks like this -

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.

The two common entry points are -

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 ownership shape is simple -

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 targets one lower object. That is why server.close() and socket.end() do separate 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. It can complete after the stack that called socket.write() has returned. It can fail after the peer resets the connection. It can also be discarded 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.

That is why close timing can feel delayed. The JavaScript object may already look destroyed before the native handle has fully closed. The close event is the later signal that Node has finished the handle teardown.

TCPWrap is useful to recognize during debugging, but it is not a public API. Keep application logic on the public contract - net.Server, net.Socket, events, stream methods, and documented options.

There is one more lifecycle detail to know. A net.Socket can exist before it has a useful native connection. A socket created with new net.Socket() has JavaScript stream state immediately. The lower connection work begins after connect(). A socket accepted by a server arrives already connected. A socket created by net.connect() is created and connected through one helper call.

Same class. Separate lifecycle entry points.

A practical reading model looks like this -

text
created
connecting
connected
ending
closed
destroyed

Those names are not a promise about internal field names. They are a useful way to read the public flags and events - 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 after the operation that caused it. Your code writes bytes. Native code submits a write. The kernel later reports failure. libuv delivers completion. Node emits error. The original function returned long ago. That is normal asynchronous I/O, and sockets make it very visible because the peer can change state at any moment.

The main native fact to remember is this - net.Socket is a stream object around a lower handle, and that lower handle can change state after your JavaScript call stack is gone.

Before and After listen()

A net.Server starts as a JavaScript object. It can have listeners and options before it owns any OS socket.

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. For an IPv4 loopback bind, it might look like this -

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. Here, the important idea is smaller - the server object is JavaScript, while the listening socket is OS state.

The listen path is roughly -

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'
});

There is also a path form 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 the returned shape -

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 path looks like this -

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);

That is an echo server. It echoes bytes directly and leaves 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 your application write boundaries. One client write can arrive as several chunks. Several client writes can arrive as 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();
});

That code is intentionally incomplete. It ignores encoding details and unbounded memory growth. It still shows the basic parser shape - keep leftover data across data events because chunk boundaries come from reads, not 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]);
});

That version has its own cost because Buffer.concat() copies. High-throughput parsers usually 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 directly 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);

That pause changes local read behavior. The peer does not receive a custom message from pause(). It only sees slower progress or blocked writes through TCP pressure.

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 is easier to read once address selection enters the picture -

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 shape as dns.lookup().

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

That asks for IPv4 results. It is useful when localhost resolves to both IPv6 and IPv4, but the server only listens on one family. The broader lookup-order behavior belongs to Chapter 9.2.

A custom lookup function is powerful and easy to misuse -

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

That example passes the default lookup function explicitly. Real custom lookup functions usually add caching, metrics, overrides, or service discovery. Keep the contract tight. 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 can be convenient for small clients. It can also 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 makes the local sequence easier to read.

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

That puts protocol startup after transport establishment. The peer may still close immediately, but your local order is clear - connect first, then write.

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.

Writes Are Local Commitments

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);
}

That signal is local stream state. A true return means Node accepted 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 that 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 contract stays simple. 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();

That batches small writes in the stream layer until uncork(). TCP behavior below that still depends on socket options and kernel state. Nagle and TCP_NODELAY belong to Chapter 9.6. For now, keep the claim narrow - corking changes Node's writable buffering before 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());

That is the old stream pattern. stream.pipeline() usually gives cleaner error propagation when both sides are streams, but raw socket protocols often need parser state and protocol decisions 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. Loopback is fast. Kernel buffers are generous. The process may finish before pressure becomes visible. The same code can fail badly against a slow client or a congested path.

The safe shape 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();
}

That snippet is intentionally small. It shows the control point - 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.

end() Sends Intent

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 sequence says to send these bytes, then finish your writable side. It preserves queued writes through the normal stream path and gives TCP 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 observe separate states. If a bug report says "the socket ended", ask which event fired. The answer changes what you should inspect next.

destroy() Tears Down Local State

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 based on socket state, unread data, pending writes, platform behavior, and options. For your code, the practical result is this - queued userland writes can be dropped, the handle closes, and future 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 in Node's queue when destroy() runs. The peer may receive nothing. It may receive partial bytes. It may receive the data and then a reset, depending on timing. Code that cares about local write progress should use end() or wait for the write callback before teardown.

Failure paths are another story -

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

That code rejects input by closing the socket abruptly. It stops reading and writing for that peer and releases 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 can help when debugging lifecycle bugs, but events usually tell the cleaner story. error reports failure. end reports peer write-end. timeout reports inactivity. close reports final handle closure.

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.

There is also resetAndDestroy() for TCP sockets. It closes the connection by sending a TCP reset when 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 is short -

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. A write can discover a broken pipe. A server can stop accepting while connected sockets keep running.

For a client socket, the normal path is -

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);
}

That loop ends when the readable side ends or errors. It uses stream async iteration from Chapter 7. It still reads from the same lower socket receive path.

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.

Timeouts Report Inactivity

A socket timeout in node:net is an inactivity timer. It reports that the socket had no activity for the configured interval.

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 sends a final message and ends gracefully. It can still hang if the peer stops reading and your final write cannot make progress. A hard idle policy often uses destroy() after timeout. A polite 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. For raw net.Socket code, remember the local rule - the timeout event fires, and your code owns the close policy.

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'));

That code records the idle state and keeps the descriptor open. Under steady traffic, those sockets can accumulate. If the intent is cleanup, perform cleanup in 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 is the TCP address shape, so local socket paths are returned directly.

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 cleanup pattern is common in examples, but production code should avoid blindly unlinking a path owned by another live process. A safer startup checks whether something is listening there before removing stale state. Full IPC design and supervisor behavior belong 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 shapes 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));
});

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

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

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

That is a local shutdown sketch. Production draining needs deadlines, readiness changes, supervisor behavior, HTTP semantics, and deployment rules. Chapter 31 owns that. The node:net fact is smaller - closing the server stops accepts, and connected sockets remain separate objects.

Connection ownership should be explicit in raw net servers. Frameworks often hide this by owning the request lifecycle. A node:net server gives you the socket and lets your code decide what happens next.

A clean ownership rule is this - the code that accepts the socket should also register the 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. If a shared map tracks it, delete from the map in close. If a timer tracks it, clear the timer in close. If a parser holds buffers, drop those references in close. Once the lower handle is gone, application state for that peer should stop retaining 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);
});

That line is application-specific, but the pattern is general. Connect the socket lifecycle to the lifecycle of whatever feeds it and whatever consumes it. Raw TCP code has fewer abstractions, so ownership bugs show up 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. Own 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();
}

That small guard saves noisy shutdown logs. It also makes ownership 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');

That creates the server object, then binds a TCP listening socket on IPv4 loopback with an OS-selected port. The port is 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 event order usually looks like this -

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. Same TCP connection. Two endpoint views.

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.

That 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 shape as 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 another endpoint shape -

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 shape.

Debugging the Object Edge

The fastest way to debug raw socket code is to log the transition where ownership changes.

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 small because Node already wrapped the native socket work for you. The hard part is remembering who owns which state. net.Server owns accepting. net.Socket owns one conversation. TCPWrap and libuv connect those JavaScript objects to the event loop. The OS owns the actual socket state.

Once those ownership lines are clear, the API becomes 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 arrive later than the code that caused them.