Performance
Compared with other servers
The Benchmark workflow serves 10 000 exact, 1 000 prefix and 500 regex rules with redirect-proxy and with the servers people usually turn to for redirects: nginx, HAProxy, Caddy, Traefik and Apache httpd. It runs every week and after each release, then publishes the new numbers (a server that fails its check holds them back for review), so the results below follow both redirect-proxy and the latest stable release of each server.
GET /exact/9999 → 301, requests per second (higher is better)
| Server | Version | p50 | p99 | Memory | Start-up |
|---|---|---|---|---|---|
| redirect-proxy | 0.7.11 | 1.3 ms | 2.7 ms | 74 MiB | 1,420 ms |
| nginx | 1.30.6 | 0.86 ms | 1.4 ms | 24 MiB | 340 ms |
| HAProxy | 3.4.7 | 0.83 ms | 1.8 ms | 24 MiB | 324 ms |
| Caddy | 2.11.9 | 16.4 ms | 32.9 ms | 134 MiB | 811 ms |
| Traefik | 3.7.18 | 17.8 ms | 53.8 ms | 184 MiB | 2,281 ms |
| Apache httpd | 2.4.71 | 2.5 ms | 5.6 ms | 27 MiB | 206 ms |
How each server is configured
- redirect-proxy (
ghcr.io/janalis/redirect-proxy:0.7.11): its own rules file. - nginx (
nginx:stable): exact rules in a `map` (hash), prefix rules are `^~` locations with one `rewrite` each, regex rules are `~` locations. - HAProxy (
haproxy:lts): `map_str` for exact rules, `map_beg` for prefix rules, `map_regm` for regex rules. - Caddy (
caddy:2): exact rules in a `map`, prefix rules are `handle_path` blocks, regex rules are `path_regexp` matchers. - Traefik (
traefik:v3): one router (`Path`, `PathPrefix` or `PathRegexp`) and one `redirectRegex` middleware per rule; `redirectRegex` only answers 301 or 302; answers 301 instead of 308 for the regex rule. - Apache httpd (
httpd:2.4): exact rules in a `RewriteMap txt:` (cached), prefix and regex rules are `RewriteRule` regexes, tried in order; worker MPM (the event MPM drops keep-alive connections under this load).
How it is measured (scripts/bench-compare.py):
- Every server runs from its official Docker image (
nginx:stable,haproxy:lts,caddy:2,traefik:v3,httpd:2.4) and is limited to half of the CPUs. redirect-proxy runs from its published image after a release, shown with its version, and otherwise from an image built frommain, shown asmain (<commit>). The load generator, oha in a container too, gets the other half. Both are on a private Docker network. - Each server gets the rules in the form it serves fastest: a hash map for exact rules where it has one, and its own prefix and regular expression constructs. "How each server is configured" above lists them.
- Before being timed, each server must answer every scenario with the expected status and
Location; one that does not is shown as failed. - Each scenario runs for 10 s with 100 keep-alive connections, after a 2 s warm-up, three times. The median is kept. Access logs are off everywhere.
- The client and the servers share one machine, which is a shared CI runner: compare the servers with each other, not with your own traffic.
make bench-compare runs it on your machine (Docker only). --only nginx,caddy measures some of the servers, and EXACT, PREFIX, REGEX, DURATION, RUNS, CONCURRENCY and SERVER_CPUS change the parameters.
The runners are not all the same machine: numbers from two runs do not compare. To compare versions or VM flags of redirect-proxy, measure them in the same run: RP_VARIANTS='short=+sbwt short; 0.7.0@ghcr.io/janalis/redirect-proxy:0.7.0' adds a redirect-proxy (label) per label[@image][=ERL_FLAGS][|K=V,K=V] entry, where K=V are environment variables of that server (upper-case names, values may hold commas; ; and | are reserved), e.g. log|RP_ACCESS_LOG_ENABLED=true (the Benchmark workflow takes variants, and baseline, a git ref it builds and measures as a variant). --profile (the workflow's profile) also reports where the server's schedulers spend their time during one scenario (PROFILE_SCENARIO, the workflow's profile_scenario, exact by default), and a perf profile when sudo perf works.
More scenarios, never published, measure what the four above leave out (EXTRA_SCENARIOS, comma separated; the workflow's scenarios and upstream). They also report the p99.9 and the slowest request:
mixed: 10 000 random URLs (MIXED_URLS), 70 % exact hits spread over the rules, 15 % prefix hits, 5 % regex hits and 10 % misses, each request with about 650 bytes of browser headers (user agent,Accept, referer, cookies). Before it is timed, a sample of each kind must answer the expected status andLocation. It is followed bymixed-rate: the same requests at a fixed rate, half the first server'smixedthroughput (FIXED_RATEsets it), the same for every variant, with latencies corrected for coordinated omission. Compare latencies there: at full load they mostly measure the queue.exact-churn: the exact hit with a new connection for each request.upstreamandupstream-churn(workflow inputupstream): a miss forwarded to an nginx upstream (server.upstream) that answers 200 with a small body, with keep-alive and with a new connection for each request. redirect-proxy is restarted with the upstream after its other scenarios.
SOAK=10m (the workflow's soak, not with extra scenarios or a profile) replaces the scenarios with mixed traffic for that long, with the access log written to a rotated file (a variant's settings override it), and samples the container's anonymous memory (cgroup anon), its docker stats memory, which also counts the page cache of the access log, and the server's own metrics (redirect_proxy_memory_bytes, redirect_proxy_processes, requests per second) every 15 s. It reports how fast memory grows under load, in MiB per minute after the first minute, over the whole run and over its second half alone. The allocator keeps freed memory, so anon often rises in steps, then levels off: that gives a positive slope overall but a flat second half. A leak keeps growing in both. The sample taken once the load has stopped is kept apart (after). oha keeps every result in memory, so give a soak of more than about 20 minutes a rate (soak_rate, SOAK_RATE); if oha is killed anyway, the memory samples are still reported, without latencies.
redirect-proxy alone
make bench (or the Benchmark workflow) generates the same rules and runs redirect-proxy from the checkout. It load-tests four scenarios with oha, falling back to ApacheBench:
- An exact hit.
- A prefix hit.
- The last regex.
- A miss. Prefix and regex rules starting with a literal are indexed, so a miss does not try every rule; regex rules that do not start with
^and a literal are all run.
It reports throughput, latency percentiles and memory. The access log is written to a file during the run, as it is enabled by default (RP_ACCESS_LOG_ENABLED=false measures without it).
Rule lookup itself, measured in-process with the same 11 500 rules, takes about 0.4 µs for an exact hit, 1.2 µs for a prefix hit, 1.9 µs for the last regex rule and 0.7 µs for a miss.
make bench-handler times the rest of the work of a request in the process that serves it, without the network: parsing the request head and running the handler (lookup, metrics, response), best of 15 runs. It does not depend on the load of other processes as much as a load test does.
make bench-matcher measures the work of one redirect in-process (lookup, hit count and Location), in BEAM reductions, for more request shapes: many prefix lengths, unindexed regexes, many hosts and wildcard hosts, query conditions and trailing slash retries. Reductions do not depend on the load of the machine, so CI compares them with scripts/matcher-bench-baseline.json and fails a change that makes a scenario more than 10 % more expensive (scripts/matcher-bench.py --update writes a new baseline).
Memory:
- An idle node with no rules uses about 48 MB RSS in the container image (30 MiB as
docker statscounts it). - Loading 11 500 rules adds about 5 MB to the shared snapshot; with them, after the load scenarios,
docker statsshows about 60 MiB. - A connection that answered a request in the last second has a 20 KiB heap, so that it is not garbage collected after every request; after a second without one it hibernates and keeps a few hundred bytes. With 5 000 keep-alive connections, memory goes up by about 130 MiB while they all send requests, and by about 40 MiB once they are idle.
- The node is started for at most 131 072 processes (
+P), twice its 65 536 ports (+Q): each connection is one of each. To accept more connections, raise both, e.g.ERL_FLAGS="+Q 262144 +P 524288".
Run the benchmark on your own hardware before sizing.
The redirect listener
The traffic port is served by a small HTTP server made for redirects, not by the general purpose one of the admin and the API: one process per connection, one read and one write per request, and nothing else.
A response on a kept-alive connection is handed to the socket without waiting for it to be written (the next request collects the outcome). In the Benchmark workflow this answers 10 % more requests per second with a 12 % lower median latency, for a 27 % longer p99 tail when the server is saturated (every connection always has a request in flight).
- HTTP/1.1 and 1.0, with keep-alive and pipelining. No TLS or HTTP/2 on this port: put a TLS proxy in front (
server.https_redirectredirects the requests it does not markX-Forwarded-Proto: https). - A connection idle for 10 s, or that takes as long to send a request head, is closed. One client address holds at most
server.max_connections_per_ipconnections (256; see Connection limits). - A request head (request line and headers) is limited to
server.max_path_bytes+ 64 KiB: a longer request line gets 414, longer headers 431, as do more than 100 header lines. - The bodies of answered requests are never read: the request is answered, then its connection is closed. What the client still sends is read and discarded for up to 2 s (0.5 s without a byte, 1 MiB at most) before the connection is closed, as after a 400: closing at once would reset the connection, and could discard the response before the client reads it. The bodies of requests forwarded to
server.upstreamare streamed to it, and the connection stays open when their length was unambiguous. Such a body must keep coming: 10 s without a byte of it gets a 408. A body longer thanserver.max_request_body_bytes(no limit by default) gets a 413, before anything is forwarded when its length is announced. - Malformed requests get 400 and the connection is closed: a request target that is not an absolute path, or has characters a URI cannot hold (a raw backslash, space,
",<,>, DEL, control character or non-ASCII byte;[ ] { } | ^and`, which browsers send unescaped in queries, are accepted); anything but one space between the parts of the request line, or afterHTTP/1.x; a header name that is not a token or is followed by whitespace; control characters or invalid UTF-8 in a header value; a folded header line; a secondHost; an HTTP/1.1 request withoutHost; aHostthat is not a host name of at most 255 bytes (letters, digits and the other characters of a URI's host: no space,/,@or non-ASCII byte), an IPv4 address or a bracketed IPv6 one, with an optional port up to 65535. Any other HTTP version than 1.0 and 1.1 gets 505.
