Learn Zig Series (#103) - Mini Project: Reverse Proxy - Routing
Learn Zig Series (#103) - Mini Project: Reverse Proxy - Routing
What will I learn?
- What a reverse proxy actually is, how it differs from the forward proxy your VPN uses, and why almost every production web stack has one sitting out front;
- How to build a routing table that maps an incoming request to the right backend, and why longest-prefix match is the rule that makes it predictable;
- How to parse just enough of an HTTP request to route it -- and no more -- because a proxy must never trust the bytes the whole internet can send it;
- How to isolate the routing decision as a pure function so you can unit-test every match without opening a single socket (episode 102's discipline, again);
- How to relay a request to a chosen upstream and stream the response straight back, plus the honest framing assumption that makes a simple relay correct;
- How to fail like a grown-up proxy -- a real
404when nothing matches, a real502when the backend dies -- in stead of dropping the socket and leaving the client guessing.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org);
- The little HTTP server we built back in episodes 51 through 54 -- we point this proxy AT it, so keep one running on a spare port;
- The ambition to learn Zig programming.
Difficulty
- Advanced
Curriculum (of the Learn Zig Series):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- Learn Zig Series (#5) - Arrays, Slices, and Strings
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- Learn Zig Series (#7) - Memory Management and Allocators
- Learn Zig Series (#8) - Pointers and Memory Layout
- Learn Zig Series (#9) - Comptime (Zig's Superpower)
- Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- Learn Zig Series (#12) - Testing and Test-Driven Development
- Learn Zig Series (#13) - Interfaces via Type Erasure
- Learn Zig Series (#14) - Generics with Comptime Parameters
- Learn Zig Series (#15) - The Build System (build.zig)
- Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- Learn Zig Series (#18b) - Addendum: Async Returns in Zig 0.16
- Learn Zig Series (#19) - SIMD with @Vector
- Learn Zig Series (#20) - Working with JSON
- Learn Zig Series (#21) - Networking and TCP Sockets
- Learn Zig Series (#22) - Hash Maps and Data Structures
- Learn Zig Series (#23) - Iterators and Lazy Evaluation
- Learn Zig Series (#24) - Logging, Formatting, and Debug Output
- Learn Zig Series (#25) - Mini Project: HTTP Status Checker
- Learn Zig Series (#26) - Writing a Custom Allocator
- Learn Zig Series (#27) - C Interop: Calling C from Zig
- Learn Zig Series (#28) - C Interop: Exposing Zig to C
- Learn Zig Series (#29) - Inline Assembly and Low-Level Control
- Learn Zig Series (#30) - Thread Safety and Atomics
- Learn Zig Series (#31) - Memory-Mapped I/O and Files
- Learn Zig Series (#32) - Compile-Time Reflection with @typeInfo
- Learn Zig Series (#33) - Building a State Machine with Tagged Unions
- Learn Zig Series (#34) - Performance Profiling and Optimization
- Learn Zig Series (#35) - Cross-Compilation and Target Triples
- Learn Zig Series (#36) - Mini Project: CLI Task Runner
- Learn Zig Series (#37) - Markdown to HTML: Tokenizer and Lexer
- Learn Zig Series (#38) - Markdown to HTML: Parser and AST
- Learn Zig Series (#39) - Markdown to HTML: Renderer and CLI
- Learn Zig Series (#40) - Key-Value Store: In-Memory Store
- Learn Zig Series (#41) - Key-Value Store: Write-Ahead Log
- Learn Zig Series (#42) - Key-Value Store: TCP Server
- Learn Zig Series (#43) - Key-Value Store: Client Library and Benchmarks
- Learn Zig Series (#44) - Image Tool: Reading and Writing PPM/BMP
- Learn Zig Series (#45) - Image Tool: Pixel Operations
- Learn Zig Series (#46) - Image Tool: CLI Pipeline
- Learn Zig Series (#47) - Build a Shell: Parsing Commands
- Learn Zig Series (#48) - Build a Shell: Process Spawning
- Learn Zig Series (#49) - Build a Shell: Built-in Commands
- Learn Zig Series (#50) - Build a Shell: Job Control and Signals
- Learn Zig Series (#51) - HTTP Server: Accept Loop and Parsing
- Learn Zig Series (#52) - HTTP Server: Router and Responses
- Learn Zig Series (#53) - HTTP Server: Static Files and MIME
- Learn Zig Series (#54) - HTTP Server: Middleware and Logging
- Learn Zig Series (#55) - ECS Game Engine: Architecture
- Learn Zig Series (#56) - ECS Game Engine: Component Storage
- Learn Zig Series (#57) - ECS Game Engine: Systems and Queries
- Learn Zig Series (#58) - ECS Game Engine: Terminal Rendering
- Learn Zig Series (#59) - Assembler: Instruction Encoding
- Learn Zig Series (#60) - Assembler: Two-Pass Assembly
- Learn Zig Series (#61) - Assembler: Disassembler and Binary Inspector
- Learn Zig Series (#62) - File Systems: Reading Directories and Metadata
- Learn Zig Series (#63) - File Watching: Detecting Changes
- Learn Zig Series (#64) - Process Management: Fork, Exec, Wait
- Learn Zig Series (#65) - Pipes and Inter-Process Communication
- Learn Zig Series (#66) - Shared Memory and Semaphores
- Learn Zig Series (#67) - Signal Handling Deep Dive
- Learn Zig Series (#68) - Unix Domain Sockets
- Learn Zig Series (#69) - Daemonization: Background Services
- Learn Zig Series (#70) - Timers and Scheduling
- Learn Zig Series (#71) - Resource Limits and Capabilities
- Learn Zig Series (#72) - System Call Wrappers
- Learn Zig Series (#73) - seccomp and Sandboxing
- Learn Zig Series (#74) - ptrace: Process Tracing
- Learn Zig Series (#75) - Reading Kernel State from /proc and /sys
- Learn Zig Series (#76) - Mini Project: Process Monitor
- Learn Zig Series (#77) - Mini Project: File Sync Tool - Part 1
- Learn Zig Series (#78) - Mini Project: File Sync Tool - Part 2: Delta Transfer
- Learn Zig Series (#79) - Mini Project: File Sync Tool - Part 3: Network Protocol
- Learn Zig Series (#80) - Mini Project: File Sync Tool - Part 4: Polish
- Learn Zig Series (#81) - UDP Sockets and Datagrams
- Learn Zig Series (#82) - DNS Resolver from Scratch
- Learn Zig Series (#83) - DNS Server Implementation
- Learn Zig Series (#84) - HTTP/1.1 Deep Dive
- Learn Zig Series (#85) - HTTP/2 Frames and Streams
- Learn Zig Series (#86) - TLS via C Interop
- Learn Zig Series (#87) - WebSocket Protocol
- Learn Zig Series (#88) - WebSocket Server
- Learn Zig Series (#89) - MQTT Messaging Protocol
- Learn Zig Series (#90) - Protocol Buffers Serialization
- Learn Zig Series (#91) - MessagePack Format
- Learn Zig Series (#92) - gRPC Service in Zig
- Learn Zig Series (#93) - SOCKS5 Proxy
- Learn Zig Series (#94) - NAT Traversal and Hole Punching
- Learn Zig Series (#95) - Mini Project: Chat Server - Protocol Design
- Learn Zig Series (#96) - Mini Project: Chat Server - Server Core
- Learn Zig Series (#97) - Mini Project: Chat Server - Client TUI
- Learn Zig Series (#98) - Mini Project: Chat Server - Rooms and History
- Learn Zig Series (#99) - Mini Project: DNS-over-HTTPS Proxy
- Learn Zig Series (#100) - Mini Project: Port Scanner
- Learn Zig Series (#101) - Mini Project: HTTP Load Tester - Part 1
- Learn Zig Series (#102) - Mini Project: HTTP Load Tester - Part 2
- Learn Zig Series (#103) - Mini Project: Reverse Proxy - Routing (this post)
Learn Zig Series (#103) - Mini Project: Reverse Proxy - Routing
Last episode I signed off pointing at data structures as our next stop, and we will get there. But I realised something looking back over the last stretch: we built an HTTP server across episodes 51 to 54, then in 101 and 102 we built a tool to hammer it with honest load -- and we never built the one piece of infrastructure that sits between a client and that server in literaly every production deployment on earth. The reverse proxy. So one more tool first, then we turn the lens inward. Having said that, roll up your sleeves.
A reverse proxy is deceptively humble in its job description: it accepts a client's HTTP request, decides which backend server should actually handle it, forwards the request there, and streams the reply back to the client. That's it. And yet that little middleman is where TLS termination happens, where load balancing lives, where rate limiting and caching and access control get bolted on -- it's the town square of modern web infrastructure. Today we build the first and most fundamental job it does: routing. Given a request, pick the right backend. We'll do load balancing and health checks in the parts that follow, but none of that means anything until the router knows where a request is supposed to go.
What a reverse proxy actually is (and isn't)
The word "proxy" gets overloaded, so let me draw the line sharply, because getting the direction backwards will confuse everything that follows. A forward proxy sits in front of clients -- your company's outbound proxy, a VPN exit node, the thing that hides which of a thousand employees is fetching a given URL. The server on the far side has no idea who the real client is; the proxy speaks for the clients. A reverse proxy sits in front of servers. The client thinks it's talking to one machine (api.example.com), but behind that single address the proxy is quietly fanning requests out to a fleet of backends. The client has no idea how many servers there are, or which one served it. The proxy speaks for the servers.
We built a SOCKS5 forward proxy back in episode 93, so you've seen the other direction. The mechanics rhyme -- accept a connection, open a second one, shovel bytes between them -- but the decision is completely different. SOCKS5 asks the client where it wants to go. A reverse proxy decides that itself, based on rules the operator sets, because the whole point is that the client doesn't get a vote. That decision is what we call routing, and it's usually driven by two pieces of the request: the Host header (which domain?) and the path (which part of that domain?). For this part we route on path, which is the more common and more interesting case -- /api/* goes to the application servers, /static/* goes to the asset servers, everything else to the marketing site. One public address, three very different backends.
The routing table: matching a request to a backend
Let's name the shape of the problem before writing a line of logic (episode 6 discipline -- get the types right and the code writes itself). A route is a rule: a path prefix, and the address of the backend that should serve anything under it. The router is just a collection of those rules plus the algorithm that picks one.
const std = @import("std");
/// One routing rule: any request whose path STARTS WITH `prefix` is sent to the
/// backend at `upstream`. That's it -- a prefix and a destination. The strings are
/// borrowed from config that outlives us, so a Route is a plain copyable value with
/// no allocator and no deinit (episode 5's slice-as-a-view thinking, applied to URLs).
const Route = struct {
prefix: []const u8,
upstream: std.net.Address,
};
/// The routing table. It owns nothing but a slice of rules, which keeps it a value
/// you can pass and copy freely. The whole intelligence of the proxy's first job
/// lives in `match` -- everything else is just plumbing around this one decision.
const Router = struct {
routes: []const Route,
/// Return the upstream for `path` by LONGEST-prefix match, or null if nothing
/// matches. Pure function of its inputs -- no I/O, no globals, no allocation --
/// so it's the piece we hammer with unit tests (exactly like ep102's frameResponse).
fn match(self: Router, path: []const u8) ?std.net.Address {
var best: ?std.net.Address = null;
var best_len: usize = 0;
for (self.routes) |r| {
// A rule applies if the path starts with its prefix. Among all applicable
// rules, the LONGEST prefix wins -- that's what lets "/api/v2" override a
// broader "/api" without depending on the order rules were declared in.
if (std.mem.startsWith(u8, path, r.prefix) and r.prefix.len >= best_len) {
best = r.upstream;
best_len = r.prefix.len;
}
}
return best;
}
};
Why longest-prefix and not first-match? Because first-match makes your routing table order-dependent, and order-dependent config is a bug generator. If /api is listed before /api/v2, first-match sends everything under /api/v2 to the wrong place, and the failure is invisible until someone reorders the list. Longest-prefix match is declaration-order-independent: the most specific rule always wins, no matter where you wrote it. This is the same instinct routers have used since the dawn of IP -- the most specific route in the table is the one that governs. Nota bene: if you've ever stared at a routing table wondering why traffic went somewhere unexpected, "most specific wins" is the rule to hold in your head.
Parsing just enough of the request to route it
To route on path, we need the path, which means reading the request line -- that first GET /api/users HTTP/1.1 line we dissected in depth back in episode 84. But here's the crucial mindset shift for a proxy: we parse the minimum we need to make a decision, and we forward everything else untouched. We are not rewriting the request (yet). We are not validating headers. We pull the path out to route on it, and the rest of those bytes get relayed to the backend exactly as they arrived. Less parsing means fewer bugs, and -- more importantly -- fewer ways for a maliciously crafted request to trip us up, because a reverse proxy is exposed to the entire internet by definition.
/// The only two fields routing needs off the request line: the method (handy for
/// logging) and the path (what we actually match on). We ignore the HTTP version
/// and every header -- those get forwarded verbatim, not inspected.
const RequestHead = struct {
method: []const u8,
path: []const u8,
};
/// Pull method and path off the first line of an HTTP request. Returns null if the
/// line is malformed -- and a proxy MUST treat malformed input as routine, not as an
/// exception, because the whole internet can reach it and a fair slice of that traffic
/// is scanners, bots, and garbage. Never trust the bytes; validate, then decide.
fn parseRequestLine(buf: []const u8) ?RequestHead {
const line_end = std.mem.indexOf(u8, buf, "\r\n") orelse return null;
const line = buf[0..line_end];
const sp1 = std.mem.indexOfScalar(u8, line, ' ') orelse return null;
const rest = line[sp1 + 1 ..];
const sp2 = std.mem.indexOfScalar(u8, rest, ' ') orelse return null;
return .{ .method = line[0..sp1], .path = rest[0..sp2] };
}
Notice how the optional return type does the heavy lifting. ?RequestHead forces the caller to confront the "this wasn't a valid request" case at the call site -- there is no path through the code where you accidentally route a garbage request because you forgot to check. In a language without this, that missing check is the classic proxy vulnerability: a half-parsed request line that reads past its bounds, or a null path that matches the wrong rule. Zig makes forgetting it a compile-time impossibility, which is exactly the property you want in code that faces hostile input all day.
We also need to read the full request head off the socket before we can parse it, because a single read() almost never hands you a complete request -- that was the whole painful lesson of episode 102, seen now from the server side of the wire.
/// Read from `stream` until we have the complete request head (through the blank
/// line `\r\n\r\n` that terminates the headers), or the buffer fills. We only need
/// the HEAD to route; the body, if any, is a separate concern handled downstream.
fn readHead(stream: std.net.Stream, buf: []u8) !usize {
var total: usize = 0;
while (total < buf.len) {
const n = try stream.read(buf[total..]);
if (n == 0) break; // client hung up before finishing -- caller decides what that means
total += n;
// The headers are complete the moment we see the blank line. Stop there --
// don't wait for more, don't read into the body we're not parsing.
if (std.mem.indexOf(u8, buf[0..total], "\r\n\r\n") != null) break;
}
return total;
}
The router as a pure, testable function
Here's the payoff of keeping match and parseRequestLine free of I/O: we can test the entire routing brain without opening a socket, spawning a backend, or touching the network at all. Deterministic input, deterministic output, runs in microseconds. This is the same move that made episode 102's framing logic bulletproof, and it's worth internalising as a reflex -- the bug-prone decisions get isolated into pure functions, and the pure functions get pounded with tests until they beg for mercy.
test "router picks the longest matching prefix, order-independently" {
const a = std.net.Address.parseIp("127.0.0.1", 1) catch unreachable;
const b = std.net.Address.parseIp("127.0.0.1", 2) catch unreachable;
const c = std.net.Address.parseIp("127.0.0.1", 3) catch unreachable;
// Deliberately declared broad-to-narrow AND out of order, to prove that
// longest-prefix match does not care what order we listed the rules in.
const routes = [_]Route{
.{ .prefix = "/", .upstream = a },
.{ .prefix = "/api/v2", .upstream = c },
.{ .prefix = "/api", .upstream = b },
};
const router = Router{ .routes = &routes };
// "/api/v2/users" matches all three; the longest (/api/v2) must win.
try std.testing.expectEqual(c.getPort(), router.match("/api/v2/users").?.getPort());
// "/api/users" matches "/" and "/api" -- "/api" is the longer of the two.
try std.testing.expectEqual(b.getPort(), router.match("/api/users").?.getPort());
// "/index.html" matches only the catch-all "/".
try std.testing.expectEqual(a.getPort(), router.match("/index.html").?.getPort());
}
test "parseRequestLine extracts method and path, rejects garbage" {
const r = parseRequestLine("GET /api/users?id=7 HTTP/1.1\r\nHost: x\r\n\r\n").?;
try std.testing.expectEqualStrings("GET", r.method);
// The query string rides along in the path -- we route on it as-is, we don't parse it.
try std.testing.expectEqualStrings("/api/users?id=7", r.path);
// A line with no spaces is not a request line. Reject it, don't misroute it.
try std.testing.expect(parseRequestLine("garbage-with-no-spaces\r\n") == null);
}
That order-independence test is the one I'd fight to keep in code review. It encodes the reason we chose longest-prefix in the first place, so that if some well-meaning future refactor quietly turns it back into first-match, a red test explains exactly what invariant just broke and why anyone cared.
Forwarding: dialing the upstream and relaying the response
Routing decides where. Forwarding actually does it: open a connection to the chosen backend (episode 21's tcpConnectToAddress, our old friend), send it the client's request, and pump every byte of the reply straight back to the client. The relay itself is gloriously dumb -- and that dumbness is a feature. The proxy does not need to understand the response. It doesn't care if it's a 200 or a 500, JSON or a JPEG. Routing's job ended the moment bytes started flowing; from here it's honest byte-copying.
/// Relay one exchange: connect to `upstream`, forward the client's request head,
/// then stream every byte the backend returns straight back to the client until the
/// backend closes. Dumb on purpose -- we don't parse the response, we just move it.
fn relay(upstream: std.net.Address, head: []const u8, client: std.net.Stream, scratch: []u8) !void {
const up = try std.net.tcpConnectToAddress(upstream);
defer up.close();
// Forward the request head verbatim. Same method, same path, same headers -- the
// backend sees essentially what the client sent. (A production proxy tweaks a few
// headers here; see the note on Host and X-Forwarded-For further down.)
try up.writeAll(head);
// Stream the reply back. Reading to EOF is CORRECT here because our backends are
// the HTTP servers we built in episodes 51-54, which send `Connection: close` and
// hang up when the response is done. That EOF is our clean end-of-response signal.
// Front a keep-alive backend and this must become ep102's frameResponse instead --
// flagged honestly below rather than hidden.
while (true) {
const n = try up.read(scratch);
if (n == 0) break; // backend closed -- the response is complete
try client.writeAll(scratch[0..n]);
}
}
I want to be dead honest about that read-to-EOF, because it's the one place this Part 1 leans on a simplifying assumption. It is correct against the backends we built in this series, which close the socket after each response. It is NOT correct against a backend that keeps the connection alive -- there, EOF never arrives between responses, and this loop would hang forever waiting for a close that isn't coming. The fix is exactly the machinery we wrote last episode: frame the response with Content-Length or chunked decoding, and stop when one complete response has been relayed. I'm deliberately not dragging all of that back in today, because the routing story is what this episode is about, and piling framing on top would bury it. Knowing precisely where your simplification lives is worth more than pretending it isn't there.
When routing fails: 404 and 502
A proxy that drops the socket when something goes wrong is a proxy that's impossible to debug, and infuriating to sit behind. There are two distinct failures, and they map onto two specific HTTP status codes that exist for exactly this situation. If no route matches, that's a 404 Not Found -- the proxy is telling the client "there's nothing here to reach". If a route matches but the backend refuses the connection or dies mid-response, that's a 502 Bad Gateway -- the canonical "I am a gateway and my upstream failed me" status (we met the whole 5xx family in episode 84). Getting these two right is the difference between a proxy you can operate and one that generates support tickets.
/// Write a minimal but correct HTTP/1.1 error response ourselves. Content-Length is
/// mandatory so the client knows exactly where the body ends -- that's episode 102's
/// framing lesson seen from the server's chair: omit the length and a well-behaved
/// client sits there waiting for bytes that never come.
fn sendError(client: std.net.Stream, code: u16, reason: []const u8) void {
var buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&buf,
"HTTP/1.1 {d} {s}\r\n" ++
"Content-Length: {d}\r\n" ++
"Connection: close\r\n\r\n" ++
"{s}",
.{ code, reason, reason.len, reason },
) catch return;
client.writeAll(msg) catch {};
}
/// Handle ONE client connection start to finish: read its head, decide where it goes,
/// and either relay it to the chosen backend or answer with our OWN error. Every
/// failure path still sends the client a valid HTTP response -- a proxy that stays
/// silent on error is a proxy nobody can diagnose.
fn handleClient(router: Router, client: std.net.Stream, buf: []u8, scratch: []u8) void {
defer client.close();
const head_len = readHead(client, buf) catch return;
if (head_len == 0) return; // empty connection, nothing to do
const head = buf[0..head_len];
const req = parseRequestLine(head) orelse {
sendError(client, 400, "Bad Request");
return;
};
const upstream = router.match(req.path) orelse {
std.log.info("no route for {s} {s} -> 404", .{ req.method, req.path });
sendError(client, 404, "Not Found");
return;
};
relay(upstream, head, client, scratch) catch {
// The backend refused us or died partway through. That's a 502 -- the client
// did nothing wrong, the infrastructure behind us did.
std.log.warn("upstream failed for {s} {s} -> 502", .{ req.method, req.path });
sendError(client, 502, "Bad Gateway");
};
}
For that 404 branch to actually fire, your routing table must not contain a catch-all / rule -- because / is a prefix of every path, so a table with it never fails to match. That's a real config decision, not an accident: a proxy fronting a known set of services often wants the strictness of "if it doesn't match a declared route, reject it", so a stray /wp-admin probe from some bot gets a clean 404 in stead of being forwarded to a backend that might actually do something with it. Whether you include the catch-all is a policy choice, and it's nice that the code makes that choice explicit rather than baking it in.
Wiring the accept loop
Now the outermost layer -- listen, accept, dispatch. I'm keeping it deliberately serial for Part 1: one connection handled to completion before the next is accepted. That's not how you'd ship it (a single slow backend would stall every other client), but it lets the routing logic stand naked without a thread pool crowding the frame. Concurrency is a known, flagged next step, and we have all the pieces for it already from episodes 30 and 64.
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const alloc = gpa.allocator();
// The routing table. In a real tool this comes from a config file -- and episode
// 20's JSON parsing drops straight in here to build this exact slice. Hard-coded
// for the walkthrough. Note the absence of a "/" catch-all: unmatched paths 404.
const routes = [_]Route{
.{ .prefix = "/api", .upstream = try std.net.Address.parseIp("127.0.0.1", 9001) },
.{ .prefix = "/static", .upstream = try std.net.Address.parseIp("127.0.0.1", 9002) },
};
const router = Router{ .routes = &routes };
const listen_addr = try std.net.Address.parseIp("127.0.0.1", 8080);
var server = try listen_addr.listen(.{ .reuse_address = true });
defer server.deinit();
std.log.info("reverse proxy listening on {any}", .{listen_addr});
// Two big scratch buffers, reused for every connection -- one for the request
// head we route on, one for shovelling the response back. Serial handling is what
// lets us get away with reusing them; the concurrent version needs one pair per worker.
const buf = try alloc.alloc(u8, 64 * 1024);
defer alloc.free(buf);
const scratch = try alloc.alloc(u8, 64 * 1024);
defer alloc.free(scratch);
while (true) {
const conn = server.accept() catch |err| {
std.log.warn("accept failed: {s}", .{@errorName(err)});
continue; // one bad accept must never kill the whole proxy
};
handleClient(router, conn.stream, buf, scratch);
}
}
Fire up two of our episode 51-54 servers on ports 9001 and 9002, point a browser or curl at the proxy on 8080, and watch requests split by path:
$ curl -s localhost:8080/api/users # -> backend on 9001
$ curl -s localhost:8080/static/logo.css # -> backend on 9002
$ curl -si localhost:8080/nope | head -1
HTTP/1.1 404 Not Found
# and with 9001 stopped, to see the gateway error:
$ curl -si localhost:8080/api/users | head -1
HTTP/1.1 502 Bad Gateway
One thing a production proxy does that ours (honestly) skips: it rewrites a couple of headers on the way to the backend. It sets X-Forwarded-For so the backend can see the real client IP (which is otherwise hidden -- the backend only sees the proxy), and it often adjusts the Host header to what the backend expects. Both are small, surgical edits to the head buffer before we forward it, and both are easy to add now that the routing skeleton stands. I'm leaving them as a deliberate exercise for your own tinkering, because the moment you rewrite headers you inherit a pile of subtle rules about which headers a proxy may and may not touch (the "hop-by-hop" headers from the HTTP spec), and that's a rabbit hole for another day.
Performance and design considerations
The routing decision itself is dirt cheap -- a linear scan over a handful of prefixes, a few startsWith comparisons per request. For the tiny tables real proxies use (a few dozen routes at most), linear scan beats anything cleverer, because the constant factors of a hash map or a trie don't pay off until you have hundreds of entries and the branch predictor is already handling the small case beautifully. This is the recurring lesson of episode 34: measure before you optimise, and respect that a simple loop over a short list is often the fastest thing you can do. If you genuinely had thousands of routes you'd reach for a prefix trie (which, funnily enough, is one of the very data structures coming up in this series), but reaching for it now would be seperate-a-solution-looking-for-a-problem engineering.
The real performance story of a reverse proxy isn't the routing -- it's the relaying, and specifically how many connections you can juggle at once. Our serial loop tops out at one client at a time, which for a learning build is exactly right and for anything real is a non-starter. The natural next move is a thread per connection (episode 64) or, better at scale, an event loop multiplexing many connections on a few threads with poll/epoll. And here Zig's explicitness pays a quiet dividend: because every buffer is one we allocated on purpose, scaling to concurrency is a mechanical "give each worker its own pair of buffers" change, not a hunt through hidden per-request allocations. You can see the memory, so you can reason about the scaling. That's the same property that let episode 102's load tester bound its file-descriptor use by worker count and dodge the ProcessFdQuotaExceeded wall.
How this compares to C, Rust, and Go
In C, this is essentially what the guts of nginx and HAProxy look like -- prefix matching over a location table, connect() to an upstream, splice() or a read/write loop to shovel bytes. What Zig bought us for free is the ?RequestHead optional forcing the malformed-request check at the call site; in C, the missing null-check on a half-parsed request is the exact shape of bug that becomes a CVE, because the code faces hostile input by design. The relay loop ports almost character-for-character, but the safety net around the parsing is where Zig quietly earns its keep in code that lives on the open internet.
In Rust, you'd build this on tokio and hyper, or reach straight for the tower ecosystem where routing is a first-class abstraction (tower::Service and friends). You'd get async connection handling for free, real HTTP framing for free, and the borrow checker guaranteeing your per-connection buffers don't alias -- the same non-aliasing property we got by giving each connection its own buffer, just enforced by the compiler in stead of by discipline. The tradeoff is the usual one: a deep, powerful dependency tree between your code and the socket, versus our from-scratch version where you can point at the precise read and know exactly what it does.
In Go, this is almost embarrassingly short, because the standard library ships net/http/httputil.ReverseProxy -- a production-grade reverse proxy in the box, X-Forwarded-For handling and all. A dozen lines gives you what took us an episode. That's not a knock on our build; it's the whole point of building it. Once you've written the router, the relay, and the two error paths by hand, httputil.ReverseProxy stops being magic -- you know precisely what it's doing with your Host header and why it frames responses the way it does. For shipping a proxy tomorrow, reach for the batteries Go includes. For understanding every proxy you'll ever sit behind, build it once from the sockets up ;-)
Where this is heading
Look at how little of today was actually new. The upstream connection is episode 21's socket. The request parsing is episode 84's HTTP rules, trimmed to the bone. The pure-function-plus-tests rhythm is episode 102's, and the buffer discipline is the same explicit-memory habit we've leaned on since episode 7. The genuinely new idea was small and portable: longest-prefix match, the rule that makes routing predictable and order-independent, which you'll now recognise in IP routing tables, CDN configs, and every API gateway you ever touch.
But a proxy that routes to exactly one backend per prefix is only half a proxy. The real reason this pattern conquered the web is that a single route can point at many identical backends, and the proxy gets to choose which one handles each request -- spreading load, surviving a dead machine, draining a box for maintenance without anyone noticing. That choosing is where a reverse proxy stops being a glorified switchboard and starts being the thing that keeps a service up. Our router currently holds one address per route; next we teach it to hold a pool, and to pick from that pool intelligently. The switchboard is built -- now we make it smart.
Thanks for reading, and see you in the next one!
Leave Learn Zig Series (#103) - Mini Project: Reverse Proxy - Routing to:
Read more #stem posts
Best Posts From scipio
We have not curated any of scipio's posts yet. But you can encourage our curation team to review posts by visiting them regularly and by referring other readers. Because we give priority to frequently read content.
More Posts From scipio
- Learn Zig Series (#105) - Mini Project: Reverse Proxy - Health Checks
- Learn Ethical Hacking (#83) - Full Red Team Simulation - External to Domain Admin
- Learn AI Series (#123) - Monitoring ML in Production
- Learn Zig Series (#104) - Mini Project: Reverse Proxy - Load Balancing
- Learn Ethical Hacking (#82) - Building a Security Operations Center (SOC)
- Learn AI Series (#122) - Edge AI: Running Models on Devices
- Learn Zig Series (#103) - Mini Project: Reverse Proxy - Routing
- Learn Ethical Hacking (#81) - Zero Trust Implementation - Beyond the Buzzword
- Learn AI Series (#121) - Model Serving Architecture
- Learn Zig Series (#102) - Mini Project: HTTP Load Tester - Part 2