radiusward benchmarks

radiusward is an open-source RADIUS server that replicates accounting over a three-member cluster: an Accounting-Response goes out only once the record is stored with fsync on a majority, so a crashed node loses nothing that was acknowledged. This page shows how much load it carries, how fast it answers, and what a crash of the cluster leader costs under load — measured with the same open harness in every environment.

How a result is measured: the request rate rises step by step. A step passes only if no request is lost, 99 % of the answers come within 500 ms (p99) and at least 90 % of the target rate is answered. The highest passing rate is the ceiling; "≥" marks a lower bound, where the limit lies higher.

github-ubuntu-24.04 · 4 vCPU x86_64

2026-11-11T18:07:17+00:00 · commit cea5e67 · openjdk version "25.0.5" 2026-10-20 LTS · profile small · raw JSON

One shared machine, 4 vCPU: three cluster members, two gateways, PostgreSQL with the exporter and 4 load generators all ran on it, each member and gateway limited to one CPU. The numbers are a lower bound and a trend from week to week; separate machines carry more.

3,731/s
Accounting
  • committed on two of three members with fsync before each Accounting-Response
  • p99 302 ms, p50 12 ms
  • the next step failed: p99 609 ms, over 500 ms
≥ 15,053/s
Authentication
  • Access-Requests with PAP, CHAP and MS-CHAPv2, decided by the gateways
  • p99 200 ms, p50 0.3 ms
  • every step passed: the limit is higher
6,821/s
Mixed load
  • 70 % accounting, 30 % authentication
  • p99 463 ms, p50 31 ms
  • the next step failed: p99 708 ms, over 500 ms
0 lost
Leader killed under load
  • the cluster leader killed at 2,611 accounting requests/s
  • 0 retransmissions, longest wait 2,528 ms
  • every acknowledged record kept

Memory at the highest load: each gateway at most 384 MiB, each cluster member at most 619 MiB (profile small, which fits a member in 1 GiB).

Accounting: 3,731/s

Start and Interim records of fresh sessions through both gateways

01,1002,2003,3004,4005001,0002,0003,0003,500target requests/srequests/starget: 500 at 500target: 1,000 at 1000target: 2,000 at 2000target: 3,000 at 3000target: 3,500 at 3500target: 3,750 at 3750target: 4,000 at 4000answered: 497 at 500answered: 996 at 1000answered: 1,996 at 2000answered: 2,996 at 3000answered: 3,493 at 3500answered: 3,731 at 3750answered: 3,995 at 4000
target answered
01683355036705001,0002,0003,0003,500target requests/sp99 latency, msp99: 163 at 500p99: 168 at 1000p99: 209 at 2000p99: 323 at 3000p99: 267 at 3500p99: 302 at 3750p99: 609 at 4000limit: 500 at 500limit: 500 at 1000limit: 500 at 2000limit: 500 at 3000limit: 500 at 3500limit: 500 at 3750limit: 500 at 4000
p99 limit
Accounting: from 500/s (p99 163 ms) up to 3,731/s (p99 302 ms); the next step failed: p99 609 ms, over 500 ms.
CPU and memory per process at 3,750/s, the ceiling (100 % = one core)
ProcessCPU avgCPU maxMemory
bench-gen-06 %28 %73 MiB
bench-gen-16 %19 %75 MiB
bench-gen-29 %50 %73 MiB
bench-gen-36 %26 %72 MiB
gateway-a60 %98 %322 MiB
gateway-b59 %100 %320 MiB
node-074 %99 %497 MiB
node-147 %79 %312 MiB
node-253 %79 %333 MiB
postgres16 %26 %124 MiB
Every step, by rate

