Pure-Erlang HTTP/1.1 + HTTP/2 + WebSocket server for OTP 29+. Beep beep.
Built ground-up via TDD as the HTTP backbone for the arizona-framework. 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). RFC-correct parsing, modern OTP idioms throughout, and predictable per-connection lifecycle observability.
Roadrunner requires OTP 29 (currently RC at the time of writing). Older OTPs won't compile, and the throughput numbers in the performance section assume 29.
Roadrunner is in 0.x. The core is functional and covered by tests,
but the API may change between minor versions. Pin an exact commit
ref in your deps (e.g. {ref, "<sha>"}) if you need stability
across upgrades.
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).
Standards conformance:
- HTTP/1.1: RFC 9110 (semantics) + RFC 9112 (syntax).
- HTTP/2: RFC 9113 (frames + multiplexing) + RFC 7541 (HPACK).
Opt-in per listener by listing
~"h2"in the TLSalpn_preferred_protocolsoption. 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.
Median req/s on a 12th-gen i9-12900HX, 50 clients, 5 s warmup + 5 s
measure, loopback. Full per-protocol grid + p50/p99 + memory shape
in docs/comparison.md.
| scenario | roadrunner | cowboy | elli |
|---|---|---|---|
hello |
298 k | 179 k | 278 k |
headers_heavy |
235 k | 118 k | 211 k |
cookies_heavy |
247 k | 154 k | — |
pipelined_h1 |
501 k | 329 k | 4.9 k |
gzip_response |
127 k | 100 k | — |
websocket_msg_throughput |
199 k | 155 k | — |
Bold = row winner. — means the elli fixture doesn't support that
workload shape (no router, no gzip middleware, no native cookie
parser, no WebSocket). On simple GETs (hello, json, echo)
Roadrunner's lead over elli is within the bench's ~15 % variance
band — the comparison doc has the full honest framing.
The numbers above are throughput from scripts/bench.escript
(closed-loop). For Coordinated-Omission-corrected tail latency at
sustained rates (open-loop, via wrk2), see
docs/wrk2_results.md and the
methodology section in docs/comparison.md.
Add to rebar.config:
{deps, [
{roadrunner, {git, "https://github.com/arizona-framework/roadrunner.git", {branch, "main"}}}
]}.Write a handler — the third route element is per-route opts, threaded
to the handler via roadrunner_req:route_opts/1:
-module(hello_handler).
-behaviour(roadrunner_handler).
-export([handle/1]).
handle(Req) ->
#{greeting := Greeting} = roadrunner_req:route_opts(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 put ~"h2" in the listener's
alpn_preferred_protocols:
3> roadrunner:start_listener(my_tls_listener, #{
port => 8443,
tls => [
{certfile, "cert.pem"},
{keyfile, "key.pem"},
{alpn_preferred_protocols, [~"h2", ~"http/1.1"]}
],
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. Omit ~"h2" from
the list to disable HTTP/2.
For listeners that don't need routing, handler => Mod skips the router
entirely and dispatches every request to Mod:handle/1:
roadrunner:start_listener(my_listener, #{port => 8080, handler => hello_handler}).- 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.- 3-tuple route shape
{Path, Handler, Opts}— opts thread to the handler. - 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 / RFC 9112 parsing — request smuggling defenses (CL+TE conflict, multiple-CL), header CRLF/NUL injection rejection, chunk-size leading-whitespace rejection, RFC 6265 cookie OWS handling, RFC 6455 §5.5 control-frame limits, SSE event-line CRLF rejection, trailer header CRLF injection rejection, sendfile path traversal + symlink escape defenses.
- TLS hardened defaults — TLS 1.2/1.3 only,
honor_cipher_order,client_renegotiationoff, AEAD-only ECDHE-or-1.3 ciphers filtered throughssl:filter_cipher_suites/2, OTP defaultsupported_groups(PQ-hybridx25519mlkem768first when the OpenSSL build supports it),early_datadisabled. - DoS bounds —
max_clients,max_content_length,minimum_bytes_per_second,request_timeout,keep_alive_timeout,max_keep_alive_request.
telemetryevents: requeststart | stop | exception | rejected,response, send_failed, listeneraccept | conn_close | slots_reconciled, wsupgrade | frame_in | frame_out,drain, acknowledged(opt-in viaroadrunner:acknowledge_drain/1).- 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_ms => 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).
- PropEr properties via
ct_property_test:roadrunner_uripercent round-trip + encode shape,roadrunner_qsround-trip,roadrunner_cookieadversarial robustness,roadrunner_http1parsers never-crash + incremental-feed equivalence, plusroadrunner_conn_looprobustness over random recv/drain/stray inputs (clean exit + slot release) andrequest_idconsistency betweenrequest_start/request_stoptelemetry. - Malformed-input corpus:
roadrunner_http1_corpus_testsexercises HTTP/1.1 patterns lifted from the llhttp test corpus and the canonical request-smuggling vectors documented by Portswigger. - Conformance harnesses:
scripts/h2spec.sh(HTTP/2),scripts/autobahn.escript(WebSocket),scripts/redbot.escript(HTTP/1.1 response hygiene).
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/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 over Plug.Conn-style transformation
— strictly more expressive than cowboy's deprecated
(Req, Env)shape and dramatically simpler than cowboy's stream handlers. - 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.
If you like Roadrunner, please consider sponsoring me. I'm thankful for your never-ending support ❤️
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.

