PlusWeb
An Express-style HTTP framework written on raw POSIX sockets, in C++17.
One acceptor thread feeds a bounded pool of 8 workers, connections stay open for keep-alive, and requests resolve through a segment trie keyed on method and path.
- C++17
- POSIX sockets
- CMake
- GoogleTest
- MIT
pw::App app;
pw::Router users;
users.GET("/new", newUserForm);
users.GET("/:id", showUser);
users.GET("/:id/posts", listPosts);
app.mount("/users", users);
app.listen(8080); mounting rewrites the path, so the sub-router never knows where it was attached.
MIT — take it
01 — the parts that are mine
An HTTP framework that starts at the socket.
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 I wrote the whole
path: accept, parse, route, run the middleware chain, serialize, write, and hold the
connection open for the next request.
- 242× faster than Express at 10,000 routes — its router scans the table, mine walks a trie
- 93.6k requests a second on a four-core box, steady however many routes are loaded
- 5.3 MB resident with 1,000 routes loaded — the Express cluster on the same box: 573 MB
- 154k requests a second across 128 connections, nobody starved — on the libuv branch, not main
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 ten routes to ten thousand and Express falls from 20,000 requests a second to 376. This one barely moves — and it holds those routes in 5.3 MB of memory where the Express cluster needs 573 MB.
| routes | PlusWeb | Express | ratio |
|---|---|---|---|
| 10 | 93,269 | 20,117 | 4.6× |
| 1,000 | 92,298 | 6,169 | 15× |
| 10,000 | 91,138 | 376 | 242× |
| measure | PlusWeb | Express | ratio |
|---|---|---|---|
| p99 while it is serving you | 0.10 ms | 2.19 ms | 20× |
| memory, 1,000 routes | 5.3 MB | 573 MB | 108× |
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.
coming soon This is a drawing of the playground. The live one will run the real router in your browser; until then nothing here can be edited or sent.
#include <plusweb/App.hpp>
pw::App app;app.get("/health", [](Request& req, Response& res) {
res.json({ "status": "ok", "uptime_s": 41207 });
});
app.get("/users", [](Request& req, Response& res) {
res.json({ "users": ["123", "124"], "total": 2 });
});
app.get("/users/new", [](Request& req, Response& res) {
res.json({ "form": "create-user" });
});
app.get("/users/:id", [](Request& req, Response& res) {
res.json({ "id": req.param("id"), "name": "Ahmed" });
});
app.post("/users", [](Request& req, Response& res) {
res.status(201).json({ "created": true });
});
app.del("/users/:id", [](Request& req, Response& res) {
res.status(204);
});app.listen(8080); // 1 acceptor, 8 workers 6 routes compiled
| method | path | status |
|---|---|---|
| GET | /health | 200 |
| GET | /users | 200 |
| GET | /users/new | 200 |
| GET | /users/:id | 200 |
| POST | /users | 201 |
| DELETE | /users/:id | 204 |
Derived from the handlers. When the playground is live it rebuilds on every keystroke, and a click on a route sends it.
- GET: , matched
- users , matched
- 123 :id , bound as a parameter
200 OK matched GET /users/:id
{
"id": "123",
"name": "Ahmed"
} no literal child matched that segment, so the router scanned this node’s children for a parameter and bound it. that scan is O(children) — invisible here, fatal at 10,000 routes.