PlusWeb — an Express-style HTTP framework in C++17 · Ahmed Sozzer

Ahmed Sozzer

4 min read Original article ↗

back to projects

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.

requests a second, as the route table grows Both servers single-threaded and pinned to one core of a four-core Intel i5-7600 box, identical routes, handlers and payloads. Same event loop, same parser, same I/O model on both sides.
routes PlusWeb Express ratio
5 91,950 19,236 4.8×
1,000 90,085 5,639 16×
10,000 89,636 355 253×
and everything else, at 32 connections
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.

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.

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