NodeJSTCP Servers (net Module)

TCP Servers (net Module)

The net module is Node's API for TCP — the reliable, ordered, connection-oriented transport that HTTP, databases, and most application protocols ride on. With it you create TCP servers and clients, and each connection is a socket: a duplex stream of bytes. Master net and you understand the layer beneath every web server you'll ever write.

A TCP server

JS
import { createServer } from 'node:net'

const server = createServer((socket) => {
  // Called once per client connection. `socket` is a Duplex stream.
  console.log('client connected:', socket.remoteAddress, socket.remotePort)

  socket.write('Hello from the server\n')

  socket.on('data', (chunk) => console.log('received:', chunk.toString()))
  socket.on('end',  () => console.log('client disconnected'))
  socket.on('error', (err) => console.error('socket error:', err.message))
})

server.listen(4000, () => console.log('listening on :4000'))
The connection callback fires per client; the socket is a stream
`createServer(fn)` calls `fn(socket)` each time a client connects. That `socket` is a full duplex stream — read incoming bytes via `data`/`for await`, send bytes via `write()`, and it obeys backpressure like any stream. The server itself is an [EventEmitter](/nodejs/event-emitter) emitting `connection`, `listening`, `error`, and `close`.
A TCP client

JS
import { connect } from 'node:net'

const socket = connect({ port: 4000, host: '127.0.0.1' }, () => {
  console.log('connected to server')
  socket.write('ping')
})

socket.on('data', (chunk) => {
  console.log('server says:', chunk.toString())
  socket.end()                 // close our side after one reply
})
socket.on('error', (err) => console.error(err.message))
The crucial gotcha: TCP has no message boundaries
One `write` does NOT equal one `data` event
TCP is a **byte stream**, not a message stream. If the client sends `write('AAA')` then `write('BBB')`, the server might receive one `data` event with `'AAABBB'` (merged), or `'AA'` then `'ABBB'` (split), or any other grouping. TCP guarantees only that bytes arrive **in order**, never how they're chunked. Code that assumes `data` = one message works on localhost and shatters under real network conditions.
The fix: message framing

To recover discrete messages you must frame them. The two standard approaches are a delimiter (e.g. newline-terminated) or a length prefix (send the byte count first). Here's newline-delimited framing — buffer across data events and split on the delimiter:

Newline-delimited message framing

JS
import { createServer } from 'node:net'

createServer((socket) => {
  let buffer = ''
  socket.on('data', (chunk) => {
    buffer += chunk.toString()
    let index
    // Pull out every COMPLETE line; keep the trailing partial in buffer
    while ((index = buffer.indexOf('\n')) !== -1) {
      const message = buffer.slice(0, index)
      buffer = buffer.slice(index + 1)
      handleMessage(socket, message)
    }
  })
}).listen(4000)

function handleMessage(socket, message) {
  socket.write(`echo: ${message}\n`)
}
Length-prefix framing for binary protocols
Delimiters are simple but break if the delimiter byte can appear *inside* a message (common with binary data). The robust alternative is a **length prefix**: write a fixed-size header (e.g. a 4-byte big-endian integer) giving the payload length, then the payload. The reader accumulates bytes until it has the full declared length. Most binary protocols (and many databases) use this scheme.
Sockets are streams — use them as such

JS
import { createServer } from 'node:net'

// Echo server via piping (socket is duplex: pipe readable→writable side):
createServer((socket) => socket.pipe(socket)).listen(4000)

// Or consume with async iteration + backpressure:
createServer(async (socket) => {
  for await (const chunk of socket) {
    socket.write(chunk)        // respects backpressure naturally
  }
}).listen(4001)
Useful socket properties and methods

Member

Purpose

socket.remoteAddress / remotePort

Who connected

socket.write(data)

Send bytes (returns false under backpressure)

socket.end([data])

Send optional final data, then close our side

socket.destroy()

Abruptly tear down (on error/abuse)

socket.setTimeout(ms)

Emit timeout after inactivity

socket.setKeepAlive(true)

Detect dead peers on idle connections

socket.setNoDelay(true)

Disable Nagle — send small packets immediately

Nagle's algorithm and `setNoDelay`
By default TCP uses **Nagle's algorithm**, which buffers tiny writes to combine them into fewer packets — efficient for bulk transfer but adds latency for chatty, small-message protocols (like a game or REPL). `socket.setNoDelay(true)` disables it so each `write` goes out immediately. Trade throughput for latency only when your protocol's interactivity needs it.
Handling errors and cleanup
Always handle the socket's `error` event
An unhandled `error` on a socket (e.g. the client vanishes — `ECONNRESET`) throws and can crash the process. Attach an `error` listener to **every** socket. Network errors are normal, not exceptional — peers disconnect, networks drop. Treat `ECONNRESET`, `EPIPE`, and timeouts as routine events to log and clean up after, not crashes.
Server lifecycle

JS
const server = createServer(/* ... */)

server.listen(4000)
server.on('error', (err) => {
  if (err.code === 'EADDRINUSE') console.error('port 4000 already in use')
})

// Graceful shutdown: stop accepting, let existing connections finish
process.on('SIGINT', () => server.close(() => process.exit(0)))
`server.close()` is graceful, not immediate
`close()` stops the server from accepting **new** connections but lets existing ones finish — the callback fires only once they've all closed. To force-close lingering connections during shutdown you must track and `destroy()` them yourself (or use a helper). `EADDRINUSE` means another process already holds the port.
Next
The connectionless counterpart — fast, lossy datagrams: [UDP Sockets (dgram Module)](/nodejs/dgram-module).