# Roadrunner
[](https://github.com/arizona-framework/roadrunner/actions/workflows/erlang.yml)
[](https://hex.pm/packages/roadrunner)
[](https://hexdocs.pm/roadrunner/)
[](https://github.com/arizona-framework/roadrunner/blob/main/LICENSE.md)

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](https://github.com/arizona-framework/arizona).
Strict RFC 9110 / 9112 / 9113 parsing, with **strict 100 %
[h2spec](https://github.com/summerwind/h2spec)** (HTTP/2 conformance)
and **strict 100 %
[Autobahn fuzzingclient](https://github.com/crossbario/autobahn-testsuite)**
(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](https://github.com/nodejs/llhttp) test corpus and the
canonical [PortSwigger](https://portswigger.net/web-security/request-smuggling)
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`](https://github.com/arizona-framework/roadrunner/blob/main/scripts/h2spec.sh) (drives
[h2spec](https://github.com/summerwind/h2spec)).
- **Content-Encoding** (RFC 9110 Β§8.4.1): gzip + deflate with
qvalue-aware `Accept-Encoding` negotiation (RFC 9110 Β§12.5.3),
works unchanged over HTTP/2.
- **WebSocket**: RFC 6455. Conformance harness:
[`scripts/autobahn.escript`](https://github.com/arizona-framework/roadrunner/blob/main/scripts/autobahn.escript) (drives the
[Autobahn|Testsuite](https://github.com/crossbario/autobahn-testsuite)
fuzzingclient).
- **WebSocket compression**: RFC 7692 `permessage-deflate`,
including `*_max_window_bits` and `*_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`](https://github.com/arizona-framework/roadrunner/blob/main/docs/bench_results.md)
and [`docs/comparison.md`](https://github.com/arizona-framework/roadrunner/blob/main/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`](https://github.com/arizona-framework/roadrunner/blob/main/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`:
```erlang
{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`:
```erlang
-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:
```erlang
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:
```erlang
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`:
```erlang
roadrunner:start_listener(my_listener, #{
port => 8080,
routes => {hello_handler, #{greeting => ~"hello"}}
}).
```
## Configuration
All listener options live in the
[`roadrunner_listener:opts/0`](https://hexdocs.pm/roadrunner/roadrunner_listener.html#t: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 in
`protocols`) β `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 a
`Send/2` callback; supports trailer headers per RFC 7230 Β§4.1.2.
- **Loop / SSE:** `{loop, Status, Headers, State}` + optional
`handle_info/3` callback for message-driven push.
- **WebSocket:** `{websocket, Module, State}` upgrade with
`roadrunner_ws_handler` callback.
- **Sendfile:** `{sendfile, Status, Headers, {Filename, Offset, Length}}` β
zero-copy file body via `file:sendfile/5` (TCP) or chunked `ssl:send`
fallback (TLS).
### Routing
- `roadrunner_router` with literal / `:param` / `*wildcard` segments.
- Routes published to `persistent_term` for O(1) lookup;
`roadrunner_listener:reload_routes/2` swaps the table without restart.
### Middleware
- Continuation-style `(Req, Next) -> {Response, Req2}` β listener-level +
per-route, first-in-list = outermost.
### Built-in handlers
- `roadrunner_static` for file serving with ETag, `If-None-Match`, `Range`,
`Last-Modified`, `If-Modified-Since`, and configurable symlink policy
(`refuse_escapes` default).
### 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 `x25519mlkem768` first
when the OpenSSL build supports it. Full settings list in the
`roadrunner_listener` module docs.
- DoS bounds β `max_clients`, `max_content_length`,
`min_bytes_per_second`, `request_timeout`, `keep_alive_timeout`,
`max_keep_alive_requests`.
### Observability
- `telemetry` events covering request, response, listener
accept / close, slot reconciliation, ws upgrade and frames, and
drain ack (opt-in via `roadrunner:acknowledge_drain/1`). Full event
list with measurements / metadata in the `roadrunner_telemetry`
module docs.
- Per-request `request_id` attached to `logger:set_process_metadata/1`
so any `?LOG_*` from middleware/handlers is auto-correlated.
- `roadrunner_listener:info/1` for pull-side `active_clients` /
`requests_served` metrics.
- `proc_lib:set_label/1` per-listener / per-acceptor / per-conn for
legible `observer` process trees.
### Lifecycle
- `roadrunner_listener:drain/2` β graceful shutdown with timeout. Closes
the listen socket, broadcasts `{roadrunner_drain, Deadline}` to in-flight
conns via `pg`, polls until idle or deadline, then `exit(Pid, shutdown)`
for stragglers.
- `roadrunner_listener:status/1` β `accepting | draining`.
- Optional `slot_reconciliation => #{interval => N}` listener opt β a
periodic reaper that compares `client_counter` against the conn `pg`
group and releases slots orphaned by `kill`-style exits. Off by default;
enable in production where you can't trust every exit path to run
`terminate/3` (`kill` signals, OOM kills, supervisor brutal-kill).
## Documentation
- [`docs/comparison.md`](https://github.com/arizona-framework/roadrunner/blob/main/docs/comparison.md) β full side-by-side
benchmarks vs cowboy and elli (throughput, latency, architectural
trade-offs, reproduction commands).
- [`docs/bench_results.md`](https://github.com/arizona-framework/roadrunner/blob/main/docs/bench_results.md) β full per-protocol
matrix with p50 / p99 across every scenario.
- [`docs/bench_internals.md`](https://github.com/arizona-framework/roadrunner/blob/main/docs/bench_internals.md) β loadgen worker
model, latency aggregation, when the loader becomes the bottleneck.
- [`docs/wrk2_results.md`](https://github.com/arizona-framework/roadrunner/blob/main/docs/wrk2_results.md) β open-loop,
Coordinated-Omission-corrected tail-latency tables (full per-scenario,
all rate-points per server).
- [`docs/resource_results.md`](https://github.com/arizona-framework/roadrunner/blob/main/docs/resource_results.md) β memory + CPU
shape per scenario.
- [`docs/conn_lifecycle_investigation.md`](https://github.com/arizona-framework/roadrunner/blob/main/docs/conn_lifecycle_investigation.md)
β the connection-process model trade-offs and the one h2 case
cowboy still wins.
- [`docs/roadmap.md`](https://github.com/arizona-framework/roadrunner/blob/main/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` /
`-moduledoc` markdown, dialyzer-clean specs. No `binary_to_atom` on
parsed names.
- **Continuation-style middleware.** `(Req, Next) -> {Response, Req2}`,
composable at listener and per-route level. Outermost first.
- **Telemetry over custom callbacks.** `telemetry` is 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 the
`erlfmt` plugin.
## Sponsors
Roadrunner is open source and maintained on personal time. If you or your company find it useful,
consider [sponsoring](https://github.com/sponsors/williamthome).
I also accept coffees β
[](https://www.buymeacoffee.com/williamthome)
<a href="https://github.com/sponsors/williamthome">
<img
src="https://raw.githubusercontent.com/williamthome/williamthome/sponsorkit/sponsors.svg"
alt="Sponsors"
/>
</a>
## Contributing
Contributions are welcome! Please see [CONTRIBUTING.md](https://github.com/arizona-framework/roadrunner/blob/main/CONTRIBUTING.md) for development setup,
testing guidelines, and contribution workflow.
### Contributors
<a href="https://github.com/arizona-framework/roadrunner/graphs/contributors">
<img
src="https://contrib.rocks/image?repo=arizona-framework/roadrunner&max=100&columns=10"
width="15%"
alt="Contributors"
/>
</a>
## Star History
<a href="https://star-history.com/#arizona-framework/roadrunner">
<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>
</a>
## License
Copyright (c) 2026 [William Fank ThomΓ©](https://github.com/williamthome)
Roadrunner is open-source under the Apache 2.0 License on
[GitHub](https://github.com/arizona-framework/roadrunner).
See [LICENSE.md](https://github.com/arizona-framework/roadrunner/blob/main/LICENSE.md) for more information.