Roadrunner
View Source
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 ☕
Contributing
Contributions are welcome! Please see CONTRIBUTING.md for development setup, testing guidelines, and contribution workflow.
Contributors
Star History
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.
