Pure-Erlang HTTP/1.1 + HTTP/2 + HTTP/3 + 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.
Requires OTP 29 or newer.
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 if you need stability across upgrades.
Strict 100 % h2spec (HTTP/2) and Autobahn fuzzingclient 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). - HTTP/3 (experimental): RFC 9114 over QUIC with QPACK (RFC 9204)
static-table compression. Enable per listener via
protocols => [http3](requirestls; QUIC mandates TLS 1.3); it co-serves with h1/h2 on the same port number (TCP for h1/h2, UDP for h3) and advertisesAlt-Svcso browsers upgrade. Built on the pure-Erlangquictransport (still 1.x), so treat HTTP/3 as experimental. - 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.
Median req/s over HTTP/1.1 on a 12th-gen i9-12900HX, 50 clients,
2 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 |
307 k | 201 k | 299 k |
json |
299 k | 189 k | 304 k |
echo |
304 k | 162 k | 282 k |
headers_heavy |
257 k | 141 k | 253 k |
large_response |
124 k | 98 k | 123 k |
multi_request_body |
262 k | 125 k | 274 k |
varied_paths_router |
290 k | 175 k | — |
post_4kb_form |
193 k | 98 k | — |
large_post_streaming |
20 k | 6.9 k | — |
pipelined_h1 |
580 k | 371 k | 4.8 k |
websocket_msg_throughput |
232 k | 179 k | — |
gzip_response |
138 k | 111 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.
Open-loop, Coordinated-Omission-corrected (wrk2, hello, 8 threads,
50 connections, 3-run median): Roadrunner sustains 291 k req/s
at p50 1.07 ms, p99 2.31 ms, p99.99 4.70 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.
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 | ✓ | ✓ | ✗ |
| HTTP/3 (QUIC, experimental) | ✓ | ✗ | ✗ |
| 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 |
✓ | — | ✗ |
Add to rebar.config:
{deps, [
{roadrunner, "0.2.2"}
]}.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 HTTP/3 (experimental), add http3 to a TLS listener's protocols
(e.g. protocols => [http1, http2, http3]). It serves h3 over UDP on the
same port number and advertises Alt-Svc so browsers upgrade from TCP;
the quic transport starts on demand, so h1/h2-only listeners never boot
it.
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"}}
}).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 - HTTP/3 tunables (under the
{http3, Opts}entry inprotocols) —listeners(reuseport pool size)
- 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).
roadrunner_routerwith literal /:param/*wildcardsegments.- Routes published to
persistent_termfor O(1) lookup;roadrunner_listener:reload_routes/2swaps the table without restart.
- Continuation-style
(Req, Next) -> {Response, Req2}— listener-level + per-route, first-in-list = outermost.
roadrunner_staticfor file serving with ETag,If-None-Match,Range,Last-Modified,If-Modified-Since, and configurable symlink policy (refuse_escapesdefault).
- 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.
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.
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).
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.
- 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.
Roadrunner is open source and maintained on personal time. If you or your company find it useful, consider sponsoring.
I also accept coffees ☕
Contributions are welcome! Please see CONTRIBUTING.md for development setup, testing guidelines, and contribution workflow.
Copyright (c) 2026 William Fank Thomé
Roadrunner is open-source under the Apache 2.0 License on GitHub.
See LICENSE.md for more information.

