Roadrunner

Erlang CIHex.pmHex DocsLicense

roadrunner logo

Pure-Erlang HTTP/1.1 + HTTP/2 + HTTP/3 + WebSocket server for Erlang/OTP. Built for low tail latency at sustained load. Beep beep.

Roadrunner is the HTTP backbone of the arizona-framework, and works standalone too. The API is small: a handler behaviour, request and response accessors, listener controls, and opt-in helpers (cookies, query strings, multipart, SSE, WebSocket).

Why Roadrunner?

A small, fast HTTP core you can trust on the hot path.

Requirements

Requires OTP 27+.

🚧 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 if you need stability across upgrades.

Conformance

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:

Performance at a glance

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.

scenarioroadrunnercowboyelli
hello307 k201 k299 k
json299 k189 k304 k
echo304 k162 k282 k
headers_heavy257 k141 k253 k
large_response124 k98 k123 k
multi_request_body262 k125 k274 k
varied_paths_router290 k175 kβ€”
post_4kb_form193 k98 kβ€”
large_post_streaming20 k6.9 kβ€”
pipelined_h1580 k371 k4.8 k
websocket_msg_throughput232 k179 kβ€”
gzip_response138 k111 kβ€”

Bold = fastest in row. β€” means that workload has no elli fixture. On simple GETs and small POSTs Roadrunner and elli sit within the bench's ~15% variance band on those rows; the comparison doc has the full methodology.

Tail latency at sustained load

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.

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βœ“βœ“βœ—
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βœ“β€”βœ—

Quickstart

Add to rebar.config (latest version on Hex):

{deps, [
roadrunner
]}.

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:

application:ensure_all_started(roadrunner).
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!

A handler is a module with handle/1. To read path parameters from a :param route (roadrunner_req:bindings/1), read the body (read_body/1 returns iodata and threads Req2 back), and reply with JSON (roadrunner_resp:json/2 encodes the term):

-module(users_handler).
-behaviour(roadrunner_handler).
-export([handle/1]).
handle(Req) ->
#{~"id" := Id} = roadrunner_req:bindings(Req),
{ok, Body, Req2} = roadrunner_req:read_body(Req),
Reply = #{id => Id, received => byte_size(iolist_to_binary(Body))},
{roadrunner_resp:json(200, Reply), Req2}.

Mix literal and :param routes:

routes => [
{~"/", hello_handler, #{greeting => ~"hello"}},
{~"/users/:id", users_handler, undefined}
]

The request accessors (method/1, path/1, header/2, parse_qs/1, bindings/1, peer/1, read_body/1) all live in roadrunner_req.

To wrap every response, register a middleware. An entry is a Callable or a {Callable, State} pair, and the first in the list runs outermost:

-module(server_header_mw).
-behaviour(roadrunner_middleware).
-export([call/3]).
call(Req, Next, _State) ->
{{Status, Headers, Body}, Req2} = Next(Req),
{{Status, [{~"server", ~"roadrunner"} | Headers], Body}, Req2}.
roadrunner:start_listener(api, #{
port => 8080,
middlewares => [server_header_mw],
routes => [{~"/", hello_handler, #{greeting => ~"hello"}}]
}).

For HTTP/2 over TLS, add a cert and list both protocols. ALPN is derived from protocols automatically:

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 open a UDP socket.

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:

Features

Handlers

Routing

Middleware

Built-in handlers

Hardening

Observability

Lifecycle

Documentation

Sponsors

Roadrunner is open source and maintained on personal time. If you or your company find it useful, consider sponsoring.

I also accept coffees β˜•

"Buy Me A Coffee"

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.