Zum Inhalt springen

I built a Backend web Framework from Scratch in C++

wouldn’t go as far as calling it a framework — it’s more of a library

I’ve been exploring some backend web frameworks lately and kept asking myself: what do these things actually do under the hood?

To find out, I decided to dive into C++ and experiment. After some tinkering, I built a small homegrown backend web library, split into three layers:

  1. Socket Library – Handles raw communication between processes.
  2. HTTP Server – Parses HTTP requests, manages headers and bodies, and handles TCP streams.
  3. Web Library – Provides a simple framework for routing, controllers, and serving static files, similar to Express.js.

Each layer is built on top of the one beneath it, so understanding the foundation is crucial.

Before we dive into the layers, you can check out the GitHub repos:

Please note that this is just my understanding of how things work, how I implemented the stuff

Also note that this project isn’t fully production-ready, but it’s an excellent exercise in understanding backend frameworks from the ground up.

Understanding Sockets: How Processes Talk

At the core of networking on Unix-like systems are file descriptors (FDs) — small integers a process uses to refer to kernel-managed resources (files, pipes, or sockets). When you call something like fflush(stdout) you’re asking your program’s runtime to push buffered bytes down to the FD that represents stdout; what happens to those bytes next depends on what that FD is connected to (a terminal, a file, or a socket).

A socket is one of those kernel-managed resources: it’s a kernel object that your process creates with socket(...) and then uses to send and receive network data. You can think of a socket as an endpoint inside your program; the socket itself is represented by an FD in your process. To tell the kernel where packets should go (or where they came from), a socket is usually bound to a network address, which is commonly expressed as three parts:

  • Address family — how to interpret addresses (IPv4 or IPv6, e.g. AF_INET or AF_INET6).
  • IP address — which host/machine on the network you mean (e.g. 127.0.0.1).
  • Port — which particular service or process on that host should receive the traffic (e.g. 80, 8080).

Note: Only ports 0–1023 are reserved for well-known services like HTTP (80) or SSH (22). Ports above that are available for general use.

Socket types

Two socket types are most relevant when writing networked servers:

1) Datagram sockets (UDP)

  • UDP sockets are connectionless: you can call sendto() with any destination address (IP+port) and the kernel will attempt to deliver that single datagram.
  • Each recvfrom() or recvmsg() call returns exactly one datagram (so message boundaries are preserved).
  • There is no handshake, and the network does not guarantee delivery, ordering, or uniqueness — datagrams can be lost, duplicated, or arrive out of order.
  • It’s common to bind a UDP socket to a port and serve many different remote peers on that single FD; the kernel provides the sender’s address on each receive so you can reply.

2) Stream sockets (TCP)

  • TCP sockets are connection-oriented: the client and server perform a 3-way handshake to establish a connection.
  • After the handshake the kernel exposes a reliable, ordered byte stream to your process. TCP ensures bytes are delivered and in order (barring extreme failures), but it does not preserve packet/message boundaries; if you send two write() calls on the sender, the receiver may receive them merged or split across read() calls.
  • For servers you bind() and listen() on a port. accept() returns a brand-new FD representing the established connection; the listening FD continues to accept more connections. Each client connection has its own kernel socket object and FD.

Notes on scale & semantics

  • For UDP you can call connect() on the socket to set a default peer (useful to avoid passing addr to every sendto()), but connect() on a UDP socket only sets the default destination — the underlying semantics remain datagram and connectionless.
  • For TCP, accept() and the new FD are what you use to read()/write() that client’s data; the listening socket never carries per-client data.
  • Remember: “ordered bytes” (TCP) ≠ “preserved messages” — if you need discrete messages on top of TCP, implement framing (length prefix, delimiters, etc.).

Creating a Simple UDP Socket

// Creating a simple UDP socket with address in C-style (On Unix)

int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
    // Handle error
}

struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);

