I learned that every web connection, whether it’s an HTTP request, a WebSocket, or a gRPC stream, ultimately boils down to network sockets managed by the operating system’s kernel.

Most high-level languages don’t communicate directly with the network hardware; instead, they rely on their own abstractions, which in turn call C functions to interact with the kernel’s socket layer.

To truly understand what happens beneath the frameworks I use every day, I decided to build a functional web server in C, from scratch. My goal is to understand how data moves from a client’s request to the server’s response at the most fundamental level, encompassing everything in between, especially concerning the hardware. I believe that walking this low-level path will not only sharpen my backend design intuition but also move me from simply consuming frameworks to understanding how they work and possibly creating them.

Everything is a File

At the kernel level, everything is treated as a file, including the socket that handles Internet connections. Since everything is a file, any I/O operation in Unix is essentially a matter of reading from or writing to a file descriptor.

int server_fd = socket(AF_INET, SOCK_STREAM, 0);

server_fd is a socket file descriptor.

A file descriptor is simply an integer that the operating system associates with an open file (or socket). When you call socket(). It returns a socket descriptor, which is another type of file descriptor. Subsequent communication occurs through interaction with this descriptor using functions such as send() [socket’s variant for write()] and recv() [socket’s variant for read()].

Here’s the beautiful simplicity of the Unix model: since sockets are treated as files, reading a network request is no different from reading a text file. You use the same read() function to copy bytes from the kernel’s buffer into your program’s memory.
Writing a response? Same deal, write() copies your response bytes from your program into the kernel’s buffer, which then sends them over the network. At this fundamental level, HTTP requests aren’t objects or structured data; they’re just bytes flowing between buffers.

Building the Web Server in C

Let us walk through exactly what happens when a request hits a server, from the moment the server is started to when the response is returned to the client. We’ll build this piece by piece with actual C code.

Part 1: Creating and Binding the Socket

When a server is started, the first thing that happens is that the program requests a socket from the operating system.

#include <sys/socket.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main() {
    // Step 1: Create a socket
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd < 0) {
        perror("socket failed");
        exit(EXIT_FAILURE);
    }
    
    printf("Socket created with file descriptor: %d\n", server_fd);
}

What’s happening:

  • socket(AF_INET, SOCK_STREAM, 0) is a system call that requests a communication endpoint from the kernel.

    • AF_INET means “Address Family: Internet” (IPv4), AF_INET6 for IPv6

    • SOCK_STREAM means “I want reliable, ordered, two-way communication” (TCP)

    • 0 means “use the default protocol” (which is TCP for SOCK_STREAM)

  • The kernel creates internal data structures to represent this socket and returns a file descriptor (just an integer)

  • This file descriptor is the handle to communicate with the kernel about this socket

This is equivalent to const app = express(); in express.

After the communication endpoint has been acquired from the kernel, it needs its own particular address location <IP address and Port>.

// Step 2: Configure the address structure
    struct sockaddr_in address;
    address.sin_family = AF_INET;           // IPv4
    address.sin_addr.s_addr = INADDR_ANY;   // Listen on all interfaces (0.0.0.0)
    address.sin_port = htons(8080);         // Port 8080 (converted to network byte order)
    
    // Step 3: Bind the socket to the address
    if (bind(server_fd, (struct sockaddr*)&address, sizeof(address)) < 0) {
        perror("bind failed");
        exit(EXIT_FAILURE);
    }
    
    printf("Socket bound to port 8080\n");

What’s happening:

  • struct sockaddr_in is a data structure that holds an IP address and a port number

  • INADDR_ANY (which is 0.0.0.0) means “accept connections on any network interface on this machine”

  • htons(8080) converts the port number to network byte order (big-endian). Networks always use big-endian, but your CPU might be little-endian, so this conversion is necessary

  • bind() is a system call that associates your socket with this address/port combination

    • The kernel now knows: “Whenever data arrives at port 8080, it belongs to this socket”

    • Only one program can bind to a port at a time (that’s why you get “address already in use” errors)

This is equivalent to app.listen() in express.

