Get E-Book
Network Fundamentals with Node.js

Node.js TCP Connections: Flow Control & Shutdown

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

Node.js does not invent its own TCP behavior. It sits on top of the operating system's TCP stack. Your JavaScript code sees events like connect, data, end, error, and close. Under that, the OS is tracking connection setup, byte order, buffers, ACKs, retransmission, shutdown, resets, and timeouts.

That is why TCP bugs can feel confusing in Node. The error shows up in JavaScript, but the reason usually started lower down. A socket can be alive, slow, closed cleanly, reset suddenly, refused before it connects, or stuck waiting for a network path that never answers. Node reports those outcomes through normal 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 OS connection state. When you call socket.write(), Node accepts bytes into its stream layer first. After that, those bytes move toward libuv, the kernel socket buffer, TCP flow control, congestion control, and finally the peer.

So when socket.write() returns false, read that as a local stream signal. It means Node has queued enough outgoing data and wants you to wait for drain. It does not prove that the peer application received the bytes. It does not even prove that the bytes have crossed the network yet.

A slow peer and a broken peer can both make writes feel bad, but they leave the socket in very different states. A slow peer may still have a valid TCP connection with shrinking buffer space. A broken peer has moved toward reset, close, or write failure.

ECONNRESET is one of the most common examples. It reaches you as a JavaScript error, but the cause is lower TCP state -

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

That stack trace points at Node because Node is where the failure reached your code. The reset itself came from the socket layer. The peer may have sent a reset. Or your local write may have hit a socket that already knew the connection was gone. Node read the kernel result, wrapped it as a system error, and emitted it through the net.Socket.

The same idea applies to ECONNREFUSED, ETIMEDOUT, and EPIPE. The JavaScript error is the report. TCP state is where the report came from.

Chapter 9.1 covered how sockets map to local and remote addresses. Chapter 9.2 covered how names become addresses. Once Node has a remote IP and port, TCP takes over. It creates the connection, numbers the byte stream, resends missing data, applies flow control, shuts down each direction, and reports failure when the connection can no longer continue.

A TCP Connection Is Kernel State

A TCP connection is OS-managed state for one ordered byte stream between two socket addresses. The connection is identified by protocol, local IP, local port, remote IP, and remote port. Both endpoints keep their own state for that same connection.

Node gives 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 local port is usually chosen by the OS. Your JavaScript socket becomes useful after the kernel finishes connection setup and Node emits connect.

TCP connection state moves through known lifecycle names. You may have seen these in tools like ss or netstat - SYN-SENT, SYN-RECEIVED, ESTABLISHED, FIN-WAIT, CLOSE-WAIT, LAST-ACK, TIME-WAIT, and CLOSED.

Node gives you a smaller, friendlier event surface. connect means the connection reached the established state. data means Node read bytes from the socket. end means the peer finished its write side cleanly. error means an operation failed. close means the JavaScript socket wrapper has finished closing.

Those events are simple, but they stand on top of several layers -

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 bytes in order, repairs missing ranges when it can, and hides packet boundaries from the application. That last part is important.

These two writes do not create two guaranteed reads on the other side -

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

The peer might receive 'abcdef' in one data event. It might receive 'abc' and 'def' in two events. It might receive smaller chunks. TCP preserved the byte order. It did not preserve your application write boundaries.

That is why every protocol built on top of TCP needs its own framing. HTTP, Redis, Postgres, and your own binary protocol all need rules for where one message ends and the next begins. TCP gives ordered bytes. Your protocol gives those bytes meaning.

The Handshake Creates the Connection

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

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

SYN asks to start a connection and carries the sender's initial sequence number. SYN-ACK accepts the start and carries the server's initial sequence number while acknowledging the client's SYN. ACK acknowledges the server's start. After that, both sides have enough sequence state to send ordered bytes.

For an outbound Node client, the path looks 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 connection 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 typical machine with no listener on port 1, this prints ECONNREFUSED. The destination answered the connection attempt with a refusal, commonly through a reset, because no listening socket accepted that address and port. Firewalls and OS policy can change the exact path, but the meaning for Node stays narrow - the connection attempt was actively rejected.

For an inbound server, the OS starts the work before JavaScript sees the socket -

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

The backlog and accept queue are covered in Chapter 9.6. For now, the important point is callback timing. net.createServer() runs your connection callback after the OS has enough state to return an accepted connected socket.

A refused connection happens before the socket becomes established. A reset usually happens after the connection existed. A timeout can happen while the local OS keeps trying to finish setup. Those paths show up differently in logs because they come from different points in the TCP lifecycle.

Refusal Happens Before the Socket Becomes Useful

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

The local loopback case is the easiest one to see -

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 the protocol, address family, local address binding rules, and port. A server listening on 127.0.0.1:3000 accepts IPv4 loopback traffic for that port. A client connecting to ::1:3000 targets IPv6 loopback. The port number is the same, but the address family points at a separate listener space. Chapter 9.1 covered address families. TCP setup uses them directly.

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

