Roadrunner
Pure-Erlang HTTP/1.1 + HTTP/2 + WebSocket server for OTP 29+. Built for low tail latency at sustained load. Beep beep.
Roadrunner is the HTTP backbone of the arizona-framework. Strict RFC 9110 / 9112 / 9113 parsing, with strict 100 % h2spec (HTTP/2 conformance) and strict 100 % Autobahn fuzzingclient (WebSocket, no exclusions). The user-facing API is a handler behaviour, request/response accessors, listener controls, and a handful of opt-in helpers (cookies, qs, multipart, SSE, WebSocket). Modern OTP idioms throughout, with predictable per-connection lifecycle observability.
β οΈ Requirements
Requires OTP 29 or newer.
π§ Status
Roadrunner is in 0.x. The core is functional and covered by tests,
but the API may change between minor versions. Pin an exact version
in your deps (e.g. {roadrunner, "0.1.0"}) if you need stability
across upgrades.
β Conformance
Eunit + Common Test (incl. PropEr) suites with 100 % line coverage, dialyzer-clean, h2spec strict 100 %, Autobahn fuzzingclient strict 100 % across the full WebSocket matrix (no exclusions). HTTP/1.1 parsers stress-tested against the llhttp test corpus and the canonical PortSwigger request-smuggling vectors.
Standards conformance:
- HTTP/1.1: RFC 9110 (semantics) + RFC 9112 (syntax).
- HTTP/2: RFC 9113 (frames + multiplexing) + RFC 7541 (HPACK).
Opt-in per listener via
protocols => [http1, http2](or[http2]for h2c prior-knowledge on plain TCP). Conformance harness:scripts/h2spec.sh(drives h2spec). - Content-Encoding (RFC 9110 Β§8.4.1): gzip + deflate with
qvalue-aware
Accept-Encodingnegotiation (RFC 9110 Β§12.5.3), works unchanged over HTTP/2. - WebSocket: RFC 6455. Conformance harness:
scripts/autobahn.escript(drives the Autobahn|Testsuite fuzzingclient). - WebSocket compression: RFC 7692
permessage-deflate, including*_max_window_bitsand*_no_context_takeover.
Performance at a glance
Median req/s over HTTP/1.1 on a 12th-gen i9-12900HX, 50 clients,
5 s warmup + 5 s measure, loopback. HTTP/2 numbers, p50 / p99
percentiles, and memory shape sit in
docs/bench_results.md
and docs/comparison.md.
| scenario | roadrunner | cowboy | elli |
|---|---|---|---|
hello | 287 k | 189 k | 281 k |
json | 290 k | 194 k | 316 k |
echo | 284 k | 153 k | 294 k |
headers_heavy | 254 k | 143 k | 249 k |
large_response | 121 k | 95 k | 129 k |
multi_request_body | 271 k | 120 k | 275 k |
varied_paths_router | 292 k | 168 k | β |
post_4kb_form | 174 k | 95 k | β |
large_post_streaming | 19 k | 7.0 k | β |
pipelined_h1 | 572 k | 362 k | 4.8 k |
websocket_msg_throughput | 231 k | 171 k | β |
gzip_response | 137 k | 108 k | β |
Bold = fastest in row. β means the elli fixture doesn't expose
that workload (no router, no gzip middleware, no WebSocket, no
streaming-POST endpoint). On simple GETs and small POSTs
Roadrunner and elli are within the bench's ~15 % variance band on
those rows; the comparison doc has the full honest framing.
Tail latency at sustained load
Open-loop, Coordinated-Omission-corrected (wrk2, hello, 8 threads,
50 connections, 3-run median): Roadrunner sustains 270 k req/s
at p50 1.06 ms, p99 2.26 ms, p99.99 3.34 ms. Full per-scenario
matrix with all four rate-points per server in
docs/wrk2_results.md.
The throughput numbers above are from scripts/bench.escript
(closed-loop); the comparison doc has the full methodology
breakdown.
Comparison
If your workload needs a feature, the server has to ship it. β
means achievable in user code but no helper / option built in; β
means out of scope for that server.
| feature | roadrunner | cowboy | elli |
|---|---|---|---|
| HTTP/1.1 | β | β | β |
| HTTP/2 + HPACK | β | β | β |
| WebSocket (RFC 6455) | β | β | β |
| permessage-deflate (RFC 7692) | β | β | β |
| Native router | β | β | β |
| gzip / deflate response negotiation | β | β | β |
| Streaming request bodies | β | β | β |
| Native qs / cookie / multipart | β | β | β |
| Server-Sent Events helper | β | β | β |
| Sendfile | β | β | β |
| Static handler (ETag / Range / IMS) | β | β | β |
| Graceful drain with deadline + broadcast | β | β | β |
Per-request request_id in logger meta | β | β | β |
Quickstart
Add to rebar.config:
{deps, [
{roadrunner, "0.1.0"}
]}.
Write a handler β the third route element is per-route state, threaded
to the handler via roadrunner_req:state/1:
-module(hello_handler).
-behaviour(roadrunner_handler).
-export([handle/1]).
handle(Req) ->
#{greeting := Greeting} = roadrunner_req:state(Req),
{roadrunner_resp:text(200, <<Greeting/binary, ", roadrunner!">>), Req}.Boot a listener:
1> application:ensure_all_started(roadrunner).
2> roadrunner:start_listener(my_listener, #{
port => 8080,
routes => [{~"/", hello_handler, #{greeting => ~"hello"}}]
}).$ curl -i localhost:8080
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 18
hello, roadrunner!
For HTTP/2 over TLS, add a cert and list both protocols. ALPN is
derived from protocols automatically:
3> roadrunner:start_listener(my_tls_listener, #{
port => 8443,
protocols => [http1, http2],
tls => [
{certfile, "cert.pem"},
{keyfile, "key.pem"}
],
routes => [{~"/", hello_handler, #{greeting => ~"hello"}}]
}).
ALPN routes h2 clients to the HTTP/2 path and http/1.1 clients (or
no-ALPN) to the HTTP/1.1 path on the same listener. Drop http2 from
the list to disable HTTP/2. For HTTP/2 on plain TCP (h2c
prior-knowledge per RFC 7540 Β§3.4), use protocols => [http2] without
the tls opt.
For listeners that don't need routing, routes => Mod (or
{Mod, State} to seed handler state) skips the router entirely and
dispatches every request to Mod:handle/1:
roadrunner:start_listener(my_listener, #{
port => 8080,
routes => {hello_handler, #{greeting => ~"hello"}}
}).Configuration
All listener options live in the
roadrunner_listener:opts/0
type, with per-key defaults and tuning rationale. Beyond port,
protocols, tls, and routes from the Quickstart, the type covers:
- DoS bounds β
max_clients,max_content_length,request_timeout,keep_alive_timeout,min_bytes_per_second,max_keep_alive_requests - Middleware β
middlewares - Body buffering β
body_buffering - Graceful drain β
graceful_drain,slot_reconciliation - Per-conn hibernation β
hibernate_after - HTTP/2 tunables (under the
{http2, Opts}entry inprotocols) βconn_window,stream_window,window_refill_threshold
Features
Handlers
- Buffered responses:
{Status, Headers, Body}βroadrunner_resp:text/2,:html/2,:json/2,:redirect/2, plus empty-status shortcuts. - Streaming:
{stream, Status, Headers, Fun}β chunked transfer with aSend/2callback; supports trailer headers per RFC 7230 Β§4.1.2. - Loop / SSE:
{loop, Status, Headers, State}+ optionalhandle_info/3callback for message-driven push. - WebSocket:
{websocket, Module, State}upgrade withroadrunner_ws_handlercallback. - Sendfile:
{sendfile, Status, Headers, {Filename, Offset, Length}}β zero-copy file body viafile:sendfile/5(TCP) or chunkedssl:sendfallback (TLS).
Routing
roadrunner_routerwith literal /:param/*wildcardsegments.-
Routes published to
persistent_termfor O(1) lookup;roadrunner_listener:reload_routes/2swaps the table without restart.
Middleware
-
Continuation-style
(Req, Next) -> {Response, Req2}β listener-level + per-route, first-in-list = outermost.
Built-in handlers
roadrunner_staticfor file serving with ETag,If-None-Match,Range,Last-Modified,If-Modified-Since, and configurable symlink policy (refuse_escapesdefault).
Hardening
-
Strict RFC 9110 / 9112 parsing, with defenses grouped by subsystem:
- Request smuggling / framing: CL+TE conflict, multiple-CL, chunk-size leading-whitespace rejection.
- Header / control-frame injection: header CRLF / NUL rejection, SSE event-line CRLF rejection, trailer-header CRLF rejection, RFC 6455 Β§5.5 control-frame limits, RFC 6265 cookie OWS handling.
- Sendfile path safety: path traversal + symlink escape defenses.
-
TLS hardened defaults β TLS 1.2 / 1.3 only, AEAD-only cipher filter,
client renegotiation off, post-quantum hybrid
x25519mlkem768first when the OpenSSL build supports it. Full settings list in theroadrunner_listenermodule docs. -
DoS bounds β
max_clients,max_content_length,min_bytes_per_second,request_timeout,keep_alive_timeout,max_keep_alive_requests.
Observability
telemetryevents covering request, response, listener accept / close, slot reconciliation, ws upgrade and frames, and drain ack (opt-in viaroadrunner:acknowledge_drain/1). Full event list with measurements / metadata in theroadrunner_telemetrymodule docs.-
Per-request
request_idattached tologger:set_process_metadata/1so any?LOG_*from middleware/handlers is auto-correlated. roadrunner_listener:info/1for pull-sideactive_clients/requests_servedmetrics.proc_lib:set_label/1per-listener / per-acceptor / per-conn for legibleobserverprocess trees.
Lifecycle
roadrunner_listener:drain/2β graceful shutdown with timeout. Closes the listen socket, broadcasts{roadrunner_drain, Deadline}to in-flight conns viapg, polls until idle or deadline, thenexit(Pid, shutdown)for stragglers.roadrunner_listener:status/1βaccepting | draining.-
Optional
slot_reconciliation => #{interval => N}listener opt β a periodic reaper that comparesclient_counteragainst the connpggroup and releases slots orphaned bykill-style exits. Off by default; enable in production where you can't trust every exit path to runterminate/3(killsignals, OOM kills, supervisor brutal-kill).
Documentation
docs/comparison.mdβ full side-by-side benchmarks vs cowboy and elli (throughput, latency, architectural trade-offs, reproduction commands).docs/bench_results.mdβ full per-protocol matrix with p50 / p99 across every scenario.docs/bench_internals.mdβ loadgen worker model, latency aggregation, when the loader becomes the bottleneck.docs/wrk2_results.mdβ open-loop, Coordinated-Omission-corrected tail-latency tables (full per-scenario, all rate-points per server).docs/resource_results.mdβ memory + CPU shape per scenario.docs/conn_lifecycle_investigation.mdβ the connection-process model trade-offs and the one h2 case cowboy still wins.docs/roadmap.mdβ deferred items, with rough effort estimates for each.
Design philosophy
- RFC-correct, hostile-input-safe. Parsers are pure incremental
binary matchers; only programmer errors raise, wire input always
becomes
{error, _}. Malformed bytes are bounded by length and rejected before reaching application code. - Modern OTP idioms. Sigils for binary literals, body recursion (cons
on the way out), binary keys for wire-derived data,
-doc/-moduledocmarkdown, dialyzer-clean specs. Nobinary_to_atomon parsed names. - Continuation-style middleware.
(Req, Next) -> {Response, Req2}, composable at listener and per-route level. Outermost first. - Telemetry over custom callbacks.
telemetryis the de facto standard (Phoenix, Ecto, gleam_otp); zero-overhead when no subscribers, integrates with prometheus / opentelemetry / datadog out of the box. - No external deps unless stdlib genuinely can't. Only runtime dep
is
telemetry(tiny, no transitive deps); only dev-time dep is theerlfmtplugin.
Sponsors
Roadrunner is open source and maintained on personal time. If you or your company find it useful, consider sponsoring.
I also accept coffees β
<img src="https://raw.githubusercontent.com/williamthome/williamthome/sponsorkit/sponsors.svg" alt="Sponsors" />Contributing
Contributions are welcome! Please see CONTRIBUTING.md for development setup, testing guidelines, and contribution workflow.
Contributors
<img src="https://contrib.rocks/image?repo=arizona-framework/roadrunner&max=100&columns=10" width="15%" alt="Contributors" />Star History
<picture> <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=arizona-framework/roadrunner&type=Date&theme=dark" /> <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=arizona-framework/roadrunner&type=Date" /> <img src="https://api.star-history.com/svg?repos=arizona-framework/roadrunner&type=Date" alt="Star History Chart" width="100%" /> </picture>License
Copyright (c) 2026 William Fank ThomΓ©
Roadrunner is open-source under the Apache 2.0 License on GitHub.
See LICENSE.md for more information.