Get E-Book
Network Fundamentals with Node.js

Node.js TCP Connections: Flow Control & Shutdown

Ishtmeet Singh @ishtms/May 11, 2026/47 min read
#nodejs#networking#tcp#sockets#flow-control

When a TCP error shows up in your Node.js code, the first instinct is to debug the Node.js code. Most of the time the problem is not there. Node.js does not implement its own TCP behavior. It runs on top of the operating system's TCP stack. Your JavaScript sees events like connect, data, end, error, and close. Underneath those, the OS is the part tracking connection setup, byte order, buffers, ACKs, retransmission, shutdown, resets, and timeouts.

So the error reaches you in JavaScript, but the reason for it almost always started lower down. A socket can be alive and slow, or closed cleanly, or reset without warning, or refused before it ever connects, or stuck waiting on a network path that never answers. Node reports every one of those through the same stream events and system error codes.

TCP Connections and Failure Modes in Node.js

A TCP socket in Node is a JavaScript object wrapped around connection state that the OS manages. When you call socket.write(), Node first accepts the bytes into its own stream layer. From there the bytes move down through libuv, the kernel socket buffer, TCP flow control, congestion control, and out to the peer.

So when socket.write() returns false, read it as a purely local stream signal. It means Node has queued up enough outgoing data that it wants you to wait for drain before writing more. The return value says nothing about whether the peer application received the bytes, or even whether the bytes have left your machine yet.

A slow peer and a broken peer can both make your writes pile up, and the socket state is what tells them apart. With a slow peer you usually still have a valid TCP connection whose buffer space is shrinking. With a broken peer the connection is already moving toward a reset, a close, or a failed write.

ECONNRESET is one of the most common errors you will hit. You see it as a JavaScript error, but it comes from lower TCP state.

text
Error: read ECONNRESET
    at TCP.onStreamRead (node:internal/stream_base_commons:216:20)

The stack trace points at Node because Node is where the failure surfaced in your code. The reset itself happened down in the socket layer. Maybe the peer sent a reset. Maybe your local write hit a socket that already knew the connection was gone. Either way, Node read the result from the kernel, wrapped it as a system error, and emitted it on the net.Socket.

ECONNREFUSED, ETIMEDOUT, and EPIPE work the same way. The JavaScript error tells you which operation failed. The reason behind it sits down in TCP state.

Chapter 9.1 covered how sockets map to local and remote addresses, and Chapter 9.2 covered how names turn into addresses. Once Node has a remote IP and port, TCP takes over. It sets up the connection, numbers the byte stream, resends anything that goes missing, applies flow control, closes each direction, and reports failure once the connection can no longer continue.

A TCP Connection Is Kernel State

A TCP connection is state the OS manages for one ordered byte stream between two socket addresses. It is identified by five things, the protocol, the local IP, the local port, the remote IP, and the remote port. Both endpoints keep their own copy of the state for that same connection.

Node hands you one endpoint as a net.Socket.

js
import net from 'node:net';

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

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

net.connect() asks the OS to create a TCP socket and connect it to the remote address. The OS usually picks the local port. Your JavaScript socket only becomes useful once the kernel finishes connection setup and Node emits connect.

TCP connection state moves through a set of named lifecycle stages. If you have used ss or netstat, you have seen these names. SYN-SENT, SYN-RECEIVED, ESTABLISHED, FIN-WAIT, CLOSE-WAIT, LAST-ACK, TIME-WAIT, and CLOSED.

Node exposes a much smaller set of events. connect fires once the connection reaches the established state. data fires when Node reads bytes off the socket. end tells you the peer finished its write side cleanly. error means some operation failed. close fires when the JavaScript socket wrapper has finished closing.

Those events are simple, but a lot sits underneath them.

text
JavaScript net.Socket
  -> Node native TCP wrapper
  -> libuv TCP handle
  -> OS TCP socket
  -> peer OS TCP socket
  -> peer program

TCP carries a byte stream. It keeps the bytes in order, repairs missing ranges when it can, and hides packet boundaries from the application. That last part, the hidden boundaries, is the part you have to design around.

Take these two writes. They do not turn into two guaranteed reads on the other side.

js
socket.write('abc');
socket.write('def');

The peer might get all of it, 'abcdef', in a single data event. Or it gets 'abc' and 'def' as two events. Or it gets even smaller pieces. TCP kept the bytes in order but threw away your write boundaries.

So every protocol built on TCP has to supply its own framing. HTTP, Redis, Postgres, and any binary protocol you write all need rules for where one message ends and the next begins. TCP keeps the bytes in order, but it never marks where one of your messages stops and the next one starts.

The Handshake Creates the Connection

Before any application data can move, TCP has to set up synchronized state on both endpoints. That setup is the three-way handshake.

text
client -> server - SYN
server -> client - SYN-ACK
client -> server - ACK

The SYN asks to start a connection and carries the sender's initial sequence number. The SYN-ACK accepts that request, carries the server's own initial sequence number, and acknowledges the client's SYN at the same time. The final ACK acknowledges the server's start. Now both sides have enough sequence state to send ordered bytes.

For an outbound Node client, the path runs roughly like this.

text
net.connect()
  -> local socket created
  -> SYN sent
  -> SYN-ACK received
  -> ACK sent
  -> 'connect' emitted

Your JavaScript connect handler runs after the kernel handshake succeeds. If the peer rejects the attempt, Node never emits connect.

js
import net from 'node:net';

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

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

On a normal machine with nothing listening on port 1, this prints ECONNREFUSED. The destination answered the attempt with a refusal, usually a reset, because no listening socket accepted that address and port. Firewalls and OS policy can change the exact path the packets take, but for Node the meaning stays narrow. The connection attempt was actively rejected.

