Skip to content

5.9 — Socket Programming: How Code Actually Speaks to a Network

Every protocol in this Part — HTTP, DNS, TLS, WebSocket — is built on one small set of operating system calls. Six of them, essentially unchanged since Berkeley Unix in 1983.

This chapter is those calls: what a socket is in the operating system, how a program takes an IP address and a port and turns them into a stream of bytes, and why the same six functions look completely different in Node than they do in C while doing exactly the same thing underneath.

1. A socket is a file descriptor

Chapter 2.8 covered the Unix idea that everything is a file. A socket is that idea applied to the network.

When you open a socket, the kernel returns a file descriptor — a small integer, an index into the process's table of open things. Descriptors 0, 1 and 2 are standard input, output and error; your socket might be 3.

c
int fd = socket(AF_INET, SOCK_STREAM, 0);    // returns 3
write(fd, "hello", 5);                        // works exactly like writing to a file
read(fd, buffer, 1024);
close(fd);

read and write are the same calls used for files. That is not a coincidence or an analogy — it is the design. A program that copies bytes from one descriptor to another does not need to know whether either end is a file, a pipe, a terminal or a TCP connection. This is why Unix pipelines can send a file across a network with no special-purpose code, and it is one of the most productive abstractions in computing.

The three arguments to socket() say what kind:

  • AF_INET — the address family. IPv4. AF_INET6 for IPv6, AF_UNIX for local sockets that never touch a network.
  • SOCK_STREAM — a reliable ordered byte stream, meaning TCP. SOCK_DGRAM means datagrams, meaning UDP (Chapter 5.4.1).
  • 0 — let the kernel pick the obvious protocol for that combination.

On Linux you can see this directly, which makes it concrete rather than theoretical:

sh
ls -l /proc/$(pgrep -n node)/fd/
# lrwx------ 1 user user 64 Aug  2 10:14 3 -> 'socket:[482913]'

Every open connection your server holds is one entry in that directory. Which is why ulimit -n, the per-process file descriptor limit, is a real production ceiling — a server holding 60,000 connections needs a limit above 60,000, and the default is often 1,024. "Too many open files" under load is this, and it is one of the most common first outages of a newly popular service.

2. The six calls, and the asymmetry between the two sides

The client needs two calls. The server needs four. That asymmetry is the shape of the whole API.

serverclientsocket()bind(addr, port)listen(backlog)accept() — blockssocket()connect(addr, port)three-way handshakeread() / write()write() / read()close()close()accept() returns a NEW descriptor
The classic sequence. The one point people miss is at the bottom of the server column: accept() does not return the listening socket, it returns a brand-new descriptor for this one connection. The listening socket stays listening.

socket() — create the descriptor. It has no address yet.

bind(address, port) — claim a local address and port. This is where 0.0.0.0 versus 127.0.0.1 from Chapter 5.3.1 bites, and it is worth being exact:

  • bind("127.0.0.1", 3000) — only reachable from this machine. In a container, that means nothing outside the container can connect, which is the single most common "the app is running but nothing can reach it" bug in Docker.
  • bind("0.0.0.0", 3000) — every IPv4 interface.
  • bind("::", 3000) — every IPv6 interface, and on most systems every IPv4 one too via dual-stack.
  • Port 0 means "kernel, pick a free one", which is how a test server gets a port that cannot clash with anything.

listen(backlog) — mark the socket as accepting connections, and size the queue. The backlog is not the number of concurrent connections; it is how many completed handshakes may wait for your program to call accept(). Overflow that queue and new connections are refused or silently dropped, which appears to the client as a hang or a connection reset. On Linux the kernel keeps two queues here — one for half-open handshakes (the SYN queue from Chapter 5.4.2's SYN flood discussion) and one for completed ones.

accept() — take the next completed connection off the queue and return a new descriptor for it. The listening descriptor remains a listening descriptor. This is the point beginners misread: a server with 10,000 clients holds 10,001 descriptors, one listener plus one per connection.

connect(address, port) — the client side. This is what triggers the three-way handshake, and by default it blocks until the handshake completes — or until it times out, which on an unreachable host can take a minute or more.

close() — release the descriptor and begin the four-way teardown from Chapter 5.4.2, with all the TIME_WAIT consequences.

3. Byte order, and why every address goes through a conversion function

A port number is 16 bits. Chapter 1.3 covered endianness: an x86 CPU stores the two bytes of 443 as BB 01 (little-endian, low byte first) and a big-endian machine stores them as 01 BB.

If a little-endian client sends its port number as raw memory and a big-endian server reads it as raw memory, the server sees 48,129 instead of 443.

The internet fixed this by decree, in 1980: network byte order is big-endian. Every multi-byte field in every header — ports, sequence numbers, IP addresses, lengths — is big-endian on the wire, regardless of what the machines at either end use internally.

Hence the conversion functions, which exist in every language:

c
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port   = htons(443);                      // (1)  host TO network, short
inet_pton(AF_INET, "142.250.187.238", &addr.sin_addr);  // (2)
  1. htons — host to network, short (16 bits). Its siblings are htonl (long, 32 bits), ntohs and ntohl for the reverse. On a big-endian machine these compile to nothing; on x86 they swap the bytes. Forgetting one produces a program that works between two machines of the same architecture and fails against anything else — a bug that hides for years.
  2. inet_pton — presentation to network: parse the human string "142.250.187.238" into the 32-bit big-endian number. inet_ntop goes the other way. The p and n are the two representations from Chapter 5.3.1: the dotted string humans read, and the single integer the protocol carries.

In JavaScript you meet this whenever you touch a binary protocol:

ts
const buf = Buffer.alloc(4);
buf.writeUInt16BE(443, 0);        // ← BE: big-endian, network byte order
buf.writeUInt16LE(443, 2);        // ← LE: wrong for the network, right for some file formats

Every framing header you write, including the length prefix from Chapter 5.4.1, must be big-endian, or your protocol only works between machines that happen to agree.

4. Blocking, and the three ways out

By default, socket calls block: the thread stops until the operation can proceed. accept() blocks until a client arrives. read() blocks until bytes are available. connect() blocks through the handshake.

That is easy to reason about and it does not scale, because one blocked thread serves one client. Chapter 2.7 built this argument in full; here is the summary and where each answer is used.

Thread per connection. Accept, hand the descriptor to a thread, repeat. Simple and genuinely fine up to hundreds of connections. At ten thousand you have ten thousand threads, each with a stack of one or two megabytes — twenty gigabytes of stacks — and the scheduler spends more time switching between them than working (Chapter 2.3).

Non-blocking plus an event loop. Mark descriptors non-blocking, so a read with nothing available returns EAGAIN immediately instead of waiting. Then ask the kernel which of my descriptors are ready and only touch those.

c
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);      // (1)
ssize_t n = read(fd, buf, sizeof buf);
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
  // (2) not an error — nothing to read right now
}
  1. One flag changes the behaviour of every subsequent call on this descriptor.
  2. EAGAIN is the normal case, not a failure. Treating it as an error is the classic first mistake; it means "come back later", and the event loop is what tells you when later has arrived.

The readiness call itself evolved, and the progression is the whole story of scalable servers:

  • select — pass a bitmap of descriptors, get back which are ready. Capped at 1,024 descriptors, and O(n) per call because the kernel scans the entire set every time.
  • poll — no fixed limit, still O(n).
  • epoll (Linux) / kqueue (BSD and macOS) — register descriptors once, then ask for ready events in O(1). This is what made 10,000 concurrent connections on one machine ordinary.
  • io_uring (modern Linux) — shared ring buffers between kernel and user space so the actual I/O is submitted and completed without a system call per operation.

This progression is exactly what Node's event loop sits on. libuv uses epoll on Linux, kqueue on macOS and IOCP on Windows, and hides the difference (Chapter 3.8.2). When you write socket.on('data', …), you are registering interest with epoll and letting the loop dispatch.

Asynchronous I/O. Rather than "tell me when it is ready", say "do this and tell me when it is done". io_uring and Windows IOCP are true completion models, and they remove the read-after-ready step entirely.

5. The same program, four ways

A TCP server in Node

ts
import net from 'node:net';

const server = net.createServer((socket) => {                 // (1)
  console.log('client', socket.remoteAddress, socket.remotePort);  // (2)

  let buffer = Buffer.alloc(0);
  socket.on('data', (chunk) => {                              // (3)
    buffer = Buffer.concat([buffer, chunk]);
    while (buffer.length >= 4) {                              // (4)
      const length = buffer.readUInt32BE(0);
      if (buffer.length < 4 + length) break;
      handle(buffer.subarray(4, 4 + length));
      buffer = buffer.subarray(4 + length);
    }
  });

  socket.on('error', (err) => console.error('socket', err));  // (5)
  socket.on('close', () => cleanup(socket));                  // (6)
});

