TCP and the net module: sockets, lifecycle, and backpressure
The net module gives you the raw TCP stream under http: a duplex socket with data/end/error/close, half-open semantics, Nagle batching you can disable with setNoDelay, and a write() boolean that is your socket-level backpressure signal.
A teammate ships a tiny TCP proxy. It works on localhost, passes review, and then under real traffic it leaks memory until the pod OOMs. The cause is two lines of missing code: they piped client bytes into an upstream socket but never watched write()’s return value, and they never handled the 'error' event on the upstream. A slow upstream made write() return false on every call; the unread bytes piled into an unbounded internal buffer. net handed them the truth on every write and they ignored it.
The socket is a duplex stream, not a mailbox
When you skip even one of the four lifecycle events on a socket, you expose yourself to the exact class of bug in the hook — whether it’s a memory leak, a silent crash, or a zombie fd that never closes. Here is the full picture.
Everything http does sits on top of net. A net.Socket is a duplex stream: a Readable for the bytes arriving from the peer and a Writable for the bytes you send. net.createServer((socket) => { ... }) hands you one socket per accepted connection; net.connect(port, host) gives you the client end of one. There is no message framing — TCP is a byte stream, so one logical “message” can arrive split across several 'data' events, or two of your sends can coalesce into one 'data' on the peer. Any protocol you build on net must do its own framing (length prefix, delimiter, or a parser).
The lifecycle is a fixed sequence of events, and you must wire all of them:
const net = require("node:net");
const server = net.createServer((socket) => {
socket.setEncoding("utf8"); // or leave as Buffers
socket.on("data", (chunk) => { /* parse — may be partial */ });
socket.on("end", () => { /* peer sent FIN, no more reads */ });
socket.on("error", (err) => { /* ECONNRESET etc — MUST handle */ });
socket.on("close", (hadError) => { /* fully torn down */ });
socket.write("hello\n");
});
server.listen(7000);The non-negotiable rule: an unhandled 'error' on a socket throws and crashes the process. TCP errors like ECONNRESET are not exceptional — a client closing a laptop lid produces them routinely — so every socket needs an 'error' listener or the first reset takes down the whole server.
end vs close, and the half-open trap
'end' and 'close' are not the same event, and confusing them is a classic bug.
| Event | Meaning | Can you still write? |
|---|---|---|
‘end’ | Peer sent FIN — the readable side is done | Yes — your writable half is still open |
’finish’ | You called end() — your writable side flushed | No — you closed your half |
’close’ | Both halves down, socket fully released | No — fd is gone |
By default Node auto-closes the writable side when it receives a FIN, so a socket lifetime is symmetric. But TCP genuinely supports half-open connections: the peer can stop sending (end) while you keep sending. Pass allowHalfOpen: true to createServer/connect and Node will not auto-end() your side on the peer’s FIN — useful for request/response protocols where the client signals “done sending” with a FIN but still waits for your full reply. The trap is the inverse: with allowHalfOpen: true you are now responsible for calling socket.end() yourself, or the half-open connection lingers and leaks a file descriptor.
▸Why this works
A FIN closes one direction. TCP is two independent simplex streams glued together, so “connection closed” is really “one half closed.” Most servers want symmetric close (the default), but a proxy or a streaming protocol often wants to read to EOF and then still flush a trailer — that is what allowHalfOpen buys you, at the cost of owning the close yourself.
Nagle, setNoDelay, and the 40 ms mystery
By default TCP runs Nagle’s algorithm: it withholds a small outbound segment until either the previous segment is ACKed or enough data accumulates to fill a packet, to avoid flooding the network with tiny packets. Combined with the peer’s delayed ACK (which waits up to ~40 ms before acknowledging), Nagle produces a notorious failure mode: a request/response protocol that sends small writes stalls for ~40 ms per round trip, because your side won’t send the small segment until the peer ACKs, and the peer won’t ACK until its delayed-ACK timer fires.
The fix is socket.setNoDelay(true), which disables Nagle so each write() goes out immediately. For interactive, latency-sensitive protocols (RPC, a REPL, a game) this is the right default. For bulk throughput where you do many small writes, leaving Nagle on can be better — it coalesces. Know which one you are: low latency on small messages → setNoDelay(true); raw throughput → leave it. socket.setKeepAlive(true, delay) is a separate dial: it makes the OS send periodic keepalive probes on an idle connection so a dead peer (crashed, network partition) is detected instead of the socket hanging open forever.
write() returns a boolean — that is your backpressure
socket.write(chunk) returns a boolean, and it is the single most ignored value in Node networking. true means the chunk went into the kernel send buffer and you may keep writing. false means Node’s internal buffer is now above the high-water mark — the peer (or the network) is not draining as fast as you are writing — and you should stop writing until the 'drain' event fires. Ignoring false and writing anyway does not error; Node keeps buffering in memory, and against a slow consumer that buffer grows without bound until the process OOMs. That is the hook’s bug.
// Manual backpressure: stop on false, resume on 'drain'.
function sendAll(socket, chunks, done) {
let i = 0;
(function next() {
while (i < chunks.length) {
const ok = socket.write(chunks[i++]);
if (!ok) return socket.once("drain", next); // wait, then continue
}
done();
})();
}In practice you rarely write this loop: pipeline(source, socket) (or source.pipe(socket)) wires backpressure for you, pausing the source when write() returns false and resuming on 'drain'. The lesson is to either let a stream pipeline manage it or honor the boolean by hand — never fire-and-forget into a socket you don’t control the drain of.
You proxy a fast client's bytes into a slower upstream socket. How do you move the data?
ref and unref: should this socket keep the process alive?
Node’s event loop stays alive as long as there is an active handle — and an open socket is one. socket.unref() tells the loop “do not count me when deciding whether to exit”; socket.ref() reverses it. The use case is a background or optional connection: a metrics socket, a health-check probe, or a debug listener that should not, by itself, keep a CLI process running after its real work is done. server.unref() does the same for a listening server. The default is ref’d — a normal server should keep the process up — so reach for unref() only on the auxiliary connections that must not pin the process open.
Your TCP server crashes intermittently in production with an uncaught ECONNRESET. What is the fix?
A small request/response protocol over net adds ~40 ms of latency per round trip on otherwise idle links. Most likely cause?
- 01Why does write() return a boolean, and what must you do when it is false?
- 02What is a half-open TCP connection, and what does allowHalfOpen change?
The net module is the raw TCP layer under http: a net.Socket is a duplex stream with no message framing, so you parse 'data' chunks that may be partial and you build your own framing. Wire the full lifecycle — 'data', 'end', 'error', 'close' — and never skip 'error', because an unhandled socket error throws and crashes the process, and resets like ECONNRESET are routine. 'end' means the peer’s FIN arrived (read side done) while your writable half may still be open; allowHalfOpen keeps it open at the price of owning the end() yourself. Tune the transport deliberately: setNoDelay(true) disables Nagle for low-latency small writes, setKeepAlive lets the OS detect dead peers. Above all, write() returns a boolean — false is backpressure, the signal to stop until 'drain' — and ignoring it is the canonical unbounded-buffer OOM. Let pipeline() manage backpressure when you can, and use unref() only on auxiliary sockets that must not pin the process alive. Now when you see a process OOM on a proxy or a server that “just hangs” under load — look first at whether write()’s return value is being ignored and whether every socket has an 'error' handler.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.