Kotlin 在 DHH 的 Campfire 重写中追平 Rust
Kotlin keeps up with Rust in DHH's Campfire rewrite
开发者用 Claude Code(Claude Opus 5.5)约 24 小时将 37signals 的 Rust 版 Campfire 移植为 Kotlin,在 Ryzen AI MAX+ 395 的 x86 机器上吞吐追平甚至超过 Rust。
I spent about a day porting 37signals’ Rust Campfire to Kotlin with Claude Code. I wanted to see where the JVM would land in DHH’s benchmark table. On the x86 machines I tested, it kept up with Rust and often pulled ahead.
David Heinemeier Hansson has been rewriting Campfire, 37signals’ open-source chat app, in one language after another, with AI agents doing most of the typing. The scoreboard is a single table: the same app and the same pages in Rails, Django, Laravel, Express, Elixir, Go, Rust and C, in requests per second. Today’s version, for the room page:
Rails Django Laravel Express Elixir Go Rust C
----- ------ ------- ------- ------ ------ ------- -------
4,101 1,507 3,872 42,636 5,350 53,060 106,494 137,524Source: basecamp/once-campfire-rust README. 16 clients on a Ryzen AI MAX+ 395, four cores per app.
It’s easy to read that table as a ranking of languages: compiled beats interpreted, and Rust and C beat everything else. I don’t think that’s what it measures.
On October 7 the Rails room page in this comparison served 230 requests per second. A day later it serves 4,101: an 18x jump with the language, framework and hardware unchanged. What changed was the architecture. The improvements from the fastest ports were carried into each implementation. In DHH’s words:
I let the clankers run for a day ... and then ask it to bring all architectural improvements to each implementation.
After another day of agent work, C passed Rust too. DHH announced: “Good old C is now in the lead!”
These numbers measure how much attention each port got at least as much as they measure the language. The Rust port has had more of it than any of the others: hundreds of commits, profiling write-ups, a replacement for Thruster, a vendored HTML parser, and a gzip pipeline tuned until compression stopped dominating the profile. The JVM isn’t in the table at all.
So I asked: what happens if a garbage-collected language gets the same attention, ported from the Rust version rather than from Rails, held to the same output, and measured on the same hardware?
once-campfire-kotlin is a port of the Rust port of Campfire to Kotlin. It runs on the JVM (Java 25) or as a GraalVM native image. It reads the same SQLite database with the same signed and encrypted cookies, so it could sit in front of an existing install.
It isn’t a toy endpoint. Every request goes through the same layers the Rust port runs:
a front server doing the jobs of Thruster (which the Rust port replaced) and
Rack::Deflater;the Rails middleware,
public/files and the full route table;the request context with its cookie jar, session and flash;
the controller’s before-actions, including the hourly session refresh;
the bounded fragment cache with Rails’ keys;
the layout;
and, in round two, the finished-response cache from upstream.
It serves one page, the room page (GET /rooms/:id), because that’s the headline row of DHH’s table. Rich text is included. That meant porting html5ever, the HTML parser the Rust port vendors, to Kotlin. The port matches the Rust crate’s 658-case corpus byte for byte, and 180,000 extra fuzzed cases against the Rust implementation found no differences.
Under the hood it’s Netty for HTTP, Java’s FFM API for SQLite (compiled from the same amalgamation and flags as the Rust build, so both apps run the same database engine), and about 22,000 lines of Kotlin. In a native image, SQLite is linked statically and called through GraalVM’s C interface.
A benchmark between two ports means nothing if the ports do different work. So the Kotlin port has one hard rule: every response must be byte-identical to the Rust port’s, headers included, in the same order.
Bodies and ETags are compared for every room in the seed, for both user-agent branches and both encodings.
Twenty-odd other requests are checked too, including 304s,
HEAD, Turbo Frames, redirects, 404s, static files and byte ranges. So are cache misses, cache hits and invalidation.Under load, the load generator checks every response byte for byte against the Rust page, using the shared harness’s exact-body contract.
Across the final four benchmark runs that’s 306 closed-loop measurements and millions of responses, with zero invalid responses.
Matching Rust byte for byte meant porting some things you’d never write by hand. Kotlin’s header map reproduces how Rust’s http::HeaderMap orders headers, including how removing one moves another into its place. A zero Content-Length goes after every other header, because hyper writes it last.
Here’s the room page’s permission check in both ports. Rust:
Kotlin:
The Rust version takes a Scope because other controllers reuse it, and the Kotlin port only serves show, so this isn’t a strictly even comparison. But most of the difference is the language: no async/await threading, no move closures, no match arms for None. Nullable types and ?: do the same job in a line. The action itself reads almost the same in both:
Kotlin:
I prefer reading the Kotlin version. In these throughput tests, that didn’t appear to come with a performance penalty.
The Rust port is the work of DHH and “GPT on behalf of DHH”: more than 400 commits since late September, about 64,000 lines of Rust plus 161 templates, held to pixel-for-pixel parity with Rails by a Playwright harness across browsers.
The Kotlin port was built with Claude Code (Claude Opus 5.5) over roughly 24 hours of wall-clock time, overnight included:
A spike in the morning: one route, about 2,100 lines, to see whether the JVM could get close at all. It was suspiciously fast, but it skipped the framework layers, so most of its lead was less work, not a faster runtime. I threw it away.
The framework port in the evening: seven agents working in parallel, module by module, checked against the golden vectors the Rust port generates from the Rails app.
Parity, then profiling: closing every byte of difference against Rust, then two rounds of profiling (cheaper SQLite text reads, allocation-free header lookups, caching the stylesheet
Linkheader) before any number counted.A review round. An independent review found three real bugs: pipelined HTTP requests could be answered out of order; a SQLite call that can block for seconds was marked
criticalin the FFM API, which Java says must never block; and the JDBC fallback couldn’t run writes. All three were fixed with regression tests, then remeasured.The response cache: when upstream Rust gained its finished-response cache mid-project, an agent ported it (about 300 lines plus tests) and matched upstream’s output on hits, misses and invalidation.
The token count: about 5.3 million fresh tokens (0.6 million generated and 4.7 million written to the prompt cache), plus about 410 million cache reads (the context re-read on every turn). The cloud bill for every benchmark run on Hetzner, including a night when I forgot to turn the servers off: about €4.65.
I spent a lot of time checking whether a faster result meant we’d made something faster or accidentally stopped doing some of the work.
Here’s how I ran the comparison:
Hardware. Hetzner Cloud dedicated-vCPU servers in Hillsboro, AMD EPYC Milan at 2.0 GHz. The app server was either a ccx13, which turns out to be one physical core with its two SMT threads, or a ccx23, two physical cores. The load generator was a separate ccx23 on a private network measured at 12–17 Gbit/s.
One app at a time, each in Docker with host networking, on a fresh copy of the seed database.
Rust as shipped: its own production image, built from its Dockerfile.
Kotlin on
eclipse-temurin:25-jrewith ParallelGC, and as an Oracle GraalVM 25 native image with-O3and a PGO profile recorded on the same code, targeted atx86-64-v3so it runs on any recent x86 box.Closed loop: 1 to 4,096 connections, a 20-second warmup, then 15 seconds per step and three reps in alternating order. The tables show medians. CPU per request comes from each container’s cgroup.
Logging off in the headline numbers. Docker’s log driver charges the request log to CPU outside the container, which cost every app about 30% on these boxes. With logging on, the ratios are similar.
Rust gets the benefit of the doubt. The Rust port’s listener asks the kernel for a queue of only 128 pending connections (more below). That makes it stall intermittently at high connection counts. I patched it to use the kernel’s
somaxconn, as Go does, and the tables use the patched Rust. Below 4,096 connections the patch changes throughput by 2% or less; it only removes stalls.A speed check before every run. One afternoon run in Ashburn came out 45% slower for every app, on two fresh servers in a row. I threw it away and moved regions rather than average it in.
This is the like-for-like comparison: Kotlin against the Rust port at the revision it was ported from (5eb6d2d), both rendering the page on every request through the same layers, with the fragment cache warm.
One core (ccx13). Requests per second, with share of Rust and CPU per request:
Conns Rust Kotlin JVM Kotlin native
----- -------------- -------------------- --------------------
1 2,379 (268 µs) 2,810 (118%; 183 µs) 2,716 (114%; 189 µs)
16 7,920 (245 µs) 8,850 (112%; 217 µs) 7,647 (97%; 245 µs)
64 8,530 (228 µs) 8,961 (105%; 212 µs) 7,747 (91%; 242 µs)
256 8,788 (222 µs) 8,945 (102%; 213 µs) 7,408 (84%; 249 µs)
1,024 7,196 (271 µs) 8,536 (119%; 225 µs) 6,887 (96%; 265 µs)
4,096 5,906 (329 µs) 7,193 (122%; 270 µs) 6,002 (102%; 307 µs)Two cores (ccx23). Requests per second, with share of Rust:
Conns Rust Kotlin JVM Kotlin native
----- ------ ------------- -------------
1 2,484 3,081 (124%) 2,844 (115%)
16 14,464 18,340 (127%) 16,095 (111%)
64 15,122 20,664 (137%) 17,562 (116%)
256 16,407 20,564 (125%) 17,703 (108%)
1,024 13,448 19,613 (146%) 16,759 (125%)
4,096 11,654 17,300 (148%) 12,605 (108%)[Chart: round one, requests/s by connections, 1 core and 2 cores, three lines each]
On one core the JVM is ahead of Rust at every connection count, by 2% to 22%, and the native image is within 84–114%. On two cores the gap widens: the JVM serves 25–48% more than Rust, and even the native image is 8–25% ahead. Every app kept the box busy (about 3.8 of 4 threads) while the load generator stayed below half its capacity, so this isn’t a measurement artifact. On this hardware, Rust spends more CPU per request: 250 µs against the JVM’s 184 µs at 64 connections.
Tail latency follows the same pattern. At 1,024 connections on one core, p99 was 284 ms for Rust, 147 ms for the JVM and 169 ms for the native image.
Then upstream changed underneath me. The Rust port gained a cache of finished, authenticated responses, valid until the database changes (SQLite’s data_version) or 15 seconds pass. That’s where most of the jump to 106,494 came from. A benchmark against yesterday’s Rust would be easy to dismiss, so the response cache was ported to Kotlin line for line and benchmarked against upstream HEAD (ac6d655), the same code behind DHH’s latest table. Byte-for-byte parity held on misses, hits and invalidation.
One core (ccx13). Requests per second, with share of Rust and CPU per request:
Conns Rust Kotlin JVM Kotlin native
----- --------------- -------------------- ---------------------
1 3,787 (123 µs) 4,411 (116%; 86 µs) 4,264 (113%; 90 µs)
16 18,614 (101 µs) 19,175 (103%; 97 µs) 16,201 (87%; 115 µs)
64 18,634 (102 µs) 21,643 (116%; 88 µs) 16,993 (91%; 110 µs)
256 19,155 (99 µs) 24,049 (126%; 81 µs) 18,431 (96%; 102 µs)
1,024 15,686 (122 µs) 21,377 (136%; 92 µs) 16,419 (105%; 113 µs)Two cores (ccx23). Requests per second, with share of Rust:
Conns Rust Kotlin JVM Kotlin native
----- ------ ------------- -------------
1 4,337 4,318 (100%) 3,876 (89%)
16 25,505 28,701 (113%) 24,557 (96%)
64 30,045 38,057 (127%) 36,106 (120%)
256 30,154 35,581 (118%) 34,746 (115%)[Chart: round two, requests/s by connections, 1 core and 2 cores]
The cache roughly doubles every implementation’s throughput (1.8x to 2.4x), and the ordering doesn’t change. With rendering cached away, what’s left is the HTTP stack, cookie verification and copying bytes. The JVM does that in less CPU than Rust: 77 µs against 109 µs per request on two cores.
The two-core table stops at 256 connections because past that the test stopped measuring the apps. At 1,024 cached connections the app server pushes 4–5 Gbit/s of pages, and Hetzner’s virtual network starts dropping packets. In 15 seconds Rust retransmitted 252,000 TCP segments and Kotlin 452,000. Both apps sat half idle, waiting out retransmission timeouts. I confirmed that with thread dumps, a flight recording, more worker threads and a second load generator, and all of it pointed at the network. Kotlin suffers more from the packet loss (fewer requests per second), plausibly because Netty flushes bigger bursts, but I haven’t proven that.
Here’s where Rust is better:
Memory. Rust idles at 15–17 MB and stayed under about 80 MB until 4,096 connections. The JVM idles at 117–170 MB and runs at 170–500 MB under load. If memory is your constraint, the JVM is the wrong choice, but read on.
Startup. Rust answers its first request in about 175 ms from
docker run, Docker’s own container start included. The JVM takes about 1.5 s.Warmup. The JVM needs its JIT to warm up on the exact request mix. At a fixed 4,000 requests/s, its p99 was 315–389 ms with the benchmark’s usual warmup, against 5 ms for Rust. Warming up with the same requests the measurement sends brought it down to 31–80 ms, but that’s still more than Rust’s 2–3 ms. The rest is most likely garbage collection, though I haven’t measured it.
Apple silicon. In Docker on an M4 Max (two CPUs per app, before the review fixes), the JVM was level with Rust (93–105%) and the native image trailed at 75–87%. Kotlin’s lead on x86 doesn’t carry over to every CPU.
This is the part I find most interesting. The same Kotlin code compiled ahead of time with GraalVM:
starts faster than Rust: 165 ms to the first page against 178 ms, Docker’s start included;
idles at 28–29 MB, twice Rust’s 15–17 MB but a fifth of the JVM’s;
stays around 30–50 MB under load, below Rust’s 70–225 MB at high connection counts;
runs at 84–125% of Rust’s throughput uncached, and 87–120% cached.
There’s one memory catch: on two cores at 1,024 uncached connections its heap grew to 555 MB. Like any garbage-collected runtime, it needs a heap cap (-Xmx) in production.
For this endpoint, GraalVM gave me another useful deployment option: much lower memory use than the JVM with throughput reasonably close to Rust. One codebase can be deployed for peak throughput on the JVM or for a tiny footprint as a native image.
Rust’s 128-connection listen queue. In 4 of 6 runs at 4,096 connections, stock Rust stalled. Half the CPU sat idle, throughput fell to 2,100–3,100 requests/s, and the worst request took 27.5 seconds. The kernel counted 13,465 dropped connection attempts during one run, with clients retrying on Linux’s 1, 3, 7 and 15 second schedule. The cause is one number: the Rust port listens through Tokio and mio, and mio hard-codes a backlog of 128, the standard library’s value. Thruster, the Go server it replaced, uses the kernel’s somaxconn (65,535 on these boxes). It’s a one-line fix, and the benchmark repo carries it as a patch.
Cloud noise is bigger than the differences you’re measuring. A 45% slowdown across every app in one region one afternoon is larger than most of the gaps in this article. Without a speed check against a known-good run, I’d have published it.
Logging is expensive in Docker. The request log costs about 30% of throughput on these boxes, more than any language difference here, because Docker’s log driver does the work outside the container.
I started this because the JVM was missing from the table. These results make me think it deserves a place there.
They also made me more cautious about reading these tables as language rankings. Rails improved 18x without changing languages, and adding a response cache roughly doubled throughput in my tests. The architecture clearly matters a lot.
One page. The Kotlin port serves the room page and nothing else. Posting messages (where the database writer dominates), WebSockets, search and attachments aren’t measured.
Two small boxes. One and two physical cores on AMD EPYC Milan. DHH’s table uses four cores of a Ryzen AI MAX+ 395, so the absolute numbers aren’t comparable, only the ratios within each table.
One load shape. Closed-loop clients hammering one cached page, plus an open-loop run at fixed rates in round one. In that open-loop run the JVM sustained about 7,500 requests/s on one core against Rust’s 6,400 and the native image’s 6,050.
A lab, not production. No TLS, and logging off in the headline numbers.
Everything is public:
The Kotlin port, parity scripts and benchmark harness: mysticcoders/once-campfire-kotlin. It includes
bench/cloud/cloud.sh(up,provision,run,fetch,down) for Hetzner, and the Rust backlog patch.The Rust port: basecamp/once-campfire-rust
The shared load generator and its byte-for-byte contract: basecamp/once-campfire-verification
A full run on Hetzner takes about an hour and costs about a euro. If you find a mistake in the method, open an issue.
No posts
来源:Hacker News · wecodefire.com