Skip to main content

README.md

# Roadrunner

[![Erlang CI](https://github.com/arizona-framework/roadrunner/actions/workflows/erlang.yml/badge.svg?branch=main)](https://github.com/arizona-framework/roadrunner/actions/workflows/erlang.yml)
[![Hex.pm](https://img.shields.io/hexpm/v/roadrunner.svg)](https://hex.pm/packages/roadrunner)
[![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/roadrunner/)
[![License](https://img.shields.io/hexpm/l/roadrunner.svg)](https://github.com/arizona-framework/roadrunner/blob/main/LICENSE.md)

![roadrunner logo](https://raw.githubusercontent.com/arizona-framework/roadrunner/main/assets/logo.jpg)

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 β˜•

[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](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.