DEV Community

Haji Rufai
Haji Rufai

Posted on • Originally published at hajirufai.github.io

Building an HTTP Server Framework from Raw TCP Sockets in Python

Most web developers never look under the hood of their framework. Flask, Django, Express — they all abstract away the same thing: raw TCP bytes being transformed into structured HTTP messages. I wanted to understand that transformation, so I built ServeKit — a complete HTTP/1.1 server framework from nothing but Python's socket module.

No Flask. No http.server. No dependencies. Just socket.socket(AF_INET, SOCK_STREAM) and hand-written protocol parsing.

The TCP Foundation

Every HTTP server starts the same way: bind a socket, listen for connections, accept them, read bytes.

import socket
import selectors

class TCPServer:
    def __init__(self, host="0.0.0.0", port=8080, workers=4):
        self.host = host
        self.port = port
        self._selector = selectors.DefaultSelector()
        self._executor = ThreadPoolExecutor(max_workers=workers)

    def start(self):
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        sock.bind((self.host, self.port))
        sock.listen(128)
        sock.setblocking(False)

        self._selector.register(sock, selectors.EVENT_READ)
        while self._running:
            events = self._selector.select(timeout=1)
            for key, mask in events:
                if key.data is None:
                    self._accept(key.fileobj)
                else:
                    self._executor.submit(self._handle, key.fileobj)
Enter fullscreen mode Exit fullscreen mode

The selectors module gives us non-blocking I/O — we can handle thousands of connections without one blocking the rest. When a connection is ready to read, we hand it to a thread pool worker.

Parsing HTTP by Hand

When raw bytes arrive on a TCP connection, they look like this:

GET /users/42?format=json HTTP/1.1\r\n
Host: localhost:8080\r\n
Accept: application/json\r\n
Connection: keep-alive\r\n
\r\n
Enter fullscreen mode Exit fullscreen mode

That's it. Text separated by \r\n, with a blank line marking the end of headers. My parser reads this byte-by-byte:

class HTTPParser:
    def parse(self, data: bytes, client_addr=("0.0.0.0", 0)):
        # Find the header/body boundary
        head_end = data.find(b"\r\n\r\n")
        head_str = data[:head_end].decode("latin-1")
        body_bytes = data[head_end + 4:]

        lines = head_str.split("\r\n")
        method, path, query, version = self._parse_request_line(lines[0])
        headers = self._parse_headers(lines[1:])
        body = self._read_body(headers, body_bytes, data, head_end + 4)

        return Request(method=method, path=path, headers=headers,
                      body=body, query_string=query)
Enter fullscreen mode Exit fullscreen mode

The parser handles everything from HTTP/1.0 to chunked transfer encoding. It validates methods, enforces size limits (no 10GB request lines), and builds a clean Request object.

URL Routing with Priority

Route matching seems simple until you have overlapping patterns. ServeKit supports three match types with clear priority:

  1. Exact match: /users/me
  2. Parameterized: /users/{id}
  3. Wildcard: /files/*path
class Router:
    def resolve(self, method, path):
        # Exact match first
        for route in self._routes:
            if route.pattern == path and route.method in (method, "ANY"):
                return route.handler, {}

        # Then parameterized
        for route in self._routes:
            if route.has_params:
                params = route.match(path)
                if params is not None:
                    return route.handler, params

        # Finally wildcards
        for route in self._routes:
            if route.is_wildcard:
                params = route.match(path)
                if params is not None:
                    return route.handler, params

        raise NotFound(f"No route for {method} {path}")
Enter fullscreen mode Exit fullscreen mode

This means /users/me always wins over /users/{id}, which always wins over /users/*path. Route groups add prefix support for API versioning:

api = app.group("/api/v1")

@api.get("/users/{id}")
def get_user(req, res):
    res.json({"id": req.params["id"]})
# Matches: GET /api/v1/users/42
Enter fullscreen mode Exit fullscreen mode

Middleware: The Onion Model

Middleware wraps handlers in layers. Each middleware calls next_handler to continue the chain, or skips it to short-circuit:

class MiddlewareChain:
    def execute(self, req, res, handler):
        def build_chain(index):
            if index >= len(self._middleware):
                return handler
            mw = self._middleware[index]
            def next_fn(req, res):
                mw(req, res, build_chain(index + 1))
            return next_fn

        build_chain(0)(req, res)
Enter fullscreen mode Exit fullscreen mode

ServeKit ships with five built-in middleware:

  • CORS — handles preflight OPTIONS and sets Access-Control headers
  • Compression — gzip responses above a size threshold
  • Rate limiting — sliding-window limiter with proper 429 responses
  • Basic auth — HTTP Basic with challenge headers
  • Logger — colored request/response timing

WebSocket: Upgrading the Protocol

The most interesting part was WebSocket support. The handshake is a standard HTTP request with magic headers:

def build_upgrade_response(request):
    ws_key = request.headers["Sec-WebSocket-Key"]
    # Concatenate with the magic GUID from RFC 6455
    combined = ws_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
    accept = base64.b64encode(hashlib.sha1(combined.encode()).digest())

    return (
        b"HTTP/1.1 101 Switching Protocols\r\n"
        b"Upgrade: websocket\r\n"
        b"Connection: Upgrade\r\n"
        b"Sec-WebSocket-Accept: " + accept + b"\r\n\r\n"
    )
Enter fullscreen mode Exit fullscreen mode

After the handshake, the connection switches from HTTP to WebSocket frames — a binary format with opcode, length, optional mask, and payload. Parsing these frames is surprisingly tricky because of variable-length encoding: payloads under 126 bytes use 1 byte for length, 126-65535 use 2 bytes, and larger use 8 bytes.

Response Serialization

The response builder mirrors the parser — turning a structured object back into HTTP bytes:

class Response:
    def serialize(self):
        status_line = f"HTTP/1.1 {self.status_code} {phrase}\r\n"
        header_lines = "".join(f"{k}: {v}\r\n" for k, v in self.headers.items())
        return (status_line + header_lines + "\r\n").encode() + self.body
Enter fullscreen mode Exit fullscreen mode

The chainable API makes handlers clean:

@app.post("/api/items")
def create_item(req, res):
    data = req.json()
    res.status(201).header("X-Custom", "value").json({"created": data})
Enter fullscreen mode Exit fullscreen mode

What I Learned

Building an HTTP server from scratch taught me things I never would have learned from using frameworks:

  1. HTTP is just text over TCP. The entire protocol is human-readable until you get to WebSocket frames.

  2. Keep-alive is why HTTP/1.1 matters. The difference between opening a new TCP connection per request vs. reusing one is massive for performance.

  3. Headers are case-insensitive. The spec says so, but most developers assume lowercase. A CaseInsensitiveDict solves this cleanly.

  4. Chunked transfer encoding exists because servers don't always know Content-Length upfront. Streaming responses need it.

  5. WebSocket masking is XOR. Client-to-server frames must be masked; server-to-client frames must not. The mask is 4 random bytes, and unmasking is just payload[i] ^= mask[i % 4].

The Result

ServeKit is ~4,500 lines of Python with 222 passing tests. It handles:

  • HTTP/1.1 with keep-alive
  • Routing with params and wildcards
  • 5 built-in middleware
  • Static file serving with caching
  • WebSocket support
  • Graceful shutdown

All from raw TCP sockets.

# Try it
git clone https://github.com/hajirufai/servekit
cd servekit
python examples/hello.py
# → http://localhost:8080
Enter fullscreen mode Exit fullscreen mode

GitHub: github.com/hajirufai/servekit
Live page: hajirufai.github.io/servekit

Top comments (0)