Networking in C · advanced · ~15 min

TCP server

- 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

Overview

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.

Why it matters

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.

Core concepts

1. The listening socket vs. the connected socket

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 does close(3) (the listening socket). Can it still talk to the three clients? Can it accept a fourth?

2. bind() — claiming an address and port

bind() 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.

3. listen() and the backlog

listen(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 after bind() but before accept().

4. accept() — picking up a waiting client

accept() 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.

5. SO_REUSEADDR — restarting without waiting

After 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, ...) after bind() and still gets EADDRINUSE on restart. Why did it not help?

Syntax notes

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.

Lesson

The five core calls

Setting up a TCP server follows a fixed sequence of system calls:

  1. socket() — create the socket.
  2. setsockopt(SO_REUSEADDR) — optional, but lets you reuse the port quickly after the program restarts.
  3. bind() — attach the socket to a local address and port.
  4. listen() — mark the socket as passive (ready to accept connections) and set the backlog, the limit on how many pending connections may queue up.
  5. accept() — pick up the next waiting client and return a new socket for talking to it.

Bind safely for local work

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.

Code examples

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

Line by line

  1. 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.
  2. setsockopt(SO_REUSEADDR) sets a flag on that socket before binding, so a restart won't be blocked by a lingering TIME_WAIT.
  3. struct sockaddr_in addr = {0} zeroes all fields, then we set family (IPv4), address (INADDR_LOOPBACK via htonl), and port (8080 via htons).
  4. 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.
  5. listen(listen_fd, 16) converts the socket to passive and creates a kernel queue up to 16 established connections deep.
  6. The 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.
  7. 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.
  8. 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.
  9. The send loop writes the reply, accounting for partial sends by advancing sent until the whole message is out.
  10. 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

Common mistakes

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.

Debugging tips

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).
  • The server accepts but the client hangs → you may be reading on the listening socket, or the client is waiting for a reply you never sent.

Logic errors

  • Nothing prints when a client connects → make sure the client actually reached this port and address; a loopback server won't answer connections to the machine's LAN IP.
  • Garbage after the received text → missing null terminator.

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.

Memory safety

A server accepts input from outside your program, so it is the highest-value place to be disciplined about memory safety.

  • Bounded reads. Always pass a size to 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.
  • Null-termination before string use. 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'.
  • Check the return value of every syscall. socket, bind, listen, accept, recv, send all return -1 on error. Ignoring failures leads to using invalid descriptors — undefined behaviour.
  • Descriptor lifetime and leaks. Every accepted 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.
  • Partial writes. send may transmit fewer bytes than requested; looping until sent == len prevents silently dropping the tail of a reply.
  • Defensive habit — least exposure. Binding to 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.

Real-world uses

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.

Practice tasks

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.

Summary

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.

Practice with these exercises