server.listen(3000, '0.0.0.0', () => console.log('listening'));  // (7)
  1. This callback is accept(). Every invocation is one completed connection, and socket wraps the new descriptor.
  2. remoteAddress and remotePort are two of the four-tuple values from Chapter 5.4.1.
  3. The data event fires whenever bytes arrive — not once per message the peer sent.
  4. The framing loop from Chapter 5.4.1, and it is mandatory. TCP is a byte stream; without this you get intermittent parse failures under load. readUInt32BE because network byte order is big-endian.
  5. An unhandled error event on a socket crashes a Node process. Connections reset by peers are routine, so this handler is not optional.
  6. Release whatever you associated with this connection, or you leak per-connection state.
  7. listen performs bind and listen together. Passing '0.0.0.0' explicitly is worth doing in a container, since the default varies and localhost is unreachable from outside.

A UDP socket

ts
import dgram from 'node:dgram';

const sock = dgram.createSocket('udp4');

sock.on('message', (msg, rinfo) => {                          // (1)
  console.log(`${msg.length} bytes from ${rinfo.address}:${rinfo.port}`);
  sock.send(Buffer.from('pong'), rinfo.port, rinfo.address);  // (2)
});

sock.bind(9000);                                              // (3)
  1. One message event is exactly one datagram — no framing needed, because UDP preserves message boundaries (Chapter 5.4.1). This is the clearest practical difference between the two protocols.
  2. There is no connection, so every reply must name the destination explicitly. rinfo carries it.
  3. No listen, no accept — UDP has no connections to accept.

The datagram size limit is the thing to know. A UDP payload over about 1,472 bytes exceeds the typical Ethernet MTU (Chapter 5.2) and gets fragmented at the IP layer, where losing any one fragment loses the whole datagram. Keep application datagrams under ~1,400 bytes, which is exactly why DNS responses over that size fall back to TCP (Chapter 5.5).

Client-side, and the part people get wrong

ts
const socket = net.createConnection({ host: 'example.com', port: 443 }, () => {
  socket.write(request);
});
socket.setTimeout(5000, () => socket.destroy());              // (1)
socket.setNoDelay(true);                                       // (2)
  1. Set a timeout, always. Without one, a connection to a host that accepts and then never replies hangs until the operating system's TCP keepalive notices — which can be two hours by default. This is the mechanism behind "the request never returned and never errored".
  2. Disable Nagle (Chapter 5.4.2), so a small request is not held for up to 200 ms waiting for more data. Node sets this by default on TCP sockets; other runtimes do not.

Resolving a name properly

ts
import dns from 'node:dns/promises';

const results = await dns.lookup('example.com', { all: true, family: 0 });  // (1)
// [{ address: '93.184.216.34', family: 4 }, { address: '2606:2800:...', family: 6 }]
  1. family: 0 means "whatever the system has". This is the address-family-agnostic pattern from Chapter 5.3.3: resolve the name, take what you get, connect to it. Code that assumes four dot-separated octets breaks the first time it meets IPv6. Never parse an address with a regex; ask the resolver.

Two operational notes. dns.lookup calls the system resolver (getaddrinfo), which is blocking, so libuv runs it on the thread pool — and a burst of lookups can saturate that pool and stall unrelated file I/O (Chapter 3.8.2). dns.resolve speaks DNS directly over the network and does not touch the pool, but it also ignores /etc/hosts. Knowing which one you are using explains a class of mysterious latency.

6. The options that matter, and what they actually do

ts
socket.setKeepAlive(true, 60000);      // (1)
server.maxConnections = 10000;         // (2)
socket.setNoDelay(true);               // (3)
  1. TCP keepalive sends a probe on an idle connection, so a peer that vanished — a laptop that closed, a NAT box that dropped the mapping — is detected instead of leaving a dead socket forever. The operating system defaults are far too long (2 hours on Linux) for anything interactive. Note this is TCP keepalive, a different mechanism from HTTP's Connection: keep-alive, which is about reusing a connection for another request.
  2. A ceiling before the process exhausts descriptors or memory.
  3. Disable Nagle, as above.

SO_REUSEADDR deserves its own note because it is the reason "address already in use" appears after a restart. Without it, a listening socket cannot bind while old connections from the previous process sit in TIME_WAIT on that port (Chapter 5.4.2). Node sets it by default; in other languages you set it explicitly and it should be standard on any server.

SO_REUSEPORT is different and more powerful: several processes may bind the same port, and the kernel load-balances incoming connections between them. This is how a multi-process server can accept in parallel without a single accepting thread becoming the bottleneck, and it is the mechanism behind Node's cluster module on Linux (Chapter 3.8.6).

7. Where the abstraction ends

