back to projects

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
examples/users.cpp C++17
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.

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.

requests a second, as the route table grows One four-core box, identical handlers on both sides, and Express run as four processes so it gets every core.
routes PlusWeb Express ratio
10 93,269 20,117 4.6×
1,000 92,298 6,169 15×
10,000 91,138 376 242×
and everything else, at four connections
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×

Express still holds more connections at once, because a worker here is tied up for as long as a client stays connected. That is the next piece of work — libuv underneath the accept loop, so that nobody using PlusWeb has to think about one. The branch already runs: 154,620 requests a second across 128 connections with nobody left waiting, against 8,024 for the Express cluster.

read the write-up

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.

main.cpp read-only for now
#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

the route table 6 routes
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.

Request
sample response · GET /users/123
  1. GET: , matched
  2. users , matched
  3. 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.