scripts/bench — workers=1, c=64, 3×20s runs per cell after 20s warmup; the median of the 3 runs is reported (the run-to-run spread is in the stability section below). Quiet machine throughout. The managed-heap cells run under a fixed heap budget so their RSS is comparable to the other cells' working sets rather than the runtime's default share of host RAM: the JVM cells (jruby, kotlin) via -Xmx512m=-Xms512m, and the .NET cells (csharp, csharp-aot) via the equivalent DOTNET_GCHeapHardLimit (512 MB). Full hardware, toolchain versions, and the exact invocation are in the environment appendix below.
These numbers measure one specific Rails reference app — not arbitrary Rails workloads. See #16 for the fixture-fragility caveats.
rails-jruby is the stock-Rails-on-the-JVM baseline — the same blog, pinned to Rails 8.0 (one minor behind the others' 8.1) on a prerelease AR-JDBC adapter (activerecord-jdbcsqlite3-adapter 80.0.pre1), the only combination that drives ActiveRecord on JRuby 10 today.
spinel runs the same Roundhouse-emitted framework as the CRuby ruby cell, but on the Spinel VM behind the Tep fiber server rather than CRuby + Puma — so the ruby → spinel gap is a runtime swap, not a framework or source change.
Each endpoint is its own chart. Bars are log-scaled; raw req/sec is shown at the right.
Same Ruby interpreter, same YJIT, same Puma — the only variable is whether the framework runtime is Rails or Roundhouse-emitted. The multiplier above each pair is the lift from the lowerer pipeline.
The same comparison on the JVM: emitted jruby beats stock rails-jruby by 25× on /articles, against 11× for ruby over rails on CRuby.
Each pair compares a cell with YJIT (ruby, rails) against its interpreter-only variant (ruby-int, rails-int). The bars show how far YJIT moves each.
Throughput normalized by memory footprint — req/sec divided by RSS in GB. This reorders the raw throughput charts above: targets with small working sets rise and high-RSS targets fall. Bars are log-scaled. How much the metric matters depends on the deployment shape — most on metered or serverless surfaces, least on bare metal with memory headroom.
Per-target lift going from /articles (HTML) to /articles.json (JSON). The asymmetry isn't JSON-escape vs HTML-escape — both paths concatenate and escape strings. What JSON skips is the layout chain (csrf_meta_tag, importmap, asset-manifest scan, content_for slots) and the per-row view helpers (link_to, button_to, dom_id, tag.*) that the HTML index template invokes for every record. The chart below shows the multiplier per target.
rails-jruby's multiplier falls below 1× (0.8×): its JSON endpoint measures slower than its HTML one.
Max RSS observed across all endpoints, per target, sorted low to high. The field spans more than two orders of magnitude. The managed-heap cells (the JVM and .NET targets) run under a fixed heap budget rather than an organically-grown working set — see the note below the chart.
The JVM cells run under a fixed -Xmx512m heap, so the heap can't grab a host-dependent share of RAM; the rest is metaspace and code-cache, bounded by the app's class count rather than the box. Read the JVM bars as a pinned budget, not an organically-grown working set like the other cells.
The .NET cells are capped the same way — DOTNET_GCHeapHardLimit at 512 MB, the -Xmx analog — so their RSS is a budget the GC collects within, not the host-proportional reservation Server GC takes by default. NativeAOT (csharp-aot) carries no JIT or warmup, so its working set is the leanest of the managed-heap cells.
Latencies are at c=64 concurrent connections; treat p50 as the median per-connection wait and p99 as the tail. Rows are ordered by measured median p50 across endpoints, fastest first.
| target | /articles | /articles/1 | /articles/new | /articles.json | /articles/1.json | |||||
|---|---|---|---|---|---|---|---|---|---|---|
| p50 | p99 | p50 | p99 | p50 | p99 | p50 | p99 | p50 | p99 | |
| rust | 1.2 | 2.2 | 0.7 | 2.5 | 0.5 | 1.6 | 0.7 | 1.9 | 0.3 | 1.0 |
| kotlin | 0.9 | 4.6 | 0.8 | 3.8 | 0.3 | 2.3 | 0.8 | 4.0 | 0.4 | 2.7 |
| csharp | 1.3 | 4.7 | 1.0 | 4.0 | 0.4 | 3.4 | 1.0 | 4.2 | 0.5 | 3.2 |
| csharp-aot | 1.3 | 4.7 | 1.0 | 4.3 | 0.4 | 2.8 | 1.0 | 3.4 | 0.5 | 2.5 |
| jruby | 2.1 | 4.4 | 1.7 | 3.8 | 1.2 | 3.1 | 1.6 | 3.6 | 1.2 | 3.0 |
| swift | 3.0 | 5.9 | 2.7 | 6.2 | 2.2 | 8.3 | 1.6 | 2.9 | 1.2 | 4.8 |
| crystal | 3.3 | 3.8 | 2.4 | 3.2 | 1.2 | 1.7 | 2.2 | 2.9 | 1.2 | 1.6 |
| go | 7.2 | 18.8 | 7.0 | 16.4 | 2.9 | 47.7 | 2.9 | 7.9 | 1.5 | 7.2 |
| elixir | 10.3 | 15.8 | 4.6 | 8.4 | 1.2 | 4.0 | 3.4 | 6.8 | 2.2 | 4.4 |
| spinel | 6.4 | 7.2 | 5.4 | 6.0 | 3.3 | 3.8 | 4.7 | 5.3 | 0.5 | 2.4 |
| python | 12.9 | 13.6 | 10.1 | 10.8 | 6.6 | 7.3 | 7.0 | 7.4 | 6.0 | 6.7 |
| ruby | 18.3 | 22.0 | 16.9 | 20.9 | 11.8 | 16.1 | 15.4 | 21.7 | 11.6 | 14.9 |
| ruby-int | 24.2 | 28.6 | 21.4 | 26.1 | 15.0 | 19.8 | 20.5 | 27.0 | 15.0 | 24.2 |
| rails-jruby | 52.7 | 75.0 | 55.8 | 81.2 | 32.7 | 52.9 | 69.0 | 93.0 | 50.0 | 73.0 |
| rails-int | 181.4 | 375.9 | 188.6 | 467.0 | 127.3 | 226.6 | 102.2 | 122.1 | 66.7 | 155.8 |
| rails | 186.1 | 355.6 | 190.4 | 412.1 | 130.9 | 231.1 | 105.3 | 114.2 | 67.9 | 192.9 |
All values in milliseconds. Lower is better.
Each cell is timed 3 times and the charts above report the median run. This section reads per-run.json directly to show how far the individual runs strayed from it. Across all 80 cells the median run-to-run coefficient of variation in req/sec is 0.58% — the timed runs of a given cell agree to a fraction of a percent, so the reported medians aren't masking noise.
The 8 cells that vary by more than 3% are concentrated in the lowest-throughput cells, where a small absolute swing is a larger fraction of the rate; in 3 of them the first timed run is the slowest, consistent with residual warmup the fixed 20s warmup doesn't fully absorb:
| target | endpoint | run 1 | run 2 | run 3 | CV |
|---|---|---|---|---|---|
| rails-jruby | /articles | 638 | 1,128 | 1,136 | 24.0% |
| ruby | /articles.json | 4,198 | 3,759 | 4,440 | 6.8% |
| ruby-int | /articles/new | 4,296 | 4,493 | 3,941 | 5.4% |
| spinel | /articles/1.json | 22,199 | 24,283 | 21,596 | 5.1% |
| rails | /articles/1.json | 885 | 987 | 975 | 4.8% |
| ruby-int | /articles | 2,663 | 2,651 | 2,873 | 3.7% |
| rails-jruby | /articles.json | 833 | 899 | 894 | 3.4% |
| ruby-int | /articles/1.json | 4,236 | 4,471 | 4,169 | 3.0% |
req/sec per timed run; CV = standard deviation ÷ mean.
| target | endpoint | req/sec | p50 (ms) | p99 (ms) | RSS (MB) | req/sec/GB |
|---|---|---|---|---|---|---|
| rust | /articles | 53,670 | 1.16 | 2.17 | 17 | 3,173,760 |
| rust | /articles/1 | 77,323 | 0.75 | 2.51 | 17 | 4,448,088 |
| rust | /articles/new | 105,618 | 0.46 | 1.57 | 18 | 5,811,151 |
| rust | /articles.json | 85,188 | 0.69 | 1.87 | 18 | 4,614,930 |
| rust | /articles/1.json | 153,777 | 0.34 | 1.01 | 18 | 8,320,272 |
| crystal | /articles | 19,212 | 3.30 | 3.84 | 22 | 890,161 |
| crystal | /articles/1 | 25,428 | 2.41 | 3.20 | 26 | 975,420 |
| crystal | /articles/new | 51,283 | 1.16 | 1.72 | 23 | 2,256,024 |
| crystal | /articles.json | 28,323 | 2.18 | 2.89 | 22 | 1,283,010 |
| crystal | /articles/1.json | 52,764 | 1.16 | 1.58 | 23 | 2,274,249 |
| go | /articles | 8,420 | 7.20 | 18.77 | 33 | 256,640 |
| go | /articles/1 | 8,694 | 7.00 | 16.41 | 34 | 255,017 |
| go | /articles/new | 11,187 | 2.88 | 47.66 | 35 | 326,835 |
| go | /articles.json | 20,438 | 2.89 | 7.88 | 37 | 561,421 |
| go | /articles/1.json | 36,773 | 1.53 | 7.22 | 37 | 1,010,490 |
| csharp-aot | /articles | 43,367 | 1.27 | 4.68 | 72 | 610,204 |
| csharp-aot | /articles/1 | 54,333 | 1.01 | 4.32 | 75 | 740,639 |
| csharp-aot | /articles/new | 105,811 | 0.43 | 2.84 | 76 | 1,420,962 |
| csharp-aot | /articles.json | 54,931 | 1.01 | 3.41 | 74 | 753,806 |
| csharp-aot | /articles/1.json | 104,096 | 0.45 | 2.53 | 75 | 1,409,150 |
| kotlin | /articles | 56,400 | 0.92 | 4.56 | 599 | 96,332 |
| kotlin | /articles/1 | 66,831 | 0.77 | 3.81 | 716 | 95,467 |
| kotlin | /articles/new | 138,021 | 0.34 | 2.34 | 764 | 184,806 |
| kotlin | /articles.json | 65,615 | 0.79 | 4.03 | 858 | 78,221 |
| kotlin | /articles/1.json | 106,198 | 0.44 | 2.70 | 879 | 123,653 |
| swift | /articles | 20,078 | 2.98 | 5.89 | 44 | 462,554 |
| swift | /articles/1 | 21,909 | 2.66 | 6.18 | 46 | 479,130 |
| swift | /articles/new | 25,490 | 2.15 | 8.31 | 49 | 531,973 |
| swift | /articles.json | 38,428 | 1.56 | 2.91 | 50 | 780,365 |
| swift | /articles/1.json | 43,799 | 1.15 | 4.79 | 50 | 887,025 |
| csharp | /articles | 40,362 | 1.26 | 4.69 | 126 | 325,919 |
| csharp | /articles/1 | 53,709 | 0.97 | 4.05 | 127 | 431,215 |
| csharp | /articles/new | 95,088 | 0.44 | 3.36 | 127 | 761,764 |
| csharp | /articles.json | 54,555 | 0.98 | 4.22 | 128 | 433,851 |
| csharp | /articles/1.json | 94,022 | 0.48 | 3.17 | 129 | 742,490 |
| spinel | /articles | 9,878 | 6.41 | 7.16 | 11 | 845,741 |
| spinel | /articles/1 | 11,794 | 5.35 | 6.05 | 11 | 1,013,385 |
| spinel | /articles/new | 19,413 | 3.27 | 3.80 | 11 | 1,690,141 |
| spinel | /articles.json | 13,548 | 4.69 | 5.34 | 12 | 1,121,074 |
| spinel | /articles/1.json | 22,199 | 0.51 | 2.35 | 12 | 1,767,744 |
| elixir | /articles | 6,115 | 10.33 | 15.81 | 134 | 46,433 |
| elixir | /articles/1 | 13,508 | 4.64 | 8.44 | 136 | 101,316 |
| elixir | /articles/new | 46,242 | 1.22 | 4.03 | 138 | 342,714 |
| elixir | /articles.json | 18,271 | 3.43 | 6.76 | 140 | 133,040 |
| elixir | /articles/1.json | 28,064 | 2.20 | 4.41 | 138 | 207,388 |
| python | /articles | 4,920 | 12.90 | 13.63 | 80 | 62,339 |
| python | /articles/1 | 6,239 | 10.08 | 10.85 | 81 | 78,770 |
| python | /articles/new | 9,530 | 6.60 | 7.26 | 81 | 120,307 |
| python | /articles.json | 9,131 | 6.99 | 7.37 | 81 | 115,258 |
| python | /articles/1.json | 10,457 | 6.03 | 6.66 | 81 | 131,994 |
| ruby | /articles | 3,556 | 18.30 | 21.99 | 125 | 28,972 |
| ruby | /articles/1 | 3,816 | 16.93 | 20.89 | 134 | 29,055 |
| ruby | /articles/new | 5,375 | 11.78 | 16.11 | 152 | 36,206 |
| ruby | /articles.json | 4,198 | 15.45 | 21.67 | 155 | 27,629 |
| ruby | /articles/1.json | 5,604 | 11.65 | 14.86 | 154 | 37,043 |
| ruby-int | /articles | 2,663 | 24.24 | 28.59 | 102 | 26,587 |
| ruby-int | /articles/1 | 3,031 | 21.39 | 26.10 | 121 | 25,475 |
| ruby-int | /articles/new | 4,296 | 14.98 | 19.75 | 131 | 33,526 |
| ruby-int | /articles.json | 3,147 | 20.54 | 27.04 | 134 | 24,051 |
| ruby-int | /articles/1.json | 4,236 | 14.95 | 24.23 | 138 | 31,263 |
| jruby | /articles | 28,325 | 2.13 | 4.42 | 939 | 30,879 |
| jruby | /articles/1 | 36,711 | 1.66 | 3.84 | 994 | 37,795 |
| jruby | /articles/new | 52,647 | 1.17 | 3.10 | 998 | 53,993 |
| jruby | /articles.json | 38,552 | 1.59 | 3.60 | 1,012 | 38,987 |
| jruby | /articles/1.json | 52,173 | 1.17 | 2.95 | 1,012 | 52,759 |
| rails | /articles | 333 | 186.11 | 355.60 | 316 | 1,079 |
| rails | /articles/1 | 325 | 190.36 | 412.09 | 324 | 1,027 |
| rails | /articles/new | 475 | 130.87 | 231.12 | 328 | 1,483 |
| rails | /articles.json | 609 | 105.30 | 114.19 | 330 | 1,885 |
| rails | /articles/1.json | 975 | 67.86 | 192.94 | 365 | 2,735 |
| rails-int | /articles | 340 | 181.35 | 375.90 | 294 | 1,181 |
| rails-int | /articles/1 | 327 | 188.56 | 467.00 | 304 | 1,102 |
| rails-int | /articles/new | 487 | 127.28 | 226.62 | 312 | 1,596 |
| rails-int | /articles.json | 624 | 102.25 | 122.14 | 316 | 2,018 |
| rails-int | /articles/1.json | 956 | 66.72 | 155.82 | 352 | 2,780 |
| rails-jruby | /articles | 1,128 | 52.67 | 75.00 | 1,091 | 1,058 |
| rails-jruby | /articles/1 | 1,057 | 55.79 | 81.17 | 1,152 | 939 |
| rails-jruby | /articles/new | 1,757 | 32.73 | 52.93 | 1,201 | 1,496 |
| rails-jruby | /articles.json | 894 | 69.02 | 93.04 | 1,320 | 693 |
| rails-jruby | /articles/1.json | 1,202 | 49.99 | 72.99 | 1,322 | 930 |
| hostname | showcase.party |
|---|---|
| OS | Ubuntu 24.04.4 LTS |
| kernel | Linux showcase.party 6.8.0-124-generic #124-Ubuntu SMP PREEMPT_DYNAMIC Tue May 26 13:00:45 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux |
| board | ASRock B450 Pro4 R2.0 |
| CPU | AMD Ryzen 5 3600 6-Core Processor |
| topology | 6 cores / 12 threads |
| clock | governor=schedutil, boost enabled, 3600 MHz max |
| memory | 64,221 MB |
| cargo | cargo 1.95.0 (f2d3ce0bd 2026-03-21) |
|---|---|
| crystal | Crystal 1.20.2 [2482c62c1] (2026-05-15) |
| curl | curl 8.5.0 (x86_64-pc-linux-gnu) libcurl/8.5.0 OpenSSL/3.0.13 zlib/1.3 brotli/1.1.0 zstd/1.5.5 libidn2/2.3.7 libpsl/0.21.2 (+libidn2/2.3.7) libssh/0.10.6/openssl/zlib nghttp2/1.59.0 librtmp/2.3 OpenLDAP/2.6.10 |
| dotnet | 10.0.301 |
| go | go version go1.26.4 linux/amd64 |
| jruby | jruby 10.1.0.0 (4.0.0) 2026-04-20 32f988b78c OpenJDK 64-Bit Server VM 25.0.3+9-LTS on 25.0.3+9-LTS +indy +jit [x86_64-linux] |
| mise | 2026.5.15 linux-x64 (2026-05-23) |
| node | v26.3.0 |
| python3 | Python 3.14.6 |
| ruby | ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux] |
| rustc | rustc 1.95.0 (59807616e 2026-04-14) |
| shards | Shards 0.20.0 [b2b98ca] (2025-12-19) |
| sqlite3 | 3.45.1 2024-01-30 16:01:20 e876e51a0ed5c5b3126f52e532044363a014bc594cfefa87ffb5b82257ccalt1 (64-bit) |
| uv | uv 0.11.22 (x86_64-unknown-linux-musl) |
| wrk | wrk debian/4.1.0-4build2 [epoll] Copyright (C) 2012 Will Glozer |
| command | scripts/bench --port 19000 rails rails-int rails-jruby ruby ruby-int jruby python spinel typescript elixir go csharp csharp-aot swift kotlin crystal rust |
|---|---|
| workers | 1 |
| concurrency | 64 |
| runs | 3 × 20s after 20s warmup |
| wrk threads | 2 |
| endpoints | /articles /articles/1 /articles/new /articles.json /articles/1.json |
| targets | rails, rails-int, rails-jruby, ruby, ruby-int, jruby, python, spinel, typescript, elixir, go, csharp, csharp-aot, swift, kotlin, crystal, rust |
| not measured | typescript (requested but produced no cells) |
| commit | a2c2eacde7dbb8c7a50eb5e30b57c7d529f5c620 |
|---|---|
| branch | main |
| subject | analyze: Int rounding, to_* fallback on Var recv, flatten/dedup unions (#57 Tier 1) |
| load average | 0.24 / 0.08 / 0.02 |
|---|---|
| uptime | 06:00:10 up 11 days, 12:46, 1 user, load average: 0.24, 0.08, 0.02 |