After the first failed step the harness bisects between the last good and the failed rate, so the run order (#) is not ascending. A step passes with nothing lost, p99 under 500 ms and at least 90 % of the rate answered.

TargetAnsweredp99#GeneratorsResult
500/s497/s163 ms14✓ passed
1,000/s996/s168 ms24✓ passed
2,000/s1,996/s209 ms34✓ passed
3,000/s2,996/s323 ms54✓ passed
3,500/s3,493/s267 ms64✓ passed
3,750/s3,731/s302 ms74✓ passed — the ceiling
4,000/s3,995/s609 ms44✗ p99 609 ms, over 500 ms

Authentication: ≥ 15,053/s

Access-Requests with PAP, CHAP and MS-CHAPv2

04,4008,80013,20017,6005001,0002,0004,0008,00016,000target requests/srequests/starget: 500 at 500target: 1,000 at 1000target: 2,000 at 2000target: 4,000 at 4000target: 8,000 at 8000target: 16,000 at 16000answered: 499 at 500answered: 999 at 1000answered: 1,988 at 2000answered: 3,946 at 4000answered: 7,722 at 8000answered: 15,053 at 16000
target answered
01382754125505001,0002,0004,0008,00016,000target requests/sp99 latency, msp99: 44 at 500p99: 38 at 1000p99: 48 at 2000p99: 110 at 4000p99: 155 at 8000p99: 200 at 16000limit: 500 at 500limit: 500 at 1000limit: 500 at 2000limit: 500 at 4000limit: 500 at 8000limit: 500 at 16000
p99 limit
Authentication: from 500/s (p99 44 ms) up to 15,053/s (p99 200 ms); every step stayed within 500 ms.
CPU and memory per process at 16,000/s, the ceiling (100 % = one core)
ProcessCPU avgCPU maxMemory
bench-gen-025 %65 %412 MiB
bench-gen-123 %65 %447 MiB
bench-gen-230 %94 %425 MiB
bench-gen-324 %58 %410 MiB
gateway-a72 %107 %381 MiB
gateway-b70 %105 %381 MiB
node-017 %63 %483 MiB
node-118 %75 %327 MiB
node-215 %73 %331 MiB
postgres1 %4 %125 MiB
Every step, by rate

After the first failed step the harness bisects between the last good and the failed rate, so the run order (#) is not ascending. A step passes with nothing lost, p99 under 500 ms and at least 90 % of the rate answered.

TargetAnsweredp99#GeneratorsResult
500/s499/s44 ms14✓ passed
1,000/s999/s38 ms24✓ passed
2,000/s1,988/s48 ms34✓ passed
4,000/s3,946/s110 ms44✓ passed
8,000/s7,722/s155 ms54✓ passed
16,000/s15,053/s200 ms64✓ passed — the ceiling

Mix: 6,821/s

70 % accounting, 30 % authentication

02,2004,4006,6008,8005001,0002,0004,0006,0007,500target requests/srequests/starget: 500 at 500target: 1,000 at 1000target: 2,000 at 2000target: 4,000 at 4000target: 6,000 at 6000target: 7,000 at 7000target: 7,500 at 7500target: 8,000 at 8000answered: 493 at 500answered: 997 at 1000answered: 1,992 at 2000answered: 3,948 at 4000answered: 5,687 at 6000answered: 6,821 at 7000answered: 7,153 at 7500answered: 7,572 at 8000
target answered
02805608411,1215001,0002,0004,0006,0007,500target requests/sp99 latency, msp99: 184 at 500p99: 229 at 1000p99: 211 at 2000p99: 381 at 4000p99: 264 at 6000p99: 463 at 7000p99: 708 at 7500p99: 1,019 at 8000limit: 500 at 500limit: 500 at 1000limit: 500 at 2000limit: 500 at 4000limit: 500 at 6000limit: 500 at 7000limit: 500 at 7500limit: 500 at 8000
p99 limit
Mix: from 500/s (p99 184 ms) up to 6,821/s (p99 463 ms); the next step failed: p99 708 ms, over 500 ms.
CPU and memory per process at 7,000/s, the ceiling (100 % = one core)
ProcessCPU avgCPU maxMemory
bench-gen-020 %64 %193 MiB
bench-gen-114 %37 %209 MiB
bench-gen-216 %39 %203 MiB
bench-gen-313 %32 %207 MiB
gateway-a63 %92 %376 MiB
gateway-b62 %75 %383 MiB
node-065 %84 %601 MiB
node-144 %77 %381 MiB
node-246 %82 %389 MiB
postgres23 %34 %183 MiB
Every step, by rate

After the first failed step the harness bisects between the last good and the failed rate, so the run order (#) is not ascending. A step passes with nothing lost, p99 under 500 ms and at least 90 % of the rate answered.

TargetAnsweredp99#GeneratorsResult
500/s493/s184 ms14✓ passed
1,000/s997/s229 ms24✓ passed
2,000/s1,992/s211 ms34✓ passed
4,000/s3,948/s381 ms44✓ passed
6,000/s5,687/s264 ms64✓ passed
7,000/s6,821/s463 ms74✓ passed — the ceiling
7,500/s7,153/s708 ms84✗ p99 708 ms, over 500 ms
8,000/s7,572/s1,019 ms54✗ p99 1,019 ms, over 500 ms

The gateway's packet path on one core (JMH)

Per accounting packet of about 200 bytes, on one core of this machine, without the network and without the cluster commit. Short runs on a shared machine are noisy; the trend below shows them over time.

StepWhat it doesPer packetPer second, one coreError
fullReceivePathall of the above: what a gateway does per accounting packet before the cluster commit771 ns1,297,000/s± 6 %
validatestructure check of the packet and its attributes38 ns26,229,000/s± 92 % (noisy)
verifyAuthenticatorthe Request Authenticator (MD5 over the packet and the shared secret)451 ns2,216,000/s± 93 % (noisy)
extractthe attributes accounting needs, read without allocating186 ns5,371,000/s± 153 % (noisy)
encodeResponsethe Accounting-Response, signed229 ns4,370,000/s± 79 % (noisy)

Trend

Every run per environment, to see whether a change made radiusward slower. On a shared machine single runs vary; read the direction, not one point.

github-ubuntu-24.04

04,1408,27912,41916,55812runceiling, requests/sAccounting: 3,788 at 1Accounting: 3,731 at 2Authentication: 3,701 at 1Authentication: 15,053 at 2Mix: 1,929 at 1Mix: 6,821 at 2
Accounting Authentication Mix
The ceilings per run.
0714222912runpackets per µs, one coreencodeResponse: 3 at 1encodeResponse: 4 at 2extract: 4 at 1extract: 5 at 2fullReceivePath: 1 at 1fullReceivePath: 1 at 2validate: 20 at 1validate: 26 at 2verifyAuthenticator: 2 at 1verifyAuthenticator: 2 at 2
encodeResponse extract fullReceivePath validate verifyAuthenticator
The gateway's packet path (JMH) per run.

runs 1: 2026-11-11 9b0d1c6, 2: 2026-11-11 cea5e67

More evidence and reproducing it

The same properties under faults: fault injection (power loss with LazyFS, partitions, full disk, a PostgreSQL outage), Kubernetes (leader killed, rolling updates, a volume replaced, a lost quorum restored), end-to-end and integration tests.

Every number here comes from scripts/bench.py; the raw results are linked per run. On your own machine (details, ADR 0066):

./mvnw -q install -DskipTests && docker build -f deploy/docker/Dockerfile -t radiusward:dev .
scripts/bench.py                                   # the compose stack on this machine
scripts/bench-aws.sh --profile small --region <r>  # on Amazon ECS, generators on their own instances