For an inbound server, the OS does its work before JavaScript ever sees the socket.

text
listening socket
  -> SYN received
  -> SYN-ACK sent
  -> ACK received
  -> connected socket queued
  -> Node accepts
  -> 'connection' emitted

Chapter 9.6 covers the backlog and accept queue. The point here is the callback timing. net.createServer() runs your connection callback only after the OS has enough state to hand back an accepted, connected socket.

These three failures come from different points in the lifecycle, which is why they read differently in logs. A refusal fails the connection before it is ever established. A reset happens after a connection has existed, even briefly. A timeout is the in-between case, where the local OS keeps retrying setup and never gets an answer.

Refusal Happens Before the Socket Becomes Useful

ECONNREFUSED is a setup failure. Your process created a JavaScript socket object and aimed it at a remote address, but the TCP connection never reached the established state.

The local loopback case is the easiest one to watch.

text
client sends SYN to 127.0.0.1:65000
kernel finds no listener for that address and port
kernel rejects the attempt
Node emits error ECONNREFUSED

A listener has to match on several things at once. The protocol, the address family, the local address binding rules, and the port all have to line up. A server listening on 127.0.0.1:3000 accepts IPv4 loopback traffic for that port. A client connecting to ::1:3000 is aiming at IPv6 loopback. Same port number, but the address family points at a completely separate listener space. Chapter 9.1 covered address families, and TCP setup uses them directly.

Remote refusal is the same category with more network in between. The SYN reaches the target host, or some device standing in for it, and something sends back a reject signal. Node gets a failed connect result. Your connect handler never runs, because the socket never became established.

Firewalls change the timing. A firewall that rejects gives the client a fast failure. A firewall that silently drops the packets gives the client nothing back, so the client keeps retransmitting SYN and eventually falls into a timeout.

Because of that, your connection logs should record elapsed time. A fast refusal usually means the listener is the problem, while a long silence points at routing, filtering, or a host that is down.

js
import net from 'node:net';

const started = Date.now();
const s = net.connect(65000, '127.0.0.1');

s.on('error', err => {
  console.error(err.code, Date.now() - started);
});

The elapsed time is rough, but it helps. On local loopback, refusal is almost immediate. On filtered remote paths, the delay can be much longer. That changes what you do next. If the failure came back fast, check whether the service is actually listening on the address and port you used. If it took a long time, check network reachability and filtering instead.

Sequence Numbers Make Missing Bytes Recoverable

TCP tracks a byte stream with sequence numbers. Each endpoint numbers the bytes it sends. ACKs tell the sender which bytes arrived in order. With that information, TCP can detect missing ranges, hold later bytes until the missing ones arrive, and resend data when needed.

It helps to think in byte ranges here rather than application writes.

text
client sends bytes 1000..1499
server ACKs 1500
client sends bytes 1500..1999
server ACKs 2000

ACK 1500 means the receiver has accepted every byte before 1500. So bytes through 1499 arrived in order. If bytes 1500..1999 disappear on the network, the sender still knows which range to send again.

Retransmission is TCP sending a byte range again because the sender believes the earlier attempt did not complete. The trigger might be a timeout, duplicate ACK behavior, or another TCP implementation detail. The kernel handles all of it.

Most packet loss shows up in Node as delay, not as an error. If one TCP segment disappears and the kernel recovers through retransmission, your application may only see a late data event. The write callback may still fire, because the local stack accepted the bytes. The peer may still process the data once TCP repairs the missing range.

Round-trip time, or RTT, affects how quickly TCP learns what happened. RTT is the time for data to reach the peer and for an acknowledgment to come back. High RTT stretches out every feedback loop, and RTT that varies a lot makes the kernel's timeout choices harder.

A delayed read can hide a lot of lower work.

text
write accepted locally
  -> segment sent
  -> ACK delayed or lost
  -> retransmission timer adjusted
  -> missing bytes sent again
  -> peer receives ordered bytes
  -> Node emits data later

From JavaScript, the socket stays in a normal state the whole time. No error fires, because TCP is still in the middle of recovering.

Sequence numbers also let TCP receive data out of order internally while still giving JavaScript ordered bytes. The kernel may receive byte range 2000..2499 before 1500..1999. It keeps the later range in receive state and waits for the missing range. JavaScript receives data only after the hole is filled.

This behavior is useful, but it can hide real performance problems. A production service can have packet loss, retransmissions, and poor throughput while Node still reports a connected socket. To see that clearly, you need timing data, OS TCP counters, or packet captures.

ACKs are transport state. They say which byte positions arrived at the TCP layer. They do not say that the remote application parsed the bytes, wrote them to disk, committed a transaction, or sent a response.

That point is easy to miss in write-heavy code.

js
socket.write(payload, err => {
  if (err) throw err;
  markSent(payload.id);
});

markSent() is a risky name there. The callback tells you about the local write path. A safer name would say what you actually know, that the bytes were accepted locally, or that the local write failed. Application delivery still needs a protocol response from the peer.

The peer kernel can ACK bytes before the peer application reads them. The bytes may be sitting in the peer's receive buffer while the peer process runs later. If that process crashes after the ACK but before application handling, TCP has no reason to resend those bytes. The transport delivered them. When you actually need to know the peer handled the bytes, your protocol has to confirm that on its own.

Sequence accounting also includes control signals. SYN and FIN consume sequence positions. You rarely need that detail in Node code, but it explains why setup, data, and shutdown all belong to one ordered TCP state machine. TCP is synchronizing payload bytes and lifecycle transitions together.

Retransmission can also create duplicates below the application. The receive side uses sequence numbers to discard byte ranges it already accepted. JavaScript normally never sees those duplicates.

The order of events runs like this.

text
receiver gets bytes 1000..1499
receiver sends ACK 1500
ACK disappears
sender retransmits bytes 1000..1499
receiver discards the duplicate range

The application still reads only one copy. TCP carried the duplicate because the sender was uncertain, and the receiver discarded it before Node ever saw it.

When data arrives late, Node usually cannot tell you why. The cause might be retransmission, or the peer's process scheduling, or backpressure in the receiver, or the application running above the peer socket. TCP hides all of those behind one ordered byte stream.

That abstraction is fine for most code. It turns painful when logs jump to the wrong conclusion. Late data does not prove the peer application is slow. The network path may be losing segments. Or the network may be fine while the peer process is blocked before it reads.

One Write Becomes Several TCP Decisions

Take one write from a connected Node client.

js
socket.write(Buffer.alloc(32 * 1024));

That call gives Node 32 KiB of application bytes. The stream layer accepts the chunk or queues it. Native code submits write work through libuv. The OS socket path accepts some or all of the bytes into the TCP send buffer. TCP then decides how to send those bytes across the network.

Several limits apply below your JavaScript call. Path MTU caps the size of each packet. The peer's receive window bounds how far the sender can get ahead without overflowing the receiver. Congestion control holds back how much data the sender should put into the network before ACK feedback comes back. And the local send buffer caps how much the OS can hold for you.

A lower-level trace might look like this.

text
app bytes 0..32767 accepted locally
TCP sends 0..1447
TCP sends 1448..2895
peer ACKs 2896
TCP sends more ranges

Those ranges are only illustrative. Actual segment sizes depend on MSS, offload, path behavior, and platform settings. The Node-level fact is smaller. Your write entered the socket path. TCP may send many segments, receive many ACKs, retransmit some ranges, and free send-buffer space later.

The peer reads a stream of bytes.

text
peer kernel receives ranges
peer TCP orders them
peer receive buffer stores bytes
peer Node process reads chunks

The peer's data event might contain 32 KiB, 16 KiB, 1 KiB, or any other chunk size produced by its receive path. TCP protects byte order. Node streams decide how chunks arrive in JavaScript.

ACK timing can also interact with your writes. Suppose the sender writes 32 KiB, then immediately writes another 32 KiB. The local stream can accept both chunks while the kernel is still waiting for ACKs from the first ranges. The second write may sit in Node's queue, libuv state, or the kernel send buffer depending on timing. When the peer window opens, the lower layers continue. JavaScript sees drain only after the Node-side queue falls below its threshold.

That is what is behind a common production log.

text
write returned false
drain after 240ms
response after 900ms

Those three lines describe three different things. Local stream pressure, then permission to resume local production, then actual application-level progress. Logging all of them as network slowness throws away the difference between them.

Flow Control Crosses Into Node Backpressure

Flow control is TCP's way of stopping the sender from overwhelming the receiver's buffers. The receiver advertises how much receive space it has. The sender keeps unacknowledged data within that advertised limit.

That advertised limit is the receive window. As long as the receiving application keeps reading, the receive buffer drains and the window can stay open. Once the application stops reading, the buffer fills up and the window shrinks. A zero or tiny window tells the peer to slow down at the TCP layer.

The path runs like this.

text
peer application writes bytes
  -> peer kernel send buffer
  -> network
  -> local kernel receive buffer
  -> Node reads into stream
  -> JavaScript consumes chunks

The receive buffer is kernel memory holding bytes that have arrived for a socket and are waiting to be read. The send buffer is the other direction, kernel memory holding bytes the application handed over that are waiting to be transmitted, acknowledged, or resent.

Node stream backpressure sits above those buffers. The stream has its own queue and highWaterMark. The kernel has send and receive buffers. TCP has a receive window. These signals live at separate layers, but they affect one another.

Here is a server that accepts a connection and then stops reading from the JavaScript stream.

js
const server = net.createServer(socket => {
  socket.pause();
});

Bytes can still arrive for a while. Node may have already pulled some bytes into stream buffers before pause() takes effect. The kernel receive buffer can also fill. Once those lower buffers tighten, TCP advertises less receive space to the peer.

The sender sees pressure through its own write path.

js
import net from 'node:net';

const server = net.createServer(s => s.pause());

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

  const c = net.connect(port, '127.0.0.1', () => {
    while (c.write(Buffer.alloc(64 * 1024))) {}

    console.log('write pressure');
    c.destroy();
    server.close();
  });
});

That loop writes until Node's writable side says to stop. The false return is a stream-level signal. It means the local writable queue crossed its threshold. The peer application and peer kernel still have their own state. The producer should wait for drain before continuing.

Below that signal, the local kernel send buffer also has finite capacity. Node may hand bytes to the OS until the OS accepts fewer of them, accepts none for now, or reports an error. libuv connects that non-blocking behavior back to JavaScript through callbacks and drain.

Flow control is remote receive pressure making its way back to you through TCP. Stream backpressure is a separate thing, Node telling your JavaScript to slow production. They often show up during the same slowdown, but they originate in different layers.

The full sending path runs like this.

text
JavaScript producer
  -> Writable stream queue
  -> Node/libuv write request
  -> kernel send buffer
  -> TCP flight governed by peer receive window
  -> peer kernel receive buffer
  -> peer JavaScript consumer

A successful socket.write() means Node took the chunk into its write path. A true return value means the stream buffer is still under its threshold. The write callback firing means the chunk moved out of Node's user-space tracking and down into the local system path. None of that proves the peer application processed a single byte.

The peer can slow you down in a lot of places. Its JavaScript might be busy and slow to read. Its process might be paused. Its kernel receive buffer might be full, or its TCP receive window might have shrunk. The network in between might be dropping packets. Closer to home, your own send buffer might be filling up, and Node's stream queue might have crossed highWaterMark.

From JavaScript, the visible pattern may only be this.

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

That pattern is still correct. It respects Node's stream contract and keeps your memory bounded while the lower transport grinds through its limits. Read drain as permission to start writing again, not as proof that the peer understood anything you sent.

Receive pressure works in the other direction. When your Node program reads from a socket and writes into a slower destination, stream.pipeline() can connect pressure between streams. At the TCP layer, slowing reads can eventually reduce the advertised window. The peer may keep the connection open while sending far less data. No exception is required. The connection is doing what flow control allows.

This is a confusing one to debug. The request hangs while CPU stays low and nothing throws. The socket may be waiting because a buffer below JavaScript has no useful space. Or the peer's receive window may be small. Or retransmission and congestion behavior may be limiting progress. Node reports an error when TCP state fails, not when TCP is waiting legally.

Three separate things hold data on the sending side.

text
Node stream buffer
libuv write requests
kernel TCP send buffer

The stream buffer is JavaScript-facing. It drives the write() return value and drain. libuv write requests are native operation records waiting for the OS path. The kernel send buffer is TCP-facing. It holds byte ranges that may be unsent, sent but unacknowledged, or waiting for retransmission.

Each of these moves at its own pace. Node can accept chunks from JavaScript, then feed them into libuv writes. The OS can accept some bytes into the send buffer and leave more work pending. TCP can send some byte ranges while holding others because the peer receive window or congestion state limits progress.

The receive side has its own chain of stages.

text
kernel TCP receive buffer
Node native read path
Readable stream buffer
JavaScript consumer

The kernel receive buffer is filled by TCP after sequence checks. Node reads from it when libuv reports readability. The Readable stream buffer stores chunks until JavaScript consumes them. If JavaScript stops consuming, Node can stop reading for a while. The kernel receive buffer then fills, and the advertised receive window shrinks.

So a slow parser, a blocked transform, or a paused socket in your JavaScript code can eventually show up as TCP receive pressure to the peer. The peer does not know your parser is busy. It only sees window and ACK behavior.

The reverse happens too. If the peer's receive window is tiny, your local kernel send buffer drains slowly, Node write requests complete slowly, and the Writable stream queue stays high for longer. write() returns false more often. JavaScript sees a local stream signal that began life as remote receive pressure.

Backpressure-aware code helps here even though it cannot see every layer.

js
import { once } from 'node:events';

async function send(socket, chunks) {
  for (const chunk of chunks) {
    if (!socket.write(chunk)) await once(socket, 'drain');
  }
}

The loop obeys the stream signal. It keeps application memory from growing without limit while TCP works through its own constraints. It does not claim that the remote application processed anything.

The broken version usually looks like this.

js
for (const chunk of chunks) {
  socket.write(chunk);
}

That loop assumes the socket will accept bytes as fast as you can produce them. If the connection slows, your process can queue a huge amount of data in user space. The remote process may still be alive. TCP may be applying valid flow control. Your process can still run itself out of memory by ignoring the local backpressure signal.

Congestion Makes Working Connections Slow

Flow control protects the receiver's buffers. Congestion control protects something else, the network path between the two endpoints. It decides how aggressively the sender should push data into that path.

Congestion control runs in the kernel. Linux, macOS, Windows, and container hosts may use different defaults and tuning. Chapter 9.6 mentions socket options, but congestion algorithms mostly sit outside the normal Node API path.

For backend debugging, a simple model is enough. The sender adjusts its sending rate based on ACKs, packet loss, RTT, and congestion algorithm state. When loss or delay suggests congestion, the sender reduces how much data it sends before getting more ACK feedback. Throughput drops, but the connection can stay open.

text
ACKs arrive steadily
  -> sender grows usable sending rate
loss or delay appears
  -> sender retransmits
  -> sender reduces sending rate

A slow upload over TCP can be a healthy connection doing congestion control and retransmission. Node writes. The kernel accepts some data. Progress continues, but at a lower rate. Your application timeouts may fire above the transport if you set them. TCP itself can keep trying as long as the OS considers the connection usable.

RTT decides how much this actually slows you down. When RTT is low, the sender learns about delivered bytes quickly. When it is high, every feedback loop takes longer, so the same packet loss rate costs far more over a long path, because acknowledgments and retransmission signals take longer to come back.

What makes congestion hard to diagnose is the silence. TCP recovery is usually quiet at the JavaScript layer. You get late data, a delayed drain, or a request deadline from your own code, and the socket can stay established the whole time.

When a log shows the socket connected and then goes quiet for thirty seconds, there are a few possibilities to think through.

text
peer application is slow
peer receive path is backed up
network transport is recovering or constrained

From Node these all look the same, and they need different evidence to tell apart. Application logs show handler progress. Socket buffer and TCP counters show transport pressure. A packet capture shows the retransmissions and ACK behavior. The net.Socket on its own cannot tell you which layer is holding things up.

Orderly Shutdown Uses FIN

FIN is TCP's clean end-of-data signal for one direction of a connection. An endpoint that sends FIN is declaring that it has finished writing. The peer can keep sending bytes in the other direction until it closes its own write side.

The common close path runs like this.