if (bind(sockfd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
    // Handle error
}

// Use the socket...

// Cleanup
close(sockfd);
return 0;

Creating a Simple UDP Socket (using my socket library)

// Creating a simple UDP socket with address Using My library (same logic as above but wrapped in my library)

using namespace hh_socket;

socket_address addr( port(8080) ,  ip_address("127.0.0.1") , family(IPV4) );

socket sock(addr , Protocol::UDP);

// Cleanup is handled by destructor

Creating a Simple TCP Server (using my socket library)

// Creating a simple TCP server, that echoes back messages

using namespace hh_socket;

socket_address addr( port(8080) ,  ip_address("0.0.0.0") , family(IPV4) );

socket sock(addr , Protocol::TCP);

sock.listen();

while (true) {
    std::shared_ptr<connection> conn = sock.accept(); // blocking call

    data_buffer buf = conn->receive(); // blocking call
    data_buffer echo_message ("Echo: ");

    echo_message.append(buf);

    conn->send(echo_message); // echo back
    conn->close();
}

// Cleanup is handled by destructor

Handling Blocking Operations

Blocking means an I/O call (like read() or accept()) makes your program wait until the operation completes.

When a socket call blocks, the current thread simply sits idle until the OS has data to return or the requested action completes. For servers that handle many clients, blocking on a single thread quickly becomes a bottleneck. To handle many connections efficiently, you can use:

1. Multithreading

  • Idea: handle each connection in its own thread or use a pool of worker threads.
  • Pros: simple mental model — each handler can use blocking calls; easy to write.
  • Cons: high memory/context-switch cost for many connections; synchronization complexity.

2. I/O multiplexing

  • Idea: a single thread (or a few threads) waits on many file descriptors and reacts when any become ready. Tools: epoll (Linux), IOCP (Windows), kqueue (macOS). There is also select (Windows/Unix) it is less efficient for large numbers of connections.
  • Pros: low thread overhead; great for many concurrent connections.
  • Cons: more complex control flow; must handle partial reads/writes.

3. Async I/O

  • Idea: submit read/write requests to the kernel and receive completion events later (no thread is blocked waiting). io_uring on modern Linux is a powerful example.
  • Pros: excellent throughput and low latency; scales well.
  • Cons: API is more advanced; portability issues.

For my project, I used I/O multiplexing, allowing a single-threaded event loop to handle hundreds of connections efficiently.

// a simple epoll server (using my epoll_server class) for handling multiple sockets.

class chat_server : public epoll_server {
    std::unordered_map<int, std::string> usernames;

protected:
    void on_connection_opened(std::shared_ptr<connection> conn) override {
        send_message(conn, data_buffer("Enter username: "));
    }

    void on_message_received(std::shared_ptr<connection> conn, const data_buffer &db) override {
        std::string msg = db.to_string();

        if (usernames.find(conn->get_fd()) == usernames.end()) {
            // First message is username
            usernames[conn->get_fd()] = msg;
            broadcast(usernames[conn->get_fd()] + " joined the chat");
        } else {
            // Regular chat message
            broadcast(usernames[conn->get_fd()] + ": " + msg);
        }
    }

    void on_connection_closed(std::shared_ptr<connection> conn) override {
        auto it = usernames.find(conn->get_fd());
        if (it != usernames.end()) {
            broadcast(it->second + " left the chat");
            usernames.erase(it);
        }
    }

private:
    void broadcast(const std::string &message) {
        data_buffer msg(message);
        for (const auto &[fd, conn_state] : conns) {
            send_message(conn_state.conn, msg);
        }
    }
};

Building the HTTP Server

TCP streams are just sequences of bytes. An HTTP request might be fragmented across multiple TCP packets, so the server must:

  1. Reassemble the byte stream.
  2. Extract request headers.
  3. Parse the body (if present).
  4. Handle limits (max body size, header size).

The HTTP server integrates tightly with the socket library, that is, it extends the hh_socket::epoll_server functionality by reusing its efficient connection handling and abstraction. This shows how layering simplifies complexity: the HTTP server focuses on protocol logic, while sockets manage the low-level networking.

Note that my implementation is not fully compliant with the HTTP specification, it just provides a basic framework for handling HTTP requests.

High-level (use the project’s parser):

// Example: receive a buffer from a connection and let the project's parser assemble
// requests that may span multiple TCP reads.
hh_socket::data_buffer buf = conn->receive();
hh_http::http_message_handler parser;

// `handle` returns an http_handled_data describing either a complete request
// or that more bytes are required (completed == false).
auto result = parser.handle(conn, buf);
if (!result.completed) {
    // Not enough data yet - wait for the next read and call parser.handle again
} else if (result.method.rfind("BAD_", 0) == 0) {
    // Parser returned an error token (e.g. BAD_METHOD_OR_URI_OR_VERSION)
    // Application can craft an error response here
} else {
    // Complete request: use result.method, result.uri, result.headers, result.body
}

Low-level (manual reassembly sketch):

// Read bytes into a string buffer until we detect the header terminator rnrn
std::string buffer = conn->receive().to_string();
size_t headers_end = buffer.find("rnrn");
if (headers_end != std::string::npos) {
    std::string header_block = buffer.substr(0, headers_end);
    std::string rest = buffer.substr(headers_end + 4); // body (maybe partial)

    // parse request-line and headers (split on rn, then on ':')
    // find Content-Length (if present) to determine expected body size
    size_t content_length = 0; // parse from header_block if present

    // Keep reading until we have the full body
    while (rest.size() < content_length) {
        rest += conn->receive().to_string();
    }

    std::string body = rest.substr(0, content_length);
    // Now header_block contains headers and `body` contains the full payload
}

HTTP Server example (using my http_server class)

#include "http_server.hpp"

int main() {
    using namespace hh_http;

    // this automatically sets up the server to listen for incoming connections
    // also this handles big request body, and also, you can normally send a big response,
    // as the epoll server itself handles sending such big chunks of data
    http_server server(8081, "0.0.0.0");

    // Set up all the callbacks
    server.set_request_callback([]( http_request &req , http_response &res ) {
        // Handle incoming HTTP requests
        res.set_body("Hello, World!n");
        res.set_status(200, "OK");
        res.send(); // send the headers, and the body to the client
        res.end();
    });
    server.set_error_callback([](const std::exception &e) {
        std::cerr << "Error: " << e.what() << std::endl;
    });

    // Start the server (this will block)
    server.listen();
}

The Web Library Layer

The top layer provides routing, controllers, and static file serving, similar to Express.js. Key features:

  • MVC-like architecture – Organizes code into controllers, views, and models for better maintainability.
  • Routing system – Maps incoming HTTP requests to controller actions.
  • Static file serving – Delivers HTML, CSS, and JavaScript assets alongside dynamic content.

Simple Example server:

#include "web-lib.hpp"

// Define request handlers (controller actions)
hh_web::exit_code get_users_handler(
    std::shared_ptr<hh_web::web_request> req,
    std::shared_ptr<hh_web::web_response> res) {

    res->send_json("{"users": ["Alice", "Bob"]}");
    return hh_web::exit_code::EXIT;
}

hh_web::exit_code create_user_handler(
    std::shared_ptr<hh_web::web_request> req,
    std::shared_ptr<hh_web::web_response> res) {

    // Extract user data from request body
    std::string body = req->get_body();
    res->send_json("{"success": true, "message": "User created"}");
    return hh_web::exit_code::EXIT;
}

int main() {

    auto server = std::make_shared<hh_web::web_server<>>(8080, "0.0.0.0");

    auto router = std::make_shared<hh_web::web_router<>>();

    // Map routes to controller actions
    using V = std::vector<hh_web::web_request_handler_t<>>;

    router->get("/api/users", V{get_users_handler});
    router->post("/api/users", V{create_user_handler});

    // It auto-detects the static files (based on the extention, then sends it)
    server->use_static("static");

    // Route with path parameters
    router->get("/api/users/:id", V{[](auto req, auto res) -> hh_web::exit_code {
        auto params = req->get_path_params();
        std::string user_id = params.at("id");
        res->send_json("{"user_id": "" + user_id + ""}");
        return hh_web::exit_code::EXIT;
    }});

    server->use_router(router);
    server->listen();
}

Why This Structure Matters

Here’s what this layered design teaches:

  • Sockets handle raw communication and events efficiently.
  • HTTP server interprets protocol-level data reliably from TCP streams.
  • Web library allows developers to structure their application cleanly and add features without worrying about low-level details.

Even after a short time learning backend programming, this project clarified:

  • Networking can be simplified to basic operations and abstractions.
  • TCP streams need careful parsing, not just reading packets.
  • Layering responsibilities makes large systems manageable and testable.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert