TCP Echo Server from Scratch

Goal: Build a TCP echo server in C that accepts multiple clients using epoll. Understand the full socket lifecycle: socketbindlistenacceptread/writeclose.

Prerequisites: TCP Protocol, Socket Programming, File IO in C, System Calls


What We’re Building

A server on port 8080 that:

  1. Accepts TCP connections
  2. Reads whatever the client sends
  3. Echoes it back
  4. Handles multiple clients concurrently (with epoll, no threads)

Step 1: Create and Bind the Socket

// echo_server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <errno.h>
 
#define PORT 8080
#define MAX_EVENTS 64
#define BUF_SIZE 4096
 
static void die(const char *msg) { perror(msg); exit(1); }
 
static void set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
 
int create_server(void) {
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd < 0) die("socket");
 
    // Allow port reuse (avoid "Address already in use" on restart)
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &(int){1}, sizeof(int));
 
    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_port = htons(PORT),
        .sin_addr.s_addr = INADDR_ANY,
    };
 
    if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) die("bind");
    if (listen(fd, 128) < 0) die("listen");
 
    set_nonblocking(fd);
    printf("Listening on :%d\n", PORT);
    return fd;
}

Why SO_REUSEADDR

After closing a socket, the port stays in TIME_WAIT for ~60 seconds. Without SO_REUSEADDR, restarting the server fails with “Address already in use”. See TCP Protocol — TIME_WAIT prevents stale packets from a previous connection.

Why non-blocking

With blocking sockets, accept() and read() block the entire process. Non-blocking + epoll lets us handle many clients in a single thread.


Step 2: The epoll Event Loop

int main(void) {
    int server_fd = create_server();
 
    int epfd = epoll_create1(0);
    if (epfd < 0) die("epoll_create1");
 
    // Watch the server socket for incoming connections
    struct epoll_event ev = {.events = EPOLLIN, .data.fd = server_fd};
    epoll_ctl(epfd, EPOLL_CTL_ADD, server_fd, &ev);
 
    struct epoll_event events[MAX_EVENTS];
 
    while (1) {
        int nready = epoll_wait(epfd, events, MAX_EVENTS, -1);
        if (nready < 0) {
            if (errno == EINTR) continue;   // interrupted by signal
            die("epoll_wait");
        }
 
        for (int i = 0; i < nready; i++) {
            int fd = events[i].data.fd;
 
            if (fd == server_fd) {
                // New connection
                accept_client(server_fd, epfd);
            } else {
                // Data from existing client
                handle_client(fd, epfd);
            }
        }
    }
 
    close(epfd);
    close(server_fd);
    return 0;
}

How epoll works

  1. Register file descriptors you care about
  2. epoll_wait blocks until at least one fd is ready
  3. Returns only the ready fds — no scanning the entire set (unlike select/poll)
  4. O(ready) per call, handles 100k+ connections

Step 3: Accept and Handle Clients

void accept_client(int server_fd, int epfd) {
    struct sockaddr_in client_addr;
    socklen_t len = sizeof(client_addr);
 
    // Accept all pending connections (non-blocking may have multiple)
    while (1) {
        int client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &len);
        if (client_fd < 0) {
            if (errno == EAGAIN || errno == EWOULDBLOCK)
                break;   // no more pending connections
            perror("accept");
            break;
        }
 
        set_nonblocking(client_fd);
 
        struct epoll_event ev = {.events = EPOLLIN, .data.fd = client_fd};
        epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);
 
        printf("Client connected: %s:%d (fd=%d)\n",
               inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), client_fd);
    }
}
 
void handle_client(int fd, int epfd) {
    char buf[BUF_SIZE];
    ssize_t n = read(fd, buf, sizeof(buf));
 
    if (n <= 0) {
        if (n == 0)
            printf("Client disconnected (fd=%d)\n", fd);
        else if (errno != EAGAIN)
            perror("read");
        epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
        close(fd);
        return;
    }
 
    // Echo back
    write(fd, buf, n);
}

Step 4: Build and Test

gcc -Wall -Wextra -g -o echo_server echo_server.c
./echo_server

In another terminal, test with nc (netcat):

# Terminal 2
nc localhost 8080
hello                    # type this
hello                    # server echoes back
world
world
^C                       # Ctrl+C to disconnect
 
# Or one-shot test:
echo "ping" | nc localhost 8080

Test multiple clients

# Terminal 2
nc localhost 8080 &
nc localhost 8080 &
nc localhost 8080 &
# All three connect simultaneously — single-threaded server handles all of them

Step 5: Verify with strace

strace -e trace=socket,bind,listen,accept4,epoll_wait,read,write ./echo_server

You’ll see the exact syscall sequence: socketbindlistenepoll_createepoll_wait (blocks) → accept4readwriteepoll_wait (blocks again).


Architecture Recap

                 ┌──────────┐
  Client 1 ──→  │          │
  Client 2 ──→  │  epoll   │ ──→ single thread handles all events
  Client 3 ──→  │  loop    │
                 └──────────┘
                      │
              ┌───────┼───────┐
              │       │       │
          accept   read/    close
                   write

This is the foundation of nginx, Redis, and Node.js — a single-threaded event loop handling thousands of connections.


Exercises

  1. Chat server: Instead of echoing back to the sender, broadcast the message to all connected clients. Track clients in an array or linked list.

  2. HTTP server: Respond to GET / with a minimal HTTP response: HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello. Test with curl http://localhost:8080/.

  3. Graceful shutdown: Handle SIGINT to close all client connections, unregister from epoll, and exit cleanly. Use signalfd or a self-pipe to integrate signals with the event loop.

  4. Throughput test: Write a client that connects and sends 1 million bytes. Measure throughput (MB/s). Try with and without TCP_NODELAY.


Next: 06 - Build a Thread Pool in C — handle CPU-bound work alongside the event loop.