Roadrunner

View Source

Erlang CI Hex.pm Hex Docs License

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.

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_id log 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

TopicDescription
API referenceEvery public module, type, and function
Listener optionsEvery listener option
HandlersResponse shapes: buffered, stream, loop, sendfile, WebSocket
MiddlewareWriting and ordering middleware
Resource limitsPer-connection and per-peer limits, defaults, and over-limit behavior
RoadmapPlanned work
ChangelogRelease 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 on Accept-Encoding
  • roadrunner_etag: weak ETag on buffered 200 responses to GET / HEAD, and 304 Not Modified on a match
  • roadrunner_security_headers: safe default headers, with opt-in HSTS (hsts => true) and CSP
  • roadrunner_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 in protocols: max_request_line, max_header_line, max_header_block, max_header_count, linger_timeout
  • HTTP/2 tunables: Under the {http2, Opts} entry in protocols: 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 in protocols: listeners (reuseport pool size), max_header_block, max_streams_bidi, max_field_section_size

Modules

ModulePurpose
roadrunnerStart, stop, and list listeners
roadrunner_listenerListener options, drain, info, and route reload
roadrunner_handlerHandler behaviour and response shapes
roadrunner_reqRequest accessors and body reading
roadrunner_respResponse builders
roadrunner_routerRoute syntax and matching
roadrunner_middlewareMiddleware behaviour
roadrunner_ws_handlerWebSocket handler behaviour
roadrunner_staticStatic file handler
roadrunner_sseServer-Sent Events framing
roadrunner_ndjsonNDJSON framing
roadrunner_multipartmultipart/form-data parser
roadrunner_cookieCookie parsing and serialization
roadrunner_qsQuery string codec
roadrunner_uriPercent-encoding helpers
roadrunner_httpShared HTTP types and date formatting
roadrunner_corsCORS middleware
roadrunner_security_headersSecurity 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-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

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 ☕

&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.