Networking in C · advanced · ~15 min
- Create, configure, bind, and listen on a TCP socket using the five-call server sequence - Explain what `bind()`, `listen()`, and `accept()` each do and why they must happen in that order - Understand the listen backlog and how the kernel queues pending connections - Use `SO_REUSEADDR` to avoid the "Address already in use" restart problem - Read a request from an accepted client and send a reply, then clean up the connection safely - Bind to loopback so a practice server can never leak onto the wider network
A TCP server is a program that waits for other programs to connect to it, then talks to each one over a reliable, ordered byte stream. Web servers, database engines, SSH daemons, game backends, and message brokers are all TCP servers underneath. Where the TCP client lesson showed how to reach out and connect to a listening program, this lesson shows the other side of the handshake: how to be the program that clients connect to.
The mental shift is important. A client does one active thing — it calls connect() and drives the conversation. A server is passive and patient: it opens a socket, claims a port on the machine, announces "I am open for business," and then blocks, waiting for clients to arrive. When one does, the server gets a brand-new socket dedicated to that single client, while the original socket keeps listening for the next arrival.
In plain language: the server socket is like a shop's front door with a receptionist. The receptionist (the listening socket) never leaves the door. Each time a customer walks in, the receptionist hands them off to a fresh conversation (the connected socket from accept()) and immediately turns back to watch the door for the next customer. Learning to keep those two sockets straight — the one that listens and the one that talks — is the single most important idea in this lesson.
Almost every networked service you use is, at its core, a TCP server accepting connections in a loop. Understanding this sequence lets you build your own services, reason about why a server "won't start" (usually a port conflict), and understand the security surface every listening program exposes.
It also builds real intuition for the tools you already use. When you see a web server "listening on port 8080," you now know that means bind() succeeded on port 8080 and listen() marked the socket passive. When a deploy fails with EADDRINUSE, you know exactly which call returned it and why. And because a server accepts input from anywhere, it is the classic place where input-validation bugs turn into real vulnerabilities — so learning the safe pattern early pays off for the rest of your career.
The biggest source of confusion for beginners is that a TCP server juggles two different kinds of socket. The listening socket is the one you bind() and listen() on; it never sends or receives application data. Its only job is to sit in the accept queue and produce new sockets. Each successful accept() returns a connected socket: a private, full-duplex channel to exactly one client. You read and write the client on that new descriptor, and you close it when that client is done — the listening socket stays open for the next client.
listening socket (fd 3) connected sockets (one per client)
+------------------+
| bind + listen | accept() -> fd 4 <----> client A
| port 8080 | accept() -> fd 5 <----> client B
| (never carries | accept() -> fd 6 <----> client C
| app data) |
+------------------+
When to use / not use: never write client data to the listening socket — it will fail. Never close the listening socket while you still want more clients.
Pitfall: calling read()/write() on the listening descriptor instead of the one accept() returned. Give them clearly different names (listen_fd vs conn_fd) so you cannot mix them up.
Knowledge check: A server calls
accept()three times and gets descriptors 4, 5, and 6. It then doesclose(3)(the listening socket). Can it still talk to the three clients? Can it accept a fourth?
bind() — claiming an address and portbind() associates your socket with a local IP address and port number, filled into a struct sockaddr_in. The port is the "phone extension" clients dial; the IP address decides which network interfaces the server answers on.
htonl(INADDR_LOOPBACK) → 127.0.0.1, reachable only from the same machine. Safe for practice.htonl(INADDR_ANY) → 0.0.0.0, reachable from every interface, including the outside network.Remember the byte-order functions from the TCP client lesson: htons() for the 16-bit port, htonl() for the 32-bit address. Networks are big-endian; your machine may not be.
Pitfall: forgetting to zero the struct sockaddr_in (= {0}). Leftover garbage in padding or sin_addr produces confusing bind failures.
listen() and the backloglisten(fd, backlog) flips the socket from active (able to connect()) to passive (able to accept()). The backlog is the maximum number of fully established connections that may sit waiting in the kernel's accept queue before your code calls accept() on them. If the queue is full, new clients are refused or delayed.
clients connecting kernel accept queue (backlog = 3)
o o o o o ---> [ ready ][ ready ][ ready ] --> accept() pops one
queue full -> extra clients wait / are refused
| Backlog value | Effect |
|---|---|
| Too small (e.g. 1) | Bursts of clients get refused under load |
| Reasonable (e.g. 16–128) | Absorbs short bursts while you loop on accept() |
| Huge (e.g. 100000) | Silently capped by the OS to a system maximum |
When NOT to obsess: for a learning server, any value from 8 to 128 is fine. The backlog is a buffer, not a client limit — total clients over a program's life is unbounded.
Knowledge check: Explain in your own words why
listen()must come afterbind()but beforeaccept().
accept() — picking up a waiting clientaccept() removes one established connection from the queue and returns a new descriptor for it. By default it blocks (the program pauses) until a client arrives. You may optionally pass a struct sockaddr and length to learn the client's address; passing NULL, NULL ignores it.
Pitfall: treating accept()'s return value as the same socket you listened on. It is a new file descriptor. Losing track of it leaks the connection.
SO_REUSEADDR — restarting without waitingAfter a server closes, its port can linger in the kernel's TIME_WAIT state for a minute or two. Rebinding during that window fails with EADDRINUSE ("Address already in use"). Setting SO_REUSEADDR before bind() lets you rebind immediately — invaluable while developing, since you restart constantly.
| Call | Purpose | Blocks? | Returns |
|---|---|---|---|
socket() |
Create the endpoint | No | A file descriptor |
setsockopt(SO_REUSEADDR) |
Allow fast port reuse | No | 0 / -1 |
bind() |
Claim IP + port | No | 0 / -1 |
listen() |
Become passive, set backlog | No | 0 / -1 |
accept() |
Pick up next client | Yes (default) | A new file descriptor |
Knowledge check (find-the-bug): A student calls
setsockopt(s, SOL_SOCKET, SO_REUSEADDR, ...)afterbind()and still getsEADDRINUSEon restart. Why did it not help?
The server sequence, annotated:
int listen_fd = socket(AF_INET, SOCK_STREAM, 0); // AF_INET=IPv4, SOCK_STREAM=TCP
int yes = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, // must be set BEFORE bind()
&yes, sizeof yes);
struct sockaddr_in addr = {0}; // zero every field first
addr.sin_family = AF_INET; // address family = IPv4
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); // 127.0.0.1, local only
addr.sin_port = htons(8080); // port, host->network order
bind(listen_fd, (struct sockaddr *)&addr, sizeof addr);
listen(listen_fd, 16); // 16 = backlog depth
int conn_fd = accept(listen_fd, NULL, NULL); // NEW socket for this client
Key headers: <sys/socket.h>, <netinet/in.h>, <arpa/inet.h>, <unistd.h>. Every one of these calls returns -1 on failure and sets errno; the compact snippet above omits the checks that the full example below includes.
Setting up a TCP server follows a fixed sequence of system calls:
socket() — create the socket.setsockopt(SO_REUSEADDR) — optional, but lets you reuse the port quickly after the program restarts.bind() — attach the socket to a local address and port.listen() — mark the socket as passive (ready to accept connections) and set the backlog, the limit on how many pending connections may queue up.accept() — pick up the next waiting client and return a new socket for talking to it.For local exercises, bind to INADDR_LOOPBACK.
This keeps the server reachable only from your own machine, so you can never accidentally expose it to the outside network.
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define PORT 8080
#define BACKLOG 16
int main(void) {
/* 1. Create the listening socket. */
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) { perror("socket"); return 1; }
/* 2. Allow immediate reuse of the port after a restart. */
int yes = 1;
if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes) < 0) {
perror("setsockopt");
close(listen_fd);
return 1;
}
/* 3. Describe the local address: loopback only, so this stays private. */
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); /* 127.0.0.1 */
addr.sin_port = htons(PORT);
/* 4. Claim the address and port. */
if (bind(listen_fd, (struct sockaddr *)&addr, sizeof addr) < 0) {
perror("bind"); /* EADDRINUSE if the port is busy */
close(listen_fd);
return 1;
}
/* 5. Become passive and set the backlog. */
if (listen(listen_fd, BACKLOG) < 0) {
perror("listen");
close(listen_fd);
return 1;
}
printf("Listening on 127.0.0.1:%d ...\n", PORT);
/* 6. Serve one client at a time, forever. */
for (;;) {
struct sockaddr_in client = {0};
socklen_t clen = sizeof client;
int conn_fd = accept(listen_fd, (struct sockaddr *)&client, &clen);
if (conn_fd < 0) {
if (errno == EINTR) continue; /* interrupted by a signal, retry */
perror("accept");
break;
}
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &client.sin_addr, ip, sizeof ip);
printf("Client connected from %s:%d\n", ip, ntohs(client.sin_port));
/* Read the client's request into a bounded buffer. */
char buf[256];
ssize_t n = recv(conn_fd, buf, sizeof buf - 1, 0);
if (n > 0) {
buf[n] = '\0'; /* terminate before printing */
printf("Received %zd bytes: %s\n", n, buf);
/* Send a fixed reply back to this client. */
const char *reply = "Hello from the server!\n";
size_t len = strlen(reply), sent = 0;
while (sent < len) { /* send() may write only part */
ssize_t w = send(conn_fd, reply + sent, len - sent, 0);
if (w < 0) { perror("send"); break; }
sent += (size_t)w;
}
} else if (n < 0) {
perror("recv");
}
close(conn_fd); /* done with THIS client; keep listening */
}
close(listen_fd);
return 0;
}
What it does: builds a loopback TCP server on port 8080, then loops forever accepting one client at a time. For each client it prints the client's address, reads up to 255 bytes, echoes what it got to the console, and sends back a fixed greeting before closing that connection.
Expected output (server console) after a client sends ping:
Listening on 127.0.0.1:8080 ...
Client connected from 127.0.0.1:54321
Received 4 bytes: ping
The client's ephemeral port (here 54321) will vary each run. Test it from another terminal with nc 127.0.0.1 8080 (type a line, press Enter) or with the client from the previous lesson.
Edge cases: if a client connects but sends nothing and disconnects, recv() returns 0 and the server just closes the connection. If send() can't push all bytes in one call, the while loop finishes the job. EINTR from accept() (a signal interrupted the wait) is retried rather than treated as fatal.
socket(AF_INET, SOCK_STREAM, 0) asks the kernel for an IPv4 TCP endpoint and returns the smallest unused descriptor (often 3, since 0/1/2 are stdin/stdout/stderr). This is the listening socket.setsockopt(SO_REUSEADDR) sets a flag on that socket before binding, so a restart won't be blocked by a lingering TIME_WAIT.struct sockaddr_in addr = {0} zeroes all fields, then we set family (IPv4), address (INADDR_LOOPBACK via htonl), and port (8080 via htons).bind() hands that address struct to the kernel, which reserves 127.0.0.1:8080 for this socket. If another process already holds it, bind returns -1 with errno == EADDRINUSE.listen(listen_fd, 16) converts the socket to passive and creates a kernel queue up to 16 established connections deep.for(;;) loop is the heart of the server. accept() blocks until a client arrives, then returns a new descriptor, conn_fd, and fills client with the peer's address.inet_ntop turns the binary client address into a printable string like 127.0.0.1; ntohs converts the client's port back to host order for display.recv(conn_fd, buf, 255, 0) reads from the connected socket. We reserve one byte so we can write buf[n] = '\0' and safely treat the data as a string.send loop writes the reply, accounting for partial sends by advancing sent until the whole message is out.close(conn_fd) ends this one conversation; control returns to the top of the loop and accept() waits for the next client. listen_fd is never closed inside the loop.Trace of file descriptors during two connections:
| Step | listen_fd | conn_fd | Notes |
|---|---|---|---|
| after socket() | 3 | — | listening socket created |
| after 1st accept() | 3 | 4 | talking to client A |
| after 1st close(conn_fd) | 3 | (closed) | A done, still listening |
| after 2nd accept() | 3 | 4 | 4 reused for client B |
| after 2nd close(conn_fd) | 3 | (closed) | B done, still listening |
1. Reading/writing the listening socket instead of the accepted one.
int listen_fd = accept(listen_fd, NULL, NULL); // WRONG: overwrites listen_fd
recv(listen_fd, buf, sizeof buf, 0);
Overwriting listen_fd loses your listening socket and confuses which descriptor is which. Corrected: keep two named variables — int conn_fd = accept(listen_fd, ...) — and always do client I/O on conn_fd.
2. Setting SO_REUSEADDR after bind(). The option only affects the next bind, so setting it afterward does nothing this run. Recognise it when a restart still fails with EADDRINUSE. Fix: move setsockopt above bind.
3. Forgetting to check bind(). If bind silently fails and you press on to listen/accept, the server appears to run but is bound to nothing sensible. Always test the return value and perror.
4. Assuming one recv() returns the whole message. TCP is a byte stream, not a message boundary. One send("hello world") on the client may arrive as hel then lo world. For real protocols, loop until you have a full message (e.g. up to a newline or a known length). The example handles a single short read for teaching clarity.
5. Closing the connected socket but leaking it on error paths. If you break/return mid-loop, make sure every path reaches close(conn_fd). Leaked descriptors eventually exhaust the process limit and accept starts failing with EMFILE.
6. Not null-terminating before treating bytes as a string. recv does not add a \0. Printing buf with %s before terminating reads past the data. Always buf[n] = '\0' and size the buffer as sizeof buf - 1.
Compiler errors
implicit declaration of 'socket'/'inet_ntop' → a header is missing. Include <sys/socket.h>, <netinet/in.h>, <arpa/inet.h>, <unistd.h>.storage size of 'addr' isn't known → you forgot <netinet/in.h> where struct sockaddr_in is defined.Runtime errors (check errno / perror)
bind: Address already in use (EADDRINUSE) → another process holds the port, or a recent instance is in TIME_WAIT. Confirm with lsof -i :8080 (macOS/Linux) or ss -ltnp. Fix with SO_REUSEADDR or a different port.bind: Permission denied (EACCES) → ports below 1024 need privilege. Use a high port like 8080.accept returning -1 with EINTR → a signal interrupted the blocking wait; just retry (the example does).Logic errors
Questions to ask when it doesn't work: Is the port free? Am I doing I/O on conn_fd, not listen_fd? Did every socket call succeed? Is the client connecting to 127.0.0.1 on the same port? Use strace/dtruss to see exactly which syscall fails.
A server accepts input from outside your program, so it is the highest-value place to be disciplined about memory safety.
recv (sizeof buf - 1) and never assume how many bytes arrive. A fixed-size stack buffer with an unbounded copy is the classic buffer overflow. Here we read at most 255 bytes into a 256-byte buffer and terminate at the returned length.recv returns raw bytes with no \0. Treating them as a C string without terminating is undefined behaviour (out-of-bounds read). Always set buf[n] = '\0'.socket, bind, listen, accept, recv, send all return -1 on error. Ignoring failures leads to using invalid descriptors — undefined behaviour.conn_fd must be close()d on all paths. Leaked descriptors are a resource leak that ends in EMFILE. Treat the connected socket like heap memory: whoever opens it owns closing it.send may transmit fewer bytes than requested; looping until sent == len prevents silently dropping the tail of a reply.INADDR_LOOPBACK instead of INADDR_ANY is a least-privilege choice: a practice server should never be reachable from the network. Only bind to INADDR_ANY when you genuinely intend to serve other machines, and only then behind a firewall and with validated input. Never trust the size, content, or encoding of anything a client sends: validate lengths, reject oversized requests, and treat all input as hostile until proven otherwise.Every listening service is a TCP server built on this exact sequence. Nginx and Apache bind()/listen() on 80 and 443; PostgreSQL listens on 5432; Redis on 6379; an SSH daemon on 22. When you run docker run -p 8080:80, Docker is forwarding to a container's listening socket. Multiplayer game servers, chat backends, and IoT device controllers all follow this shape.
Professional best practices
| Area | Beginner habit | Advanced habit |
|---|---|---|
| Naming | listen_fd vs conn_fd kept distinct |
Wrap the setup in a make_server(port) helper returning the fd |
| Error handling | perror and exit on failure |
Structured logging, graceful shutdown on SIGINT |
| Concurrency | One client at a time (this lesson) | fork, threads, or an event loop (epoll/kqueue) to serve many at once |
| Input handling | Bounded recv, null-terminate |
Frame messages by length/delimiter, cap total request size |
| Cleanup | close(conn_fd) every path |
RAII-style wrappers, SO_REUSEADDR, connection timeouts |
| Exposure | Bind loopback for local work | Bind explicit interface, run behind a firewall/reverse proxy |
The single-client loop here is deliberately simple; the natural next step is handling many clients at once, which the udp-client topic and later concurrency lessons build toward. The professional habit that never changes: validate input, check every return, and always clean up sockets.
Beginner 1 — Echo one client. Start from the example, but instead of a fixed greeting, send back exactly the bytes you received (an echo server). Requirements: read once with a bounded recv, echo the same bytes with a partial-send loop, then close. Hint: reuse n as the length to send. Concepts: accept, recv, send, bounded buffers.
Beginner 2 — Configurable port. Modify the server to read the port number from argv[1] (default to 8080 if none given). Requirements: validate the argument with strtol, reject values outside 1–65535, and print the chosen port. Input example: ./server 9000. Hint: htons() still applies. Concepts: bind, argument parsing, validation.
Intermediate 1 — Serve many clients in a loop with a counter. Keep the accept loop running and assign each accepted connection a monotonically increasing id (1, 2, 3, …). Print "[conn 3] ..." prefixes. Requirements: the id survives across iterations; close each conn_fd. Hint: an int next_id declared before the loop. Concepts: loop state, descriptor lifetime. (Related to the next_conn_id exercise.)
Intermediate 2 — Clamp the backlog. Accept a requested backlog from argv, clamp it to the range 0..128 before passing it to listen(), and print both the requested and effective values. Requirements: never pass a negative backlog. Input example: ./server 8080 -5 → effective backlog 0. Hint: two comparisons. Concepts: listen backlog, input clamping. (Related to the clamp_backlog exercise.)
Challenge — Line-delimited request/response. Read from the client until you see a newline \n, treating everything before it as one request line. Requirements: loop calling recv and appending into a bounded buffer until a \n is found or the buffer is full; if the line is PING, reply PONG\n, otherwise reply UNKNOWN\n; reject and close if the buffer fills without a newline (guard against oversized input). Constraints: buffer no larger than 512 bytes; no reads or writes on the listening socket. Hint: track how many bytes you've accumulated and scan the new bytes for \n. Concepts: TCP has no message boundaries, framing, defensive length limits.
A TCP server is built from a fixed sequence: socket() creates the endpoint, an optional setsockopt(SO_REUSEADDR) (set before bind) allows fast restarts, bind() claims a local IP and port, listen() makes the socket passive and sets the backlog, and accept() blocks until a client arrives and returns a new socket for that client. The most important idea is the two-socket model: the listening socket only produces connections; each connected socket (conn_fd) carries one client's data and must be closed when that client is done.
Most important syntax: bind(fd, (struct sockaddr*)&addr, sizeof addr), listen(fd, backlog), and int conn_fd = accept(listen_fd, NULL, NULL). Fill struct sockaddr_in after zeroing it, using htonl(INADDR_LOOPBACK) and htons(port).
Common mistakes: doing I/O on the listening socket, setting SO_REUSEADDR too late, ignoring return values, forgetting that TCP is a byte stream with no message boundaries, not null-terminating received bytes, and leaking conn_fd. Remember: bind loopback for practice, bound every recv, check every call, and close every socket. That discipline is exactly what separates a toy server from a robust one.