radix-router
June 30, 2026 ยท View on GitHub
A high-performance path router for C++: map /-separated path patterns to a
value of any type, and look paths up in time that does not grow with the number
of routes.
#include "radix_router.h"
radix::Router<int> r;
r.insert("/documents/count", 1);
r.insert("/documents/:id", 2); // ":id" matches one segment
r.insert("/static/*path", 3); // "*path" matches the rest
radix::Params params;
const int* v = r.find("/documents/42", params); // v -> 2
params.get("id"); // "42" (a view, no copy)
Why a radix tree
The obvious first router scans every registered pattern on every request. That is
O(routes): add more endpoints and every lookup gets slower, and a miss has to
examine the whole table. A radix tree (a compressed prefix trie) instead walks
the shared prefixes of the path, so a lookup costs O(path length) and is flat in
the number of routes. It is the structure julienschmidt/httprouter popularized,
and what Gin, Echo and Fiber build on.
The benchmark (examples/bench.cc) pits the two against each other on the same
pattern language, across growing tables. The radix tree stays flat while the
linear scan climbs:
routes mix linear ns/op radix ns/op speedup
8 static-hit 43.8 11.3 3.9x
32 static-hit 191.1 16.3 11.7x
128 static-hit 692.9 19.3 35.9x
512 static-hit 2495.3 25.1 99.4x
1024 static-hit 5048.6 24.7 204.1x
1024 miss 10587.9 24.0 440.2x
(Apple M-series, -O3. The radix line moves from 11 to 24 ns only because of
cache and tree depth; the linear line tracks the route count.)
Patterns
A pattern is a sequence of /-separated segments:
- static, e.g.
/documents/count :nameparam matches exactly one segment, up to the next/*namecatch-all matches the remainder of the path, and is only allowed as the final segment
Captured :name / *name values are reported through Params at lookup time.
As in httprouter, a node holds either static children or a single wildcard child,
never both: registering /users/new and /users/:id at the same position is a
conflict and throws from insert() (a programming error, surfaced at setup, not
a per-lookup cost).
Zero-copy
Lookups allocate nothing on the hot path. Parameter names are views into the
registered pattern (stable for the router's lifetime); parameter values are
views into the path you pass to find(). Both are std::string_view, so reading
them copies nothing. The only allocation is Params growing its small vector,
which you can reuse across lookups (find() clears it for you).
Transport-agnostic
This library knows nothing about HTTP: it maps path strings to values. The HTTP
binding (per-method trees, an HttpHandler adapter, 404 vs 405) lives one layer
up, in Kronuz/http. Anything that routes paths
can use this directly.
Build
Header-only; drop in radix_router.h, or pull it in with FetchContent:
FetchContent_Declare(radix_router
GIT_REPOSITORY https://github.com/Kronuz/radix-router.git GIT_TAG main)
FetchContent_MakeAvailable(radix_router)
target_link_libraries(your_target PRIVATE radix_router::radix_router)
To run the tests and benchmark from a checkout:
cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build
ctest --test-dir build # correctness
./build/radix_router_bench # linear vs radix throughput