Lightweight concurrency benchmarks
Modern web frameworks often encourage running many workers to gain concurrency. But a single worker is the honest unit of comparison for a runtime's concurrency machinery: its scheduler, its event loop, its fibers. If one worker can't keep up, more workers are just covering up runtime overhead.
So we built a deterministic, language-agnostic benchmark and put three single-worker web implementations through the same load:
- Go —
net/httpwith goroutines,GOMAXPROCS=1 - Python — FastAPI on uvicorn, one worker, one event loop,
httpxfor outbound calls - Ruby — Rack on Falcon + Async, one process, YJIT enabled, fibers for outbound calls
The short version: Go is the strongest all-rounder. Python is excellent on simple paths but collapses on outbound HTTP fan-out. Ruby/Falcon delivers Go-like throughput but pays for it in memory.
01 TL;DR
| Go | Python | Ruby/Falcon | |
|---|---|---|---|
| Stable max RPS (4 of 5 scenarios) | 2000 | 2000 | 2000 |
| Stable max RPS (HTTP I/O fan-out) | 1000 | ❌ none (saturates at 100) | 1000 |
| p95 latency at capacity | 0.25–5.32 ms | 0.39–5.85 ms | 1.03–6.45 ms |
| RSS at capacity | 23–31 MB | 63–82 MB | 84–295 MB |
| WebSocket connect p95 @ 1000 clients | 130 ms | 659 ms | 688 ms |
| WebSocket RSS (median) @ 1000 clients | 56 MB | 130 MB | 265 MB |
02 Methodology
All three apps are instrumented by the same harness, so the only variable is the runtime itself.
- Load tool: k6, open-loop constant-arrival-rate — requests arrive on a schedule, not gated by responses, so saturation shows up honestly in latency instead of being hidden by closed-loop feedback.
- Protocol: 30 s warmup, 120 s measurement, 3 repetitions per tested rate, rates of 100 / 500 / 1000 / 2000 RPS.
- Saturation: a repetition counts as saturated if p99 > 1000 ms, p99.9 > 2000 ms, error rate > 1%, or achieved RPS < 95% of requested. A "stable max" requires all three repetitions non-saturated.
- Memory: RSS sampled from each app's
/metricsendpoint after each run — checkpoint samples, not continuous profiles.
Five HTTP scenarios plus idle WebSocket connection handling cover the workloads that actually distinguish concurrency models.
| Scenario | What it does | What it measures |
|---|---|---|
json |
Decode → validate → encode a JSON body | Baseline request handling |
fanout |
10 concurrent 5 ms runtime sleeps | Scheduler / timer overhead |
io-fanout |
10 concurrent local HTTP calls to a deterministic sidecar | Runtime HTTP I/O behavior |
cache |
Shared in-memory cache, mixed reads/writes | Locking / shared-state contention |
pipeline |
Request-ID + logging, static auth, validation, JSON response | Realistic middleware chain |
ws |
Idle WebSocket setup and hold at 100 and 1000 clients | Connection concurrency |
03 Throughput
All three runtimes hold 2000 RPS on json, fanout, cache, and pipeline — the workloads where the CPU is the only bottleneck. The differences appear when concurrency gets real:
- Go sustains 1000 RPS on
io-fanoutand tips over only at 2000 (one saturated repetition of three). - Ruby/Falcon matches Go at 1000 RPS on
io-fanout. - Python has no stable level at all on
io-fanout— it saturates at the lowest tested rate of 100 RPS.
The fan-out scenario is also the only place latency separates the runtimes meaningfully.
04 Latency
At stable capacity, all three live comfortably in single-digit milliseconds:
- Go: p95 of 0.25–5.32 ms, p99 of 2.20–9.92 ms. Latency barely grows with load — the goroutine scheduler keeps per-request work tiny even while 2000 requests race through.
- Python: p95 of 0.39–5.85 ms, p99 of 0.63–8.30 ms. Remarkably flat; FastAPI's per-request overhead is low, and its
json/pipelinep99s (under 1 ms) were the best in the whole benchmark. - Ruby: p95 of 1.03–6.45 ms, p99 of 2.32–8.30 ms. Falcon holds its own at 2000 RPS with p95 at or below ~6.5 ms in every scenario.
The one outlier is Python's io-fanout: the bars (marked 9149* and 12274*) aren't typos — those are milliseconds. Python has no stable level on this scenario, so the charts plot its first tested rate: p95 of 9.1 s and p99 of 12.3 s at just 100 RPS. That collapse is the subject of §07 — HTTP I/O fan-out.
05 Memory
- Go sits at 23–31 MB across all HTTP scenarios. 2000 RPS, 2000 concurrent requests, and the entire process uses less memory than a browser tab.
- Python is modest and strikingly constant: 63–82 MB, barely moving between 100 and 2000 RPS.
- Ruby/Falcon is the memory outlier: 84–295 MB at stable load.
fanoutis the worst (≈295 MB, with a peak of ~3700 live fibers at 2000 RPS) — Falcon's per-request fiber pools stay warm and cost real memory. At stableio-fanoutload it sits around 251 MB — the price of true per-request fiber concurrency.
Multi-GB RSS spikes only appear when io-fanout is pushed into saturation (see below).
06 WebSockets
Holding 1000 idle WebSockets is the classic "C10K-ish" smoke test, and the runtimes separate cleanly (RSS values are medians):
| Go | Python | Ruby | |
|---|---|---|---|
| Connect p95 @ 100 clients | 27.6 ms | 50.0 ms | 75.5 ms |
| Connect p95 @ 1000 clients | 129.6 ms | 659.2 ms | 687.5 ms |
| RSS median @ 1000 clients | 56.0 MB | 130.2 MB | 264.8 MB |
Go's epoll-backed net/http handles 1000 simultaneous connections with a p95 connect of ~130 ms and 56 MB RSS. Python and Ruby both degrade by roughly an order of magnitude from 100 to 1000 clients in connect latency (~13× and ~9×, to ~660–690 ms), and Ruby's fiber-per-connection model shows up again in RSS at ~265 MB. None of them error — but if connection concurrency is your job, Go is the clear pick.
07 HTTP I/O fan-out
This is the chart that decides the conclusion. Fan-out — one request spawning 10 concurrent outbound HTTP calls — is the workload that service-to-service backends live on, and the three runtimes diverge hard:
- Go stays in single digits the whole way: p99 of ~7 ms at 100 RPS, falling to 2.2 ms at 1000 RPS, and only at 2000 RPS does one of three repetitions saturate. Thousands of goroutines block on I/O and the scheduler just swaps them.
- Ruby/Falcon tracks Go closely (p99 3.8 ms at 1000 RPS) until 2000 RPS, where all three repetitions saturate and the achieved rate collapses to ~185 RPS while RSS balloons to 4.4 GB. The fiber scheduler handles the load right up to the cliff, then falls off it.
- Python never gets off the ground: at just 100 RPS it achieves ~20 RPS, with p95 of 9.1 s and p99 of 12.3 s — the event loop's outbound I/O handling can't keep up with even a trickle of requests. This is the classic Python asyncio story: many concurrent connections are fine, but heavy outbound I/O concurrency is brutal.
Error rates stay at zero across the board until saturation; at Ruby's io-fanout 2000 RPS cliff the error rate reaches ~1.2%, and Go's single saturated repetition still produced zero errors.
http_req_failed rate across all tested rates and repetitions.08 Verdicts
Go — the default choice
It sustained 2000 RPS on four of five scenarios and 1000 RPS on io-fanout, kept p95 under 5.4 ms everywhere it was stable, used 23–31 MB of RSS, and connected 1000 WebSockets faster than anyone. There is no scenario in this benchmark where Go is not at or near the top. The one nuance: at 2000 RPS io-fanout it had a single saturated repetition, so 1000 RPS is the conservative stable number.
Python — great at everything except outbound I/O
For json, fanout, cache, and pipeline it hit 2000 RPS with the best p99s in the benchmark and a flat 63–82 MB RSS. If your service's hot path is request handling, validation, caching, or timers — no external calls — Python is genuinely competitive. If your hot path fans out to other services, this benchmark says: don't. 100 RPS of local fan-out produced 9-second p95s.
Ruby/Falcon — throughput without compromise, memory with one
Falcon reached 2000 RPS on four scenarios and 1000 RPS on io-fanout, with p95 latency matching Go within a few milliseconds. The trade-off is memory: 84–295 MB at stable load and multi-GB spikes when io-fanout is pushed past its cliff. Choose Ruby when you need Go-like HTTP throughput, can afford the RSS, and your I/O fan-out stays under its capacity.