An Express-style HTTP framework in C++17, on a libuv event loop and the llhttp parser.
Published in Microsoft’s vcpkg registry. One event loop handles every connection, requests resolve through a segment trie keyed on method and path, and no connection went unanswered at any concurrency tested, including the 512 where Express left 264 waiting.
- C++17
- libuv
- llhttp
- CMake
- MIT
examples/users.cpp C++17
HttpServer app(3000);
Router users;
users.GET("/new", newUserForm);
users.GET("/:id", showUser);
users.GET("/:id/posts", listPosts);
app.use("/users", users);
app.serve(); mounting rewrites the path, so the sub-router never knows where it was attached.
01 · what I wrote, and what I swapped out
An HTTP framework, from the router up.
Express hands you app.get(path, handler) and hides everything under it. I
wanted to know what stands between that call and a file descriptor, so the first version did
all of it: a blocking accept loop on POSIX sockets, a thread pool, and a parser that split
the request on line breaks. A year later I moved the loop onto libuv and the parsing onto
llhttp, the same two pieces Node is built on, because the point was understanding Express,
not owning a parser. What’s mine is where the design choices live: the segment trie
that routes, the middleware chain with next(), and the request and response
API.
- 4.8× Express on a 5-route app, the case where Express looks best and the number worth quoting
- 91.9k requests a second, flat from 5 routes to 10,000 — its router scans the table, mine walks a trie
- 6.5 MB resident with 1,000 routes loaded, against 97.7 MB for Express on the same box
- 55% of what a bare epoll server reaches here, while doing full parsing, a 1,000-route lookup and serialization — Express manages 3.9%
02 — against Express, on one box
Add routes to Express and it slows down. This one doesn’t.
Same machine, same handlers, the same load pointed at both. Go from five routes to ten thousand and Express falls from 19,000 requests a second to 355. This one barely moves — and it holds those routes in 6.5 MB of memory where the Express cluster needs 97.7 MB.
| routes | PlusWeb | Express | ratio |
|---|---|---|---|
| 5 | 91,950 | 19,236 | 4.8× |
| 1,000 | 90,085 | 5,639 | 16× |
| 10,000 | 89,636 | 355 | 253× |
| measure | PlusWeb | Express | ratio |
|---|---|---|---|
| p99 while it is serving you | 0.55 ms | 2.33 ms | 4.3× |
| memory, 1,000 routes | 6.5 MB | 97.7 MB | 15× |
Under load the difference stops being throughput and becomes reliability. At 512 concurrent connections Express left 264 of them with no response at all and its slowest request took 4,804.2 ms, against 20.67 ms here. PlusWeb starved no connections at any level tested.
Where it narrows: 100 KB response bodies bring it to 4.5×, no keep-alive bring it to 4.1×, 16 middleware bring it to 5.2×. And the framework is no longer the cost — at 55% of what a bare epoll server reaches on the same box, parsing and routing together are under a tenth of the CPU.
03 — run it
Write the routes. Send a request.
Write the handlers and the router rebuilds as you type. Underneath it is
src/trie.cpp itself, compiled for the browser — the method is the
first segment of the key, a literal child always beats a parameter, and nothing backtracks.
send a path
try , ,
the trie these build
- GET /health
- GET /users
- GET /users/new
- GET /users/:id
- POST /users
- DELETE /users/:id
The route lines are compiled by the real router; handler names are decoration.
Written up
- I could not understand Express, so I wrote my own
Middleware chaining made no sense to me, so I rebuilt it in C++. A year later the profile said the slowest thing was a map I rebuilt on every response.