text
local app ends writes
  -> local TCP sends FIN
  -> peer receives end-of-stream
  -> peer sends its own FIN later
  -> both FINs are ACKed
  -> connection closes

Node maps the peer's FIN to readable stream end behavior. On a net.Socket, the readable side can emit end when the peer has finished sending. The socket may still have write state depending on timing and API options. Chapter 9.4 covers net.Socket methods and allowHalfOpen. Here the TCP idea is simple. Each direction can close on its own.

A half-open connection means one direction has closed while the other direction stays open. One side has sent or received FIN, and the other side can still send data. That is normal during clean shutdown. It becomes a bug when application code assumes both directions ended together.

text
peer -> local - FIN
local readable side ends
local write side may still send

Some protocols use half-open behavior deliberately. Many application protocols treat it as full connection termination. Node gives you events and options to decide. TCP itself treats the two directions independently.

TIME-WAIT is a TCP state kept after active close. It gives late packets from the old connection time to expire and lets final acknowledgments be handled. The endpoint that performs the active close commonly enters TIME-WAIT. Duration and reuse rules depend on the OS.

You often see TIME-WAIT during local tests that create many short connections. Your process closed its sockets, but the OS still has connection state. That state can consume local ephemeral ports for a while. Your JavaScript code is done, while the kernel is still holding the old connection identity.

text
ESTABLISHED
  -> FIN-WAIT-1
  -> FIN-WAIT-2
  -> TIME-WAIT
  -> CLOSED

Tool output can show many TIME-WAIT sockets after a load test. That is normal TCP teardown behavior. It becomes operational pressure when ephemeral ports or socket-table capacity run low.

Clean shutdown still has application risk. A peer can send FIN after sending only part of an application message. TCP delivered ordered bytes and then end-of-stream. Your protocol parser must decide whether the message was complete. TCP can tell you the byte stream ended, but not whether your application frame was complete.

The active closer usually pays the TIME-WAIT cost. Simultaneous close and platform behavior can change the exact state path, but the common client-server case is easy to recognize. A client opens many short outbound connections, sends requests, actively closes, and then accumulates many TIME-WAIT entries using local ephemeral ports.

text
client local port 50100 -> server 443
client closes
client keeps TIME-WAIT for that tuple
client opens more short connections
ephemeral range gets pressured

Connection pooling reduces that pressure by reusing established TCP connections for multiple application requests. HTTP agents and database pools make those choices later in the book. At the TCP level the reason is simple. Fewer teardowns means fewer recently closed tuples sitting in the kernel.

Servers can accumulate close states too. If the peer sends FIN, the local TCP endpoint can move into CLOSE-WAIT until the local application closes its side. A pile of CLOSE-WAIT sockets usually means the application received peer close and failed to close its own socket.

text
peer sends FIN
local TCP enters CLOSE-WAIT
Node emits end
application leaves socket open
CLOSE-WAIT remains

TIME-WAIT and CLOSE-WAIT mean very different things. TIME-WAIT is normal cleanup after an active close. CLOSE-WAIT means the peer ended its write side and your application still has a socket it never closed.

Node's event order can make this visible.

js
socket.on('end', () => console.log('peer ended'));
socket.on('close', () => console.log('closed'));

An end with no close inside the time window you expect is worth a look. The protocol might allow half-open behavior, the code might have forgotten to close, or a pending write might still be flushing. Chapter 9.4 covers the API switches, and the TCP state here is why the symptom shows up at all.

FIN also interacts with buffered writes. If your program calls socket.end('bye'), Node queues the bytes and then ends the write side. The local TCP stack sends the data before the FIN in the ordered byte stream. The peer reads the bytes, then sees end-of-stream. If the connection resets before those bytes are sent or acknowledged, the clean shutdown path stops and error handling takes over.

Abrupt Shutdown Uses RST

RST is TCP's reset signal. It aborts connection state instead of closing the stream cleanly. A reset tells the peer to stop using that connection state. Node often reports this as ECONNRESET.

Resets can happen for several reasons.

text
write reaches a peer that has reset state
peer process destroys socket abruptly
middlebox rejects an existing flow
local OS receives data for a closed connection

Node can send a reset on purpose too. In Node v24, use the reset-specific API when that is what you want.

js
socket.resetAndDestroy();

resetAndDestroy() closes the TCP connection by sending RST, then destroys the stream state. destroy() is the general stream teardown API. The exact packets for destroy() depend on timing and platform state. Chapter 9.4 covers both APIs. At the TCP level, a reset means the peer loses the connection state and later operations can fail.

Here is a small client-side example.

js
const c = net.connect(port, '127.0.0.1', () => {
  c.write('hello');
});

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

If the server resets immediately, the client may see ECONNRESET. Timing decides which operation reports it. A read can fail, or a later write can fail. The reset can land between JavaScript turns and surface on whatever socket operation runs next.

That timing creates a common debugging trap. The line that logs ECONNRESET is often after the real cause. The cause may be the peer closing abruptly earlier, a protocol violation that made the peer reject the connection, an idle timeout in the path, or local code destroying the socket because an upper layer gave up.

EPIPE is the broken-pipe style error reported when writing to a connection whose write side can no longer accept data. On Unix-like systems, the name comes from pipe behavior, but Node can expose it for sockets too. It means the OS rejected the write because the write path is broken.

text
peer has closed or reset
local code writes anyway
OS rejects the write
Node reports EPIPE or ECONNRESET depending on timing and platform

Use the error code as a starting clue, then check the order of events. The socket might have emitted end before your write, your own timeout might have called destroy(), the peer might have sent a protocol-level error and closed, or an upstream proxy might have cut an idle connection. The TCP error names the operation that failed. On its own, it does not tell the whole story.