Part 2: Listening for Connections

// Step 4: Mark socket as passive (listening)
    if (listen(server_fd, 10) < 0) {
        perror("listen failed");
        exit(EXIT_FAILURE);
    }
    
    printf("Server listening on port 8080...\n");

What’s happening:

  • listen(server_fd, 10) tells the kernel: “This socket should accept incoming connections”.

    • The 10 is the backlog: the maximum number of pending connections the kernel will queue before refusing new ones

    • When a client tries to connect, the kernel completes the TCP handshake and puts the connection in a queue

    • The program will later call accept() to pull connections from this queue

Part 3: The Main Server Loop - Accepting Connections

// Step 5: Accept loop
    while (1) {
        struct sockaddr_in client_address;
        socklen_t client_len = sizeof(client_address);
        
        // This blocks until a client connects
        int client_fd = accept(server_fd, 
                               (struct sockaddr*)&client_address, 
                               &client_len);
        
        if (client_fd < 0) {
            perror("accept failed");
            continue;
        }
        
        printf("New connection accepted (fd: %d)\n", client_fd);
        
        // Handle the client...
    }

What’s happening:

  • accept() is a blocking system call; the program stops here and waits until a client connects

  • When a client connects:

    • The kernel pulls a completed connection from the listen queue

    • It creates a new socket specifically for this client

    • It returns a new file descriptor for that client(client_fd)

  • Now the server has two sockets:

    • server_fd: Still listening for new connections

    • client_fd: Connected to this specific client

  • The client_address data structure gets filled with the client’s IP and port

It is crucial to understand that every client gets its own socket (file descriptor).
The original listening socket does not send or receive data; it only accepts new connections

Part 4: Reading the Request

 // Step 6: Read data from client
        char request_buffer[4096] = {0};
        ssize_t bytes_read = read(client_fd, request_buffer, sizeof(request_buffer) - 1);
        
        if (bytes_read < 0) {
            perror("read failed");
            close(client_fd);
            continue;
        }
        
        printf("Received %zd bytes:\n%s\n", bytes_read, request_buffer);

What’s happening:

  • read(client_fd, request_buffer, size) is a system call that says: “Copy data from the kernel’s socket buffer into my program’s buffer”

  • The kernel has been receiving TCP packets from the client and reassembling them

  • read() copies up to size bytes from the kernel’s buffer to the program’s (server) request_buffer

  • It returns the number of bytes actually read (could be less than asked for)

  • This is raw bytes. An HTTP request is basically just plain text

An HTTP request looks like this in the buffer:

GET /users/123 HTTP/1.1
Host: localhost:8080
User-Agent: curl/7.68.0
Accept: */*

Note: recv() could be used instead of read()

ssize_t bytes_read = recv(client_fd, request_buffer, sizeof(request_buffer) - 1, 0);

recv() is socket-specific and gives more options (like MSG_PEEK to look at data without removing it), but for basic use, read() works fine.

Part 5: Parsing the Request (Routing)

Now comes the “magic” of routing. Interestingly, regardless of the elaborations performed by your backend framework, it is just string parsing and if/else statements.

// Step 7: Parse the HTTP request
        char method[16], path[256], version[16];
        sscanf(request_buffer, "%s %s %s", method, path, version);
        
        printf("Method: %s, Path: %s, Version: %s\n", method, path, version);
        
        // Step 8: Route handling (just conditionals!)
        char response[4096]; //response buffer
        
        if (strcmp(method, "GET") == 0 && strcmp(path, "/") == 0) {
            // Route: GET /
            sprintf(response,
                "HTTP/1.1 200 OK\r\n"
                "Content-Type: text/html\r\n"
                "Content-Length: 20\r\n"
                "\r\n"
                "<h1>Home Page</h1>");
                
        } else if (strcmp(method, "GET") == 0 && strncmp(path, "/users/", 7) == 0) {
            // Route: GET /users/:id
            char *user_id = path + 7;  // Skip "/users/"
            
            sprintf(response,
                "HTTP/1.1 200 OK\r\n"
                "Content-Type: application/json\r\n"
                "Content-Length: 25\r\n"
                "\r\n"
                "{\"user_id\": \"%s\"}", user_id);
                
        } else {
            // Route: 404 Not Found
            sprintf(response,
                "HTTP/1.1 404 Not Found\r\n"
                "Content-Type: text/plain\r\n"
                "Content-Length: 9\r\n"
                "\r\n"
                "Not Found");
        }

What’s happening:

  • sscanf() parses the first line of the HTTP request (the “request line”)

  • We extract: method (GET, POST, etc.), path (/users/123), and HTTP version

  • Routing is just a series of if/else statements that compare strings

  • For dynamic routes (like /users/:id), we use strncmp() to match prefixes, then extract the parameter

This is exactly what Express does under the hood with:

app.get('/users/:id', (req, res) => { ... })

It parses the request, does string comparisons, and when it finds a match, it extracts the :id parameter and calls the handler. It’s just more sophisticated string parsing with regular expressions and a routing tree for performance.

Part 6: Sending the Response

// Step 9: Send response back to client
        ssize_t bytes_sent = write(client_fd, response, strlen(response));
        
        if (bytes_sent < 0) {
            perror("write failed");
        }
        
        printf("Sent %zd bytes\n", bytes_sent);
        
        // Step 10: Close the client connection
        close(client_fd);

What’s happening:

  • write(client_fd, response, length) is a system call that copies data from the program’s buffer to the kernel’s socket buffer

  • The kernel then:

    • Breaks data into TCP packets

    • Adds TCP/IP headers

    • Sends packets through the network interface

    • Handles retransmissions if packets are lost

  • close(client_fd) tells the kernel: “I’m done with this connection”

    • The kernel sends a FIN packet to the client (TCP connection termination)

    • The file descriptor is released and can be reused

Note on Memory Management: Both request_buffer and response buffers are on the stack. For large requests and responses (like file upload), malloc() is used to allocate heap memory and free() when done.

send() could be used instead of write()

ssize_t bytes_sent = send(client_fd, response, strlen(response), 0);

Part 7: Complete Working Server

#include <sys/socket.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main() {
    // Create socket
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd < 0) {
        perror("socket failed");
        exit(EXIT_FAILURE);
    }
    
    // Allow reuse of address (useful for development)
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    
    // Configure address
    struct sockaddr_in address;
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(8080);
    
    // Bind socket
    if (bind(server_fd, (struct sockaddr*)&address, sizeof(address)) < 0) {
        perror("bind failed");
        exit(EXIT_FAILURE);
    }
    
    // Listen
    if (listen(server_fd, 10) < 0) {
        perror("listen failed");
        exit(EXIT_FAILURE);
    }
    
    printf("Server listening on http://0.0.0.0:8080\n");
    
    // Main server loop
    while (1) {
        struct sockaddr_in client_address;
        socklen_t client_len = sizeof(client_address);
        
        // Accept connection
        int client_fd = accept(server_fd, (struct sockaddr*)&client_address, &client_len);
        if (client_fd < 0) {
            perror("accept failed");
            continue;
        }
        
        // Read request
        char request_buffer[4096] = {0};
        ssize_t bytes_read = read(client_fd, request_buffer, sizeof(request_buffer) - 1);
        if (bytes_read < 0) {
            perror("read failed");
            close(client_fd);
            continue;
        }
        
        // Parse request
        char method[16], path[256], version[16];
        sscanf(request_buffer, "%s %s %s", method, path, version);
        
        printf("Request: %s %s\n", method, path);
        
        // Route and generate response
        char response[4096];
        if (strcmp(method, "GET") == 0 && strcmp(path, "/") == 0) {
            sprintf(response,
                "HTTP/1.1 200 OK\r\n"
                "Content-Type: text/html\r\n"
                "Content-Length: 20\r\n"
                "\r\n"
                "<h1>Home Page</h1>");
        } else if (strcmp(method, "GET") == 0 && strncmp(path, "/users/", 7) == 0) {
            char *user_id = path + 7;
            char body[256];
            sprintf(body, "{\"user_id\": \"%s\"}", user_id);
            sprintf(response,
                "HTTP/1.1 200 OK\r\n"
                "Content-Type: application/json\r\n"
                "Content-Length: %zu\r\n"
                "\r\n"
                "%s", strlen(body), body);
        } else {
            sprintf(response,
                "HTTP/1.1 404 Not Found\r\n"
                "Content-Type: text/plain\r\n"
                "Content-Length: 9\r\n"
                "\r\n"
                "Not Found");
        }
        
        // Send response
        write(client_fd, response, strlen(response));
        
        // Close connection
        close(client_fd);
    }
    
    close(server_fd);
    return 0;
}

Compile and run:

gcc -o server server.c
./server

Test:

curl http://localhost:8080/
curl http://localhost:8080/users/42
curl http://localhost:8080/invalid

I have a more elaborate implementation of this on my GitHub https://github.com/thesirtaylor/bare-metal-http-server-with-c

It would be wrong to talk about building an app in C without mentioning how we manage memory

Stack memory (automatic):

  • request_buffer, response, method, path are all allocated on the stack when the loop iteration starts

  • And are automatically freed when the loop iteration ends

  • This is fast, but limited in size (usually a few MB)

Heap memory (dynamic): For a production server handling large requests/responses, the system would require the use of malloc():

// Allocate 1MB for large request
char *large_buffer = malloc(1024 * 1024);
if (large_buffer == NULL) {
    perror("malloc failed");
    // handle error
}

// Use the buffer...
read(client_fd, large_buffer, 1024 * 1024);

// Always free when done!
free(large_buffer);

Kernel memory:

  • The kernel maintains buffers for each socket (send buffer and receive buffer)

  • When read() is called, data is copied from kernel buffer space to user buffer space (the program<web server in this case>)

  • When write() is called, data is copied from the user buffer space to the kernel buffer space

  • These copies are expensive, which is why techniques like sendfile() exist to avoid copying for large files

File descriptors:

  • File descriptors are just integers in the program, but they reference kernel data structures

  • close() must be called on these descriptors to free kernel resources

  • If not closed, the process will run out of descriptors (usually limited to 1024 per process by default)

What We Haven’t Covered (But You Should Know)

This simple server is missing several things a real server needs:

  1. Concurrency: Our server handles one client at a time. Real servers use:

    • Multi-threading (one thread per connection)

    • Multi-processing (fork a process per connection)

    • Event-driven I/O (epoll/kqueue to handle many connections in one thread)

  2. HTTP parsing: We used sscanf(), which is fragile. Real servers parse headers line-by-line and handle edge cases.

  3. Persistent connections: HTTP/1.1 supports keep-alive (reusing the same TCP connection for multiple requests). We close after every response.

  4. Error handling: We should handle partial reads/writes, timeouts, malformed requests, etc.

  5. Security: No buffer overflow protection, no HTTPS/TLS, no input validation.

But you now understand the core: sockets are file descriptors, routing is string matching, and HTTP is just text over TCP.

High-Level Framework Abstractions

See how high-level languages handle server creation, having abstracted away low-level parts:

Express.js (Node.js)

const express = require('express');
const app = express();

app.get('/users/:id', (req, res) => {
    res.json({ user_id: req.params.id });
});

app.listen(8080);

What Express is doing under the hood:

  1. express() eventually calls socket(), bind(), and listen() through Node’s C++ bindings

  2. Node’s event loop continuously calls accept() in a non-blocking way

  3. When a request arrives, Node reads it with read()/recv()

  4. Express parses the HTTP headers and body

  5. Express runs your route handlers through a routing tree (optimised string matching)

  6. When you call res.json(), Express formats the response and calls write()/send()

  7. Express calls close() after sending the response (unless it’s a keep-alive connection)

The key difference: Node uses non-blocking I/O (via epoll/kqueue), so one thread can handle thousands of connections. The simple C server we built blocks on accept(), so it can only handle one client at a time.