Roadrunner

View Source

Erlang CI Hex.pm Hex Docs License

roadrunner logo

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-Encoding negotiation (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_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 and docs/comparison.md.

scenarioroadrunnercowboyelli
hello287 k189 k281 k
json290 k194 k316 k
echo284 k153 k294 k
headers_heavy254 k143 k249 k
large_response121 k95 k129 k
multi_request_body271 k120 k275 k
varied_paths_router292 k168 k
post_4kb_form174 k95 k
large_post_streaming19 k7.0 k
pipelined_h1572 k362 k4.8 k
websocket_msg_throughput231 k171 k
gzip_response137 k108 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.

featureroadrunnercowboyelli
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 boundsmax_clients, max_content_length, request_timeout, keep_alive_timeout, min_bytes_per_second, max_keep_alive_requests
  • Middlewaremiddlewares
  • Body bufferingbody_buffering
  • Graceful draingraceful_drain, slot_reconciliation
  • Per-conn hibernationhibernate_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/1accepting | 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

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.

I also accept coffees ☕

&quot;Buy Me A Coffee&quot;

Sponsors

Contributing

Contributions are welcome! Please see CONTRIBUTING.md for development setup, testing guidelines, and contribution workflow.

Contributors

Contributors

Star History

Star History Chart

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.