← All posts

Lightweight concurrency benchmarks

· 9 min read · V. Dombrovskiy

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:

  • Gonet/http with goroutines, GOMAXPROCS=1
  • Python — FastAPI on uvicorn, one worker, one event loop, httpx for 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 /metrics endpoint 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

Stable throughput capacity for Go, Python and Ruby/Falcon across the five scenarios
Highest requested RPS at which all 3 repetitions stayed non-saturated.

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-fanout and 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

p95 latency at capacity for Go, Python and Ruby/Falcon
Median p95 across repetitions at each implementation's stable max (log scale; * = no stable level, first tested rate shown).
p99 latency at capacity for Go, Python and Ruby/Falcon
Median p99 across repetitions at each implementation's stable max.

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/pipeline p99s (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

RSS at capacity for Go, Python and Ruby/Falcon
Median RSS at each implementation's stable max (lower is better).
  • 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. fanout is 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 stable io-fanout load 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

WebSocket connect p95 at 100 and 1000 clients
WebSocket connection p95 at 100 and 1000 concurrent clients.
WebSocket RSS at 100 and 1000 clients
RSS while holding idle WebSocket connections (medians).

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

I/O fan-out p99 latency curve for Go, Python and Ruby/Falcon across requested RPS
Median p99 latency by requested RPS; "sat" markers indicate at least one saturated repetition.

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.

Maximum observed error rate across all tested rates
Max k6 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.

Working through something similar?

Start a project