net.createServer sits on accept(). http.createServer sits on net. fetch sits on http. Each layer hides the one below, and each hides a failure mode that eventually surfaces.

What the layers hide, in the order you will meet them:

  • fetch hides connection reuse, so you will not notice you are opening a new TCP connection per request until ephemeral ports run out (Chapter 5.4.1). The fix is an agent with a connection pool.
  • http hides framing, so you will not think about Content-Length until a proxy disagrees with your server about where a message ends — which is HTTP request smuggling (Chapter 8.5).
  • net hides the descriptor, so you will not think about ulimit -n until 1,024 connections.
  • All of them hide backpressure. socket.write() returns false when the kernel buffer is full, and ignoring that return value is how a fast producer and a slow consumer produce an out-of-memory crash. Use pipe() or honour the return value and wait for drain (Chapter 3.8.4). This is the receive window from Chapter 5.4.3 surfacing three layers up, and it is the single most valuable thing to carry from this page into ordinary application code.

What the interviewer will push on

"What does accept() return?" A new file descriptor for that one connection; the listening socket keeps listening. A server with 10,000 clients holds 10,001 descriptors, which is why ulimit -n is a production ceiling.

"Your server runs in Docker but nothing can reach it. What is wrong?" Almost certainly bound to 127.0.0.1 instead of 0.0.0.0, so it only accepts connections originating inside the container. This is asked because it is the most common containerisation bug there is.

"Why does socket code call htons on a port number?" Network byte order is big-endian by definition, and most CPUs are little-endian. Skip it and the code works between identical machines and fails against anything else.

"Explain select versus epoll." select passes the whole descriptor set on every call and the kernel scans it, so it is O(n) and capped at 1,024. epoll registers once and returns only ready events in O(1). That difference is what made 10,000 concurrent connections routine, and it is what Node's event loop runs on.

"What is EAGAIN and is it an error?" No — on a non-blocking socket it means nothing is available right now. Treating it as a failure is the classic first mistake; the event loop exists to tell you when to try again.

"Your service intermittently fails to parse JSON from a TCP peer. Why?" No framing. TCP is a byte stream, so writes coalesce and split. Length-prefix the messages and buffer across data events, with a while loop because one chunk may hold several messages.

"What happens if you ignore the return value of socket.write()?" It returns false when the kernel buffer is full. Ignoring it means unbounded memory growth on a slow consumer, and eventually an out-of-memory crash. This is TCP's receive window appearing as application-level backpressure.

One thing to volunteer: point out that a socket is a file descriptor and that read/write are the same calls used for files — then say what that buys: any code that copies between descriptors works across files, pipes, terminals and network connections without knowing which it has. It is the reason the API has survived forty years essentially unchanged.

Recall

  • A socket is a file descriptor; read and write are the same calls used for files, which is why one piece of code works across files, pipes and connections. ulimit -n is therefore a real connection ceiling.
  • Six calls: socket · bind · listen(backlog) · accept on the server, socket · connect on the client. accept() returns a NEW descriptor; the listener keeps listening.
  • bind("127.0.0.1") is unreachable from outside the machine or container — the most common "running but unreachable" bug. bind("0.0.0.0") is every interface; port 0 means "kernel, choose one".
  • Network byte order is big-endian by decree, hence htons/htonl and writeUInt32BE. Getting it wrong works between identical machines and fails everywhere else.
  • Blocking gives one thread per client; non-blocking plus readiness scales. select is O(n) and capped at 1,024, epoll/kqueue is O(1) after registration, io_uring removes the per-operation system call. EAGAIN is normal, not an error.
  • TCP needs framing (buffer plus a while loop); UDP does not, because one message event is exactly one datagram — keep datagrams under ~1,400 bytes to avoid IP fragmentation.
  • Always set a socket timeout, or a silent peer hangs you until TCP keepalive notices — up to two hours. SO_REUSEADDR prevents "address already in use" after a restart; SO_REUSEPORT lets several processes share a port with kernel load balancing.
  • socket.write() returning false is backpressure — ignoring it is how a slow consumer causes an out-of-memory crash.

Self-test: How many descriptors does a server with 10,000 clients hold, and why? · Why does binding to 127.0.0.1 break a containerised service? · Why does htons exist? · Give the complexity difference between select and epoll and why it mattered · Why does UDP need no framing? · What does socket.write() returning false mean, and what happens if you ignore it?

Next: 5.10 assembles the whole Part into the thing you will actually configure — a cloud network, where every AWS and Azure concept turns out to be one of the primitives from 5.1 to 5.9 with a management console attached.