Roadrunner
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.
Why Roadrunner?
A small, fast HTTP core you can trust on the hot path.
- Fast where it counts: Tuned for low p50 and p99 under sustained load. See Performance.
- Correct and hostile-input-safe: Strict RFC 9110 / 9112 / 9113 parsing, 100% h2spec and 100% Autobahn (no exclusions), and tested against request-smuggling cases. See Conformance.
- Every HTTP version, one server: HTTP/1.1, HTTP/2, HTTP/3 (experimental),
and WebSocket served from one listener; browsers upgrade to h3 over the same
port via
Alt-Svc. - Pure Erlang, almost no dependencies: One runtime dep (
telemetry), no C NIFs, and Roadrunner owns its own h1/h2/h3 codecs and its own QUIC transport: easy to read and audit. - Pleasant to build on: Plain-Erlang request and response values, composable middleware, and opt-in helpers for cookies, query strings, multipart, SSE, NDJSON, and WebSocket.
- Production lifecycle in the box: Graceful drain with a deadline, telemetry
events, per-request
request_idlog correlation, and configurable DoS bounds.
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.
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 localhost:8080
hello, roadrunner!
Documentation
| Topic | Description |
|---|---|
| API reference | Every public module, type, and function |
| Listener options | Every listener option |
| Handlers | Response shapes: buffered, stream, loop, sendfile, WebSocket |
| Middleware | Writing and ordering middleware |
| Resource limits | Per-connection and per-peer limits, defaults, and over-limit behavior |
| Roadmap | Planned work |
| Changelog | Release notes |
Features
Routing
Routes are tried top to bottom and the first match wins. :param binds one
segment and *wildcard binds the rest of the path as a list:
routes => [
{~"/", hello_handler, #{greeting => ~"hello"}},
{~"/users/:id", user_handler},
{~"/files/*path", files_handler}
]
#{~"id" := Id} = roadrunner_req:bindings(Req).
A route that can never match, because the routes above it already answer its path and methods, stops the listener from starting and names both routes. A mis-ordered table shows up at boot instead of as a 404 in production.
The map form adds per-route options: allowed methods (others get 405 with an
Allow header), a body cap, a request rate, and route middleware:
routes => [
#{path => ~"/users", handler => users_index, methods => [~"GET"]},
#{path => ~"/users", handler => users_create, methods => [~"POST"]},
#{path => ~"/upload", handler => upload_handler, max_content_length => 104_857_600},
#{path => ~"/login", handler => login_handler, rate_limit => #{rate => 5, period => 60}}
]
Method matching is literal, so a [~"GET"] route does not answer HEAD. Swap
the table on a running listener with roadrunner_listener:reload_routes/2. For
a listener with a single handler, pass routes => Mod (or {Mod, State}) to
skip the router.
Requests
roadrunner_req reads everything from the request value. Functions that
consume the body return a new Req:
handle(Req) ->
Method = roadrunner_req:method(Req),
Agent = roadrunner_req:header(~"user-agent", Req),
Query = roadrunner_req:parse_qs(Req),
Cookies = roadrunner_req:parse_cookies(Req),
{ok, Body, Req2} = roadrunner_req:read_body(Req),
...
read_form/1 decodes both urlencoded and multipart/form-data bodies. With
body_buffering => manual, read_body/2 reads the body in chunks.
Responses
roadrunner_resp builds buffered responses, and you can also return a plain
{Status, Headers, Body} tuple:
%% Each line is a possible return from handle/1
{roadrunner_resp:json(200, #{id => 42}), Req}
{roadrunner_resp:redirect(302, ~"/login"), Req}
{roadrunner_resp:not_found(), Req}
{{200, [{~"content-type", ~"text/plain"}], ~"hi"}, Req}
A plain tuple without content-length gets one from its body.
Set a cookie on any buffered response:
Resp = roadrunner_resp:text(200, ~"welcome"),
{roadrunner_resp:set_cookie(Resp, ~"session", Token, #{http_only => true, secure => true}), Req}
Streaming
Return {stream, Status, Headers, Fun} and call Send as data is ready.
On HTTP/1.1 the body goes out chunked, and {fin, Trailers} sends trailer
headers:
handle(Req) ->
Fun = fun(Send) ->
ok = Send(~"first part\n", nofin),
ok = Send(~"last part\n", fin)
end,
{{stream, 200, [{~"content-type", ~"text/plain"}], Fun}, Req}.
Server-Sent Events
A {loop, ...} response keeps the connection open and calls handle_info/3
for each message the process receives. roadrunner_sse frames the events:
handle(Req) ->
ok = my_pubsub:subscribe(self(), notifications),
Headers = [{~"content-type", ~"text/event-stream"}, {~"cache-control", ~"no-cache"}],
{{loop, 200, Headers, undefined}, Req}.
handle_info({notification, Body}, Push, State) ->
_ = Push(roadrunner_sse:event(~"notify", Body)),
{ok, State};
handle_info(_Other, _Push, State) ->
{ok, State}.
Keep a catch-all clause: Roadrunner also sends the handler
{roadrunner_disconnect, Reason} when the client goes away and
{roadrunner_drain, Deadline} when the listener drains.
NDJSON
{roadrunner_resp:ndjson(200, Rows), Req}
To stream rows instead, send roadrunner_ndjson:item(Row) from a {stream, ...}
response.
WebSocket
Upgrade from any handler over HTTP/1.1, then handle frames in a
roadrunner_ws_handler:
handle(Req) ->
{{websocket, echo_ws, #{}}, Req}.
-module(echo_ws).
-behaviour(roadrunner_ws_handler).
-export([handle_frame/2]).
handle_frame(#{opcode := text, payload := Msg}, State) ->
{reply, [{text, Msg}], State};
handle_frame(_Frame, State) ->
{ok, State}.
Fragmented messages arrive reassembled, and ping, pong, and close are handled
for you. permessage-deflate is negotiated automatically when the client asks
for it. Optional init/1, handle_info/2, and handle_drain/2 callbacks
cover setup, process messages, and drain.
Static files
{~"/static/*path", roadrunner_static, #{
dir => ~"/var/www",
cache_control => ~"public, max-age=31536000, immutable"
}}
Supports ETag, Last-Modified, Range, and precompressed .br / .gz
files, and by default refuses a symlinked file that points outside dir.
Handlers can also send a file directly with
{sendfile, Status, Headers, {Filename, Offset, Length}} (zero-copy for
HTTP/1.1 over plain TCP).
Middleware
A middleware wraps the handler. It gets the request and a Next function,
and the first entry in the list runs outermost:
auth(Req, Next, _State) ->
case roadrunner_req:header(~"authorization", Req) of
undefined -> {roadrunner_resp:unauthorized(), Req};
_ -> Next(Req)
end.
Built-in middleware:
middlewares => [
roadrunner_compress,
roadrunner_etag,
roadrunner_security_headers,
{roadrunner_cors, #{origins => [~"https://app.example.com"], credentials => true}},
{fun ?MODULE:auth/3, undefined}
]
roadrunner_compress: gzip / deflate negotiated onAccept-Encodingroadrunner_etag: weakETagon buffered200responses toGET/HEAD, and304 Not Modifiedon a matchroadrunner_security_headers: safe default headers, with opt-in HSTS (hsts => true) and CSProadrunner_cors: CORS with preflight handling and deny-by-default origins
HTTP/2 and HTTP/3
Add a certificate and list the protocols. ALPN is set up from protocols:
roadrunner:start_listener(my_tls_listener, #{
port => 8443,
protocols => [http1, http2, http3],
tls => [{certfile, "cert.pem"}, {keyfile, "key.pem"}],
routes => [{~"/", hello_handler, #{greeting => ~"hello"}}]
}).
HTTP/1.1 and HTTP/2 share the TCP port. HTTP/3 (experimental) serves over UDP
on the same port number. When the listener also serves HTTP/1.1 or HTTP/2 on a
fixed port (not port => 0), their responses advertise HTTP/3 in Alt-Svc so
browsers upgrade. For HTTP/2 over plain TCP (prior knowledge), use
protocols => [http2] without tls. Without tls, a listener serves either
HTTP/1.1 or HTTP/2, not both.
Behind a proxy
%% L4 balancer that prepends a PROXY protocol header
proxy_protocol => true,
%% HTTP proxies that set X-Forwarded-For
real_ip => #{trusted_proxies => [~"10.0.0.0/8"]}
roadrunner_req:client_ip/1 returns the real client, and rate limits and
telemetry use it too.
Limits and hardening
max_clients => 10_000,
max_content_length => 1_048_576,
request_timeout => 30_000,
rate_limit => #{rate => 100, period => 1}
- Strict RFC 9110 / 9112 parsing that rejects request smuggling shapes
(CL + TE, conflicting
Content-Length) and CR / LF injection in headers - TLS 1.2 / 1.3 only with AEAD ciphers, and post-quantum hybrid key exchange first on HTTP/1.1 and HTTP/2 when your OTP and OpenSSL support it
- Bounds on connections, concurrent HTTP/2 and HTTP/3 requests, body size, slow clients, and header size, all listed in Configuration
Observability
Roadrunner emits telemetry events for
requests, connections, and WebSocket frames:
telemetry:attach(log_requests, [roadrunner, request, stop],
fun(_Event, #{duration := D}, #{method := M, path := P, status := S}, _) ->
Us = erlang:convert_time_unit(D, native, microsecond),
logger:info("~s ~s -> ~p (~p us)", [M, P, S, Us])
end, undefined).
Each request also sets a request_id in the logger process metadata, so
logs from your handlers line up with it. roadrunner_listener:info/1 returns
live counters such as active_clients and requests_served.
Graceful drain
case roadrunner_listener:drain(my_listener, 30_000) of
{ok, drained} -> ok;
{timeout, Killed} -> logger:warning("closed ~p connections at the deadline", [Killed])
end.
Drain stops accepting new connections, tells open connections and long-lived handlers (loop and WebSocket) to finish, waits up to the deadline, closes what is left, and then stops the listener.
Configuration
All listener options live in the
roadrunner_listener:opts/0
type. Beyond port,
protocols, tls, and routes, the type covers:
- Limits and timeouts:
max_clients,max_concurrent_requests,overload_mode(refuse or queue requests over the limit),max_content_length,max_keep_alive_requests,request_timeout,keep_alive_timeout,tls_handshake_timeout,min_bytes_per_second,rate_check_interval,rate_limit - Real client IP:
proxy_protocol,real_ip(see Behind a proxy) - Sockets:
ip(bind address, all interfaces by default),num_acceptors,socket_backlog,recv_buffer - Middleware:
middlewares - Body buffering:
body_buffering - Graceful drain:
graceful_drain,slot_reconciliation - Per-conn hibernation:
hibernate_after - Handler spawn opts:
handler_spawn - WebSocket:
ws:max_frame_size,max_message_size,recv_buffer,hibernate_after - HTTP/1 tunables: Under the
{http1, Opts}entry inprotocols:max_request_line,max_header_line,max_header_block,max_header_count,linger_timeout - HTTP/2 tunables: Under the
{http2, Opts}entry inprotocols:conn_window,stream_window,window_refill_threshold,max_concurrent_streams,max_header_block,max_header_list_size - HTTP/3 tunables: Under the
{http3, Opts}entry inprotocols:listeners(reuseport pool size),max_header_block,max_streams_bidi,max_field_section_size
Modules
| Module | Purpose |
|---|---|
roadrunner |
Start, stop, and list listeners |
roadrunner_listener |
Listener options, drain, info, and route reload |
roadrunner_handler |
Handler behaviour and response shapes |
roadrunner_req |
Request accessors and body reading |
roadrunner_resp |
Response builders |
roadrunner_router |
Route syntax and matching |
roadrunner_middleware |
Middleware behaviour |
roadrunner_ws_handler |
WebSocket handler behaviour |
roadrunner_static |
Static file handler |
roadrunner_sse |
Server-Sent Events framing |
roadrunner_ndjson |
NDJSON framing |
roadrunner_multipart |
multipart/form-data parser |
roadrunner_cookie |
Cookie parsing and serialization |
roadrunner_qs |
Query string codec |
roadrunner_uri |
Percent-encoding helpers |
roadrunner_http |
Shared HTTP types and date formatting |
roadrunner_cors |
CORS middleware |
roadrunner_security_headers |
Security headers middleware |
Conformance
Strict 100% h2spec (HTTP/2) and Autobahn fuzzingclient across the full WebSocket matrix (no exclusions). The HTTP/1.1 parser is tested against malformed-request cases taken from the llhttp test corpus and PortSwigger request-smuggling patterns.
Standards conformance:
- HTTP/1.1: RFC 9110 (semantics) + RFC 9112 (syntax).
- HTTP/2: RFC 9113 (frames + multiplexing) + RFC 7541 (HPACK).
Conformance harness:
scripts/h2spec.sh(drives h2spec). - HTTP/3 (experimental): RFC 9114 over QUIC with QPACK (RFC 9204) static-table compression, over Roadrunner's own pure-Erlang QUIC transport (TLS 1.3 required).
- 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.
Performance
Roadrunner runs on HttpArena, an open benchmark that tests HTTP servers and frameworks on the same hardware under the same rules. See the leaderboard for current numbers.
Sponsors
Roadrunner is open source and maintained on personal time. If you or your company find it useful, consider sponsoring.
I also accept coffees ☕
Contributing
Contributions are welcome! Please see CONTRIBUTING.md for development setup, testing guidelines, and contribution workflow.
Contributors
Star History
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.