Firewalls change the timing. A firewall can reject, which gives the client a fast failure. It can also drop packets, which gives the client silence. Silence leads to SYN retransmissions and eventually a timeout path.

Because of that, connection logs should include elapsed time. Fast refusal points you toward the listener. Long silence points you toward routing, filtering, or a dead target.

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 usually immediate. On filtered remote paths, the delay can be much longer. That changes your next move. For fast refusal, check whether the service is listening on the address and port you used. For long silence, check network reachability and filtering.

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.

The useful way to think about it is byte ranges, not 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 reason may be a timeout, duplicate ACK behavior, or another TCP implementation detail. The kernel owns this work.

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 every feedback loop. Variable RTT makes timeout choices harder for the kernel.

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 can look healthy the whole time. No error fires because TCP is still 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 reflect what you actually know - the bytes were accepted locally, or 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 did its job. Your protocol needs its own confirmation when confirmation is important.

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.

Here is the shape -

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

The application reads one copy. TCP carried duplicate protocol traffic because the sender had uncertainty, and the receiver cleaned it up before Node saw it.

When data arrives late, Node usually cannot tell you why. It could be retransmission. It could be peer scheduling. It could be receiver backpressure. It could be the application above the peer socket. TCP hides those causes behind one ordered byte stream.

That abstraction is good for most code. It becomes 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. The path MTU limits packet size. The peer receive window limits how far the sender can advance without overflowing the receiver. Congestion control limits how much data the sender should place into the network before ACK feedback arrives. The local send buffer limits how much data the OS can hold.

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 explains a common production log -

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

The first line is local stream pressure. The second line means local production can resume. The third line is application protocol progress. Calling all three "network slowness" loses useful information.

Flow Control Crosses Into Node Backpressure

Flow control is TCP's way of preventing 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. When the receiving application reads quickly, the receive buffer drains and the window can stay open. When the application stops reading, the receive buffer fills and the window shrinks. A zero or tiny window tells the peer to slow down at the TCP layer.

The path looks like this -

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

A receive buffer is kernel memory holding bytes that arrived for a socket and are waiting for the application to read them. A send buffer is kernel memory holding bytes accepted from the application and waiting for transmission, acknowledgment, or retransmission.

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 bytes, 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 coming back through TCP. Stream backpressure is Node telling JavaScript to slow production. They can happen during the same slowdown, but they come from separate layers.

The full sending path looks 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 accepted the chunk into its write path. A true return means the stream buffer is still under its threshold. A write callback means the chunk moved through Node's user-space write tracking into the local system path. None of those signals prove the peer application processed the bytes.

The peer can slow you down in many places. Its JavaScript code can be busy and delay reads. Its process can be paused. Its kernel receive buffer can fill. Its TCP receive window can shrink. The network path can lose packets. Your local send buffer can accumulate data. Node's stream queue can cross highWaterMark.

The visible JavaScript pattern may only be this -

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

That pattern is still right. It respects Node's stream contract and keeps memory bounded while the lower transport works through its limits. Treat drain as permission to resume local writing. Do not treat it as proof that the peer understood the data.

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.

That can look strange during debugging. A request hangs, CPU is low, and no error fires. 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.

The sending side has three owners to keep in mind -

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.

Those owners move at their 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 -

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 also happens. If the peer's receive window is tiny, your local kernel send buffer drains slowly. Node write requests complete slowly. The Writable stream queue stays high longer. write() returns false more often. JavaScript sees a local stream signal that began as remote receive pressure.

That is why backpressure-aware code helps even when 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 wrong code usually looks like this -

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

That loop treats a TCP socket like an infinite sink. 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 hurt itself by ignoring the local backpressure signal.

Congestion Makes Working Connections Slow

Flow control protects the receiver's buffers. Congestion control protects the network path between endpoints. It decides how aggressively the sender should put data into the network.

The kernel owns congestion control. 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, this 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 changes how painful this feels. With low RTT, the sender learns about delivered bytes quickly. With high RTT, every feedback loop takes longer. The same packet loss rate can feel much worse across a long path because acknowledgments and retransmission signals take longer to return.

The hard part is silence. TCP recovery is often quiet at the JavaScript layer. You get late data, delayed drain, or a request deadline from your own code. The socket can remain established the whole time.

When a log shows "socket connected" and then nothing for thirty seconds, think through these possibilities -

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

Those cases can look similar from Node. They need different evidence. Application logs show handler progress. Socket buffer and TCP counters show transport pressure. Packet captures show retransmissions and ACK behavior. The Node object alone cannot tell you which layer is currently holding up progress.

Orderly Shutdown Uses FIN

FIN is TCP's clean end-of-data signal for one direction of a connection. When an endpoint sends FIN, it says, "I am done writing." The peer can still send bytes in the other direction until it closes its own write side.

The common close path looks 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 remains 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, but the kernel is still protecting 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. It cannot tell you 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 shape 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 own those choices later in the book. At the TCP level, the reason is simple - fewer teardowns means fewer recently closed tuples waiting 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 tell very different stories. TIME-WAIT is normal cleanup after active close. CLOSE-WAIT means the peer ended its write side and your application still has a socket to close.