RST is also how TCP rejects data for state it cannot accept. A host may receive a segment for a connection tuple that no longer exists. It can send a reset to tell the peer to stop using that tuple. From the sender's side, the connection looked alive locally right up until the reset arrived. From the receiver's side, the tuple was already invalid.

That can happen after crashes, restarts, and fast reconnects. A server process exits and loses its sockets. The client still has an established connection locally for a short time. The next client write reaches a host with no matching connection state, or a new listener that knows nothing about the old tuple. The client then sees reset or broken-pipe behavior.

text
client thinks ESTABLISHED
server process exits
server TCP state disappears or resets
client writes again
client observes reset or write failure

A log line that just says the server restarted hides this lower sequence. A new process can listen on the same port and handle new connections, but it does not inherit the old established TCP state. An ordinary restart breaks every existing connection.

Reset timing also affects retries. If a client sends a request and gets ECONNRESET before any response bytes, the request may or may not have reached the peer application. TCP cannot answer that. Safe retry behavior depends on the application protocol, idempotency, and request semantics. Chapter 27 covers those policies.

Refused, Reset, Timed Out, Broken Pipe

ECONNREFUSED means the connection attempt reached a host that actively rejected the target address and port. The common local case is a closed port.

js
import net from 'node:net';

const s = net.connect(65000, '127.0.0.1');

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

If no process listens there, the local host usually refuses quickly. A firewall can change the symptom by dropping traffic instead of rejecting it. Dropped packets usually produce waiting and eventual timeout rather than fast refusal.

ETIMEDOUT means an operation waited too long for transport progress. For connect attempts, it often means the local TCP stack sent SYN packets and never received a usable response. Firewalls, routing problems, dead hosts, and filtered ports can all produce that outcome.

text
SYN sent
  -> no SYN-ACK
  -> retransmit SYN
  -> still no response
  -> timeout reported

Node can also have application-level socket timeouts through APIs covered later. Keep the source straight. A TCP connect timeout comes from connection setup failing to finish. A socket.setTimeout() event is a JavaScript timer around inactivity. An HTTP client deadline sits above TCP.

ECONNRESET means established connection state was aborted. A peer reset, a local reset, or a path device can cause it. The socket was connected enough for reset behavior to apply. The failure often appears on a read or write after the reset arrives.

EPIPE means a write hit a closed or broken write path. The peer may have already closed. The local socket may already know writes are invalid. The application attempted to send anyway.

Here is a compact table for reading these in logs.

CodeUsual TCP positionHow to read it
ECONNREFUSEDduring connecttarget actively rejected the address and port
ETIMEDOUTduring connect or OS-level send/keepalive timeoutan operation waited too long for transport progress
ECONNRESETafter connection existsconnection state was aborted
EPIPEduring writewrite side was already broken

These are system errors. Node exposes OS-level code strings on error objects. The same application bug can produce different codes across platforms or timing windows. Read the code as a state clue, then line it up with endpoint addresses, recent socket events, and protocol logs.

Timeouts need careful logging because several layers use the same word.

text
TCP retransmission timeout
TCP connect timeout
Node socket inactivity timeout
HTTP request deadline
application cancellation

A TCP retransmission timeout is internal to the kernel. It decides when a missing ACK has taken too long and a byte range or SYN should be sent again. Node usually sees the result as delay.

A TCP connect timeout means setup failed to finish in time. The OS sends SYNs, waits, retries according to its policy, and eventually reports failure. Node surfaces that as a connect error if no higher deadline acted first.

A Node socket inactivity timeout comes from JavaScript API calls. It watches for inactivity on the socket and emits a timeout event. The socket stays open until your code closes or destroys it.

js
socket.setTimeout(5000);

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

Here, your code chooses to destroy the socket after five seconds of inactivity. The peer may later see reset-like behavior. The source was an application timer.

An HTTP request deadline sits higher. It can close a TCP socket because an HTTP response took too long, even while TCP was still healthy. The resulting TCP error on the peer can look transport-level, but the reason was protocol policy above TCP.

Cancellation works the same way. An AbortSignal tied to a client operation can destroy a socket that TCP would otherwise keep using. The remote side might log ECONNRESET while your local side records a user abort. Both are accurate, each at its own layer.

Good timeout logs name the layer that acted.

text
connect timeout to 203.0.113.10:443
socket idle timeout after connect
HTTP response deadline exceeded
operation aborted by caller

Those messages save you time because they say who acted first. TCP, a Node socket timer, a protocol client, and caller cancellation can all tear a connection down. A useful log records which one made the call.

Which timeout fired also affects cleanup. A connect timeout usually leaves you with a socket that never emitted connect. An idle timeout after establishment leaves you with a connected socket that your code chose to destroy. A request deadline can destroy a pooled connection that another part of the client hoped to reuse. The peer may see the same TCP error across those cases, while the local cause sits in a higher layer.

Group your timeout metrics by phase. Connect, TLS later on, request write, response headers, response body, and idle pool lifetime. This subchapter only covers the TCP pieces, but the habit is worth starting now. Name the phase, then close whatever socket state your code is responsible for.

A Slow Reader Looks Different From a Broken Peer

Slow peers and broken peers can both stall your writes. The connection state is what tells them apart.

With a slow reader, the TCP connection is still valid. The receiver advertises limited window space. The sender queues bytes, waits for ACKs and window updates, and continues when space appears. Node may return false from write() and later emit drain.

text
write returns false
  -> local queue drains slowly
  -> drain fires
  -> connection remains established

With a broken peer, the connection state has ended or reset. Writes fail. Reads may error or end. Waiting for drain may be wrong, because the socket is already moving toward teardown.

text
peer resets
  -> local socket records error
  -> next read or write reports ECONNRESET
  -> close follows

During debugging, log the event order.

js
for (const name of ['connect', 'end', 'error', 'close', 'drain']) {
  socket.on(name, arg => console.log(name, arg?.code));
}

That snippet is rough on purpose, and it only shows event order. In real debugging, log the endpoint fields too. localAddress, localPort, remoteAddress, remotePort, and the operation your code was running when the event fired.

Timing changes what you see, because TCP state shifts underneath JavaScript. A reset can arrive while you are preparing the next write. A FIN can land after you have already queued data. A timeout can destroy the socket while a Promise chain still holds a reference to it. By the time your callback actually runs, the kernel state may have moved on.

Reads, Writes, and What Success Means

socket.write() success is local acceptance. It says the data entered Node's writable path. By the time a write callback fires, the data may also have moved into the kernel path. Peer application processing needs evidence from the protocol above TCP.

js
socket.write('COMMIT\n', err => {
  if (err) console.error(err.code);
});

The callback reports the local write operation. For an application-level commit, you still need an application-level response. TCP can deliver the bytes, but the remote program is what decides what they mean.

Reads have the matching rule. A data event means Node pulled bytes from the socket receive path. Full application messages need your parser to assemble chunks according to your protocol's framing.

js
socket.on('data', chunk => {
  parser.push(chunk);
});

Your parser handles message framing. TCP handles byte order and delivery attempts. Node streams move chunks between the two.

During shutdown, success becomes more conditional. A peer can accept bytes into its kernel receive buffer and crash before its application processes them. A local write can complete before a later reset tells you the peer rejected the connection. TCP cannot confirm remote application handling. All it reports is transport state.

Request-response protocols wait for a response for exactly this reason. A database driver, an HTTP client, or a queue producer treats the protocol response as the acknowledgment that counts. The TCP write callback is only lower-level progress.

What Local Demos Show and Hide

Loopback demos remove route noise, DNS noise, and external packet loss. They still exercise TCP state. That makes them useful for learning event order, though limited for production diagnosis.

The refused demo is the clean one.

js
import net from 'node:net';

const s = net.connect(65000, '127.0.0.1');

s.on('connect', () => console.log('connected'));
s.on('error', err => console.error(err.code));

With no listener, error fires and connect never does. The local host rejected setup outright. A remote firewall that drops SYNs gives you a different timeline instead. No fast rejection, repeated SYN attempts, then a timeout path or your own deadline firing.

The reset demo depends heavily on timing.

js
import net from 'node:net';

const server = net.createServer(s => s.resetAndDestroy());

server.listen(0, '127.0.0.1', () => {
  const c = net.connect(server.address().port, '127.0.0.1');

  c.on('error', err => console.error(err.code));
  c.on('close', () => server.close());
});

The server accepts and sends a reset. The client may report ECONNRESET, or it may close quickly depending on when the reset is observed and which side had pending operations. A demo that produces different event order across runs is useful, because resets are asynchronous relative to JavaScript.

The slow-reader demo is also local, but it shows a real pressure path.

js
import net from 'node:net';

const server = net.createServer(s => s.pause());

server.listen(0, '127.0.0.1', () => {
  const c = net.connect(server.address().port, '127.0.0.1');

  c.on('connect', () => {
    console.log(c.write(Buffer.alloc(1e6)));
    c.destroy();
    server.close();
  });
});

Depending on buffer sizes, the first large write may return false. If it returns true, write more chunks. The server is alive and connected, and it has stopped consuming data. Pressure then builds from the receiving application back out into Node's buffers and TCP receive-window behavior.

Local demos also hide congestion. Loopback has tiny RTT and very high effective bandwidth compared with remote paths. Retransmission and congestion behavior barely show up unless you use OS traffic shaping tools. The event categories stay the same, but the timing profile changes completely on real networks.

Reading Host TCP State

Node events tell you what reached JavaScript. Host TCP state tells you what the kernel is still tracking, which is not always the same thing. On Linux, ss is usually the first tool to reach for.

bash
ss -tan

The output shows local addresses, peer addresses, and TCP state. During a local demo, you might see ESTAB, TIME-WAIT, CLOSE-WAIT, or setup states if you catch them quickly. The exact abbreviations depend on the tool and platform.

Use it with endpoint tuples. If Node logs local 127.0.0.1:50100 remote 127.0.0.1:3000, search for those ports in ss output. A matching ESTAB entry means the kernel still considers the connection established. TIME-WAIT means teardown completed through the active-close path and the kernel is holding the tuple for a while. CLOSE-WAIT means the peer sent FIN and the local process still has close work to do.

The process view and the socket view can briefly disagree. JavaScript may have emitted close while TIME-WAIT remains in the kernel table. That is expected. The JavaScript wrapper is finished, while TCP cleanup state remains. JavaScript may also still hold a net.Socket object while the kernel has already recorded a reset. The next read or write will surface that state.

On a busy server, totals are usually more useful than single rows.

bash
ss -tan state time-wait
ss -tan state close-wait

A lot of TIME-WAIT sockets after outbound load usually means many short-lived connections. A lot of CLOSE-WAIT sockets points instead at application code that saw the peer close and kept the descriptors open. SYN-SENT entries piling up can mean slow or filtered outbound connect attempts. SYN-RECEIVED entries involve backlog and SYN handling, which Chapter 9.6 covers.

Node cannot expose all of that through net.Socket, because the state belongs to the OS. Good debugging combines three views. Node event order for what your process saw, kernel TCP state for what the host is still tracking, and protocol logs for what the application believed it finished.

Failure Usually Belongs To a Side

TCP errors become easier to read when you attach them to the operation that was happening.

Take an outbound connect failure first.

text
local endpoint picked
remote address targeted
handshake fails
Node emits error before connect

An established read failure reads differently.

text
connection established
peer or path resets
local read observes reset
Node emits ECONNRESET

A write after the peer is gone looks like this.

text
connection established
peer closes or resets
local code writes later
OS rejects write
Node emits EPIPE or ECONNRESET

And a clean close from the peer.

text
peer sends FIN
local readable side sees end
local code decides whether to write or close
close completes after teardown

A single timeline can include several of these. A client connects, writes a request, receives a partial response, and then the peer resets. The log might show data, then error ECONNRESET, then close. That means TCP delivered some ordered bytes and later aborted state. Your protocol parser decides whether the partial response is usable. Most request-response protocols discard it.

The endpoint tuple from Chapter 9.1 still identifies the connection. Two connections to the same server port are separate connections if their local ephemeral ports are not the same. One can reset while the other stays established. A server log that records only the remote IP throws away the remote port, and with it the connection identity.

js
socket.on('error', err => {
  console.error({
    code: err.code,
    local: `${socket.localAddress}:${socket.localPort}`,
    remote: `${socket.remoteAddress}:${socket.remotePort}`
  });
});

Those fields may be undefined before connection or after teardown, depending on timing. When present, they attach the error to the TCP endpoint pair.

The State Machine Under a Node Socket

A net.Socket is a small thing to reason about. The TCP underneath it is not. Node gives you an object with methods and events. The kernel runs a full state machine with timers, sequence numbers, buffers, windows, retransmission, and teardown states. Most of the time those two views line up well enough. The hard bugs show up in the timing gaps between them.

The TCP connection state machine: setup from CLOSED through SYN-SENT or LISTEN and SYN-RCVD to ESTABLISHED, the active-close path FIN-WAIT-1, FIN-WAIT-2, TIME-WAIT, the passive-close path CLOSE-WAIT, LAST-ACK, and reset transitions, each labelled with the Node call or event.

During connect, JavaScript has a net.Socket immediately. The OS may still be in SYN-SENT. Code can attach listeners, set options, or even queue writes before the connect event. Node stores that intent and flushes it if the native connection path succeeds. If the handshake fails, queued work is discarded through error handling. The JavaScript object existed the whole time, but the TCP connection became usable only after the handshake completed.

During transfer, JavaScript writes chunks. Node's stream layer counts the queued bytes, libuv tracks the write requests, and the kernel send buffer holds bytes that may still need transmission, acknowledgment, or retransmission. TCP sequence state records which byte ranges are still outstanding. The peer's receive window caps how far ahead the sender can get, and congestion control caps how much it should put into the network. A single JavaScript write() can touch every one of those layers without ever exposing the states in between.

Reads have their own path. The kernel receives TCP segments, acknowledges bytes, stores them in the receive buffer, and reports readability. libuv observes readiness. Node pulls bytes into stream machinery. JavaScript sees data chunks according to stream state. If the application pauses the stream, Node can stop reading for a while. Kernel receive space then becomes the limiting resource, and the advertised receive window can shrink. What the peer sees is transport pressure, with no JavaScript event behind it that it could know about.

Shutdown adds more timing. socket.end() means the application is done writing. Node flushes pending writes and then asks the lower layer to close the write side. TCP sends FIN only after queued bytes are handled according to the local stack's rules. The peer may still send data. Your local socket can receive after ending its write side. If the peer sends FIN, Node may emit end before close. If either side sends RST, the clean path stops and errors can surface on operations already queued in JavaScript.

Timeouts sit alongside all of this. TCP has retransmission timers, Node sockets can have inactivity timers, higher protocols can have request deadlines, and a user can abort an operation outright. Any one of them can destroy the socket. The final error code reflects whichever layer acted first, or the OS result that Node happened to observe. Two runs of the same code can report different symptoms when packet timing changes which side moves first.

Being connected is only true for a moment. It means the connection reached the established state at some point in the past. After that, every read and write runs against whatever the current TCP state is. A peer FIN, a peer RST, a local timeout, a process exit, a lost route, or a retransmission failure can all move the kernel state while JavaScript is still holding a socket reference.

Readable logs follow the lifecycle. Record when connect started, when it completed, when each side ended, when your code destroyed the socket, which write was in progress, and which endpoint tuple was involved. Without that order, ECONNRESET is just a label telling you some lower socket state changed before your operation could finish.

What Belongs Above TCP

TCP gives reliable ordered delivery at the byte-stream layer while the connection stays usable. Application message framing, remote processing confirmation, retry policy, and deadline policy all belong above it.

The transport can deliver half a protocol frame and then end cleanly with FIN. It can accept a local write and report a reset later. It can stall because the peer's receive window is closed, or retransmit for a while and then give up with a timeout. Every one of those is a valid TCP outcome, and Node reports them through stream events and system errors.

Backend code needs a short, plain discipline. Treat TCP as the byte transport. Make the application protocol prove completion. Frame your messages. Wait for protocol acknowledgments. Respect write() backpressure. Log endpoint tuples and event order. Keep retries and circuit breakers in their own layer, because safe retry behavior depends on what the application protocol may have already done.

The next subchapter moves up one level, into the node:net API. The method names are simpler to work with, but every one of them still drives the same kernel TCP state this subchapter walked through.