Node event order can make this visible -

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

An end without a later close in the expected time window deserves inspection. Maybe the protocol allows half-open behavior. Maybe the code forgot to close. Maybe a pending write is still flushing. Chapter 9.4 covers the API switches. The TCP state here explains why the symptom exists.

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 intentionally send a reset too. In Node v24, use the reset-specific API when that is the goal -

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

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 may fail. A later write may fail. The reset can arrive between JavaScript turns and surface on the next socket operation.

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. Read it as - 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 clue, then inspect event order. The socket may have emitted end before your write. Your own timeout may have called destroy(). The peer may have sent a protocol-level error and closed. An upstream proxy may have cut an idle connection. The TCP error names the failed operation. It does not tell the whole story by itself.

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 view, the connection was alive locally until the reset arrived. From the receiver's view, 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

"The server restarted" often hides this lower sequence. A new process listening on the same port can handle new connections. It does not automatically inherit old established TCP state. Ordinary restarts break existing connections.

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

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 clear. 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 log-reading table -

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. Treat 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 has the same shape. An AbortSignal tied to a client operation can destroy a socket that TCP would otherwise keep using. The remote side might report ECONNRESET while the local side records "user aborted." Both can be true at their own layer.

Good timeout logs name the layer -

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 time because they tell you who acted first. TCP, a Node socket timer, a protocol client, and caller cancellation can all tear down a connection. The useful log names the owner of the decision.

Timeout ownership 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.

Timeout metrics should be grouped by phase - connect, TLS later, request write, response headers, response body, and idle pool lifetime. This subchapter covers the TCP pieces, but the habit starts here. Name the phase, then close the socket state your code owns.

A Slow Reader Looks Different From a Broken Peer

Slow peers and broken peers can both make writes uncomfortable. The state tells you which one you have.

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 intentionally rough. It shows order. In real debugging, include endpoint fields too - localAddress, localPort, remoteAddress, remotePort, and the operation your code was doing when the event fired.

Timing changes the result because TCP state changes below JavaScript. A reset can arrive while your code is preparing the next write. A FIN can arrive after you queued data. A timeout can destroy a socket while a Promise chain still holds a reference. By the time your callback runs, the kernel state may have already 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 requires 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 bytes. The remote program decides what those bytes mean.

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

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

The parser owns message framing. TCP owns byte order and delivery attempts. Node streams move chunks between those layers.

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. It reports transport state.

That is why request-response protocols wait for responses. A database driver, HTTP client, or queue producer treats the protocol response as the meaningful acknowledgment. The TCP write callback is lower-level progress.

The Small Local Demos Lie in Useful Ways

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

The refused demo is clean -

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 does not. The local host rejected setup. A remote firewall that drops SYNs produces another timeline - no fast rejection, repeated SYN attempts, then a timeout path or your own deadline.

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 teaches 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. It simply stopped consuming. Pressure builds from the receiving application outward into Node 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.

Host TCP State Gives the Other Half

Node events tell you what reached JavaScript. Host TCP state tells you what the kernel is still tracking. 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 often more useful than single rows -

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

Many TIME-WAIT sockets after outbound load often means lots of short-lived connections. Many CLOSE-WAIT sockets often points at application code that received peer close and kept descriptors open. Many SYN-SENT entries can point at slow or filtered outbound connect attempts. Many 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 observed, kernel TCP state for what the host is tracking, and protocol logs for what the application thought it completed.

Failure Usually Belongs To a Side

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

Outbound connect failure -

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

Established read failure -

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

Established write failure -

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

Orderly peer close -

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 easy to hold in your hand mentally. TCP is not. Node gives you an object with methods and events. The kernel runs a state machine with timers, sequence numbers, buffers, windows, retransmission, and teardown states. Most of the time those two views line up well enough. Hard bugs live in the timing gaps.

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 queued bytes. libuv tracks write requests. The kernel send buffer tracks bytes that may need transmission, acknowledgment, or retransmission. TCP sequence state tracks which byte ranges are outstanding. The peer's receive window limits how far ahead the sender can get. Congestion control limits how much the sender should place into the network. One JavaScript write() can touch all of that without exposing the intermediate states.

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. The peer sees transport pressure, not a JavaScript event.

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 beside all of this. TCP has retransmission timers. Node sockets can have inactivity timers. Higher protocols can have request deadlines. A user can abort an operation. Any one of those can destroy the socket. The final error code reflects the layer that acted first or the OS result that Node observed. Two runs of the same code can report different symptoms if packet timing changes which side acts first.

"Connected" is a temporary fact. It means the connection reached established state at one point. After that, every read and write runs against current TCP state. A peer FIN, peer RST, local timeout, process exit, route loss, or retransmission failure can move the kernel state while JavaScript still holds 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 for "some lower socket state changed before this operation finished."

Keeping Protocols Above TCP Honest

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

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

Backend code needs a simple 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 API level into node:net. The method names get friendlier, but the socket still belongs to TCP before it belongs to JavaScript.