Porting Basecamp's Campfire to C#: What It Took to Beat Rust on the Shared Benchmark
Basecamp open-sourced Campfire, its self-hosted group chat app. It’s a Rails app on SQLite, and Basecamp has since published ports of it in Rust, Go, Express, Django, Laravel, Elixir and C. Every port runs on the same database and serves the same pages, and a shared benchmark harness measures them all the same way.
There was no C# port, so I built one: once-campfire-csharp.
This debrief covers what “the same app” meant, how we checked it, why the first benchmark disappointed me, and where the port landed in the final table. Each performance fix gets its own post later in the series.
What “the same app” meant
I wanted a port you could swap in for the Rails container on a running install without anyone noticing. That came down to five rules:
- The same SQLite database and schema, so you can point the C# app at an existing Campfire database.
- The same storage layout for uploads and avatars.
- The same signed and encrypted cookies, so people stay signed in when you switch from Rails to C#.
- The same URLs and the same frontend, with the JavaScript, CSS and Turbo/Stimulus code shipped byte for byte.
- The same HTML that Rails renders.
The Rails app was the oracle. Whatever Rails returned for a request was the correct answer, and any difference was a bug. The only exceptions are written down in the README’s “Known differences” section, each with the test that shows it. Today there is one entry.
How we proved it
Writing the C# code turned out to be the easier half. Proving it behaved like Rails took most of the effort, and it’s the part I’d reuse on any rewrite:
- Golden vectors: expected outputs generated by running the real Rails code, for things like cookie encryption, sessions, routes, rich text and storage keys. The C# tests compare against those files instead of hand-written expectations.
- Request replay: requests recorded against the Rails app are replayed against the C# app, and the responses and resulting database changes have to match.
- Cross-reading: Rails has to be able to read, edit, delete and search a message that the C# app wrote.
- Differential fuzzing: the rich-text sanitizer decides what HTML a chat message may contain, which makes it the most security-sensitive code in the app. A fuzzer feeds the same generated input to both apps and compares the output, and 658 reference cases have to match. A second run of 486,000 cases found ten more differences, and each fix now has a regression case.
- A benchmark that checks every response: the harness validates each response against a route contract before counting it, so a fast response that skips work Rails does counts as a bug.
That last rule caught us twice. An early internal benchmark showed C# posting messages 8.8× faster than Rails, because the C# code wasn’t yet pushing new messages to connected clients or queuing bot and notification jobs, which Rails does. We threw that number out.
The second catch came in the final run. With a writer posting in the background, 2 sidebar responses out of 592,526 came back as invalid UTF-8, and the harness failed the run. A page had been rendered into a pooled buffer that went back to the pool while the response was still being cached, gzipped and sent. Once in a few hundred thousand requests, the gzip step rented that same buffer and wrote over the page it was compressing. The bug had been there since the sidebar was first ported, and a benchmark that only counted requests would have published it.
How it got built
AI coding agents from several vendors wrote most of the code, working from a task graph of about 100 tasks. Each task got its own GitHub issue, branch and pull request, and listed the files it owned so two agents never edited the same code. Nothing merged unless the parity checks passed, which mattered more than which model wrote it.
That setup will get its own post. For this story, what made the agents’ output trustworthy was the Rails oracle and the checks above.
The first benchmark was a letdown
The first official run on the shared harness compared Rails and C# on the same machine:
| Workload | Rails (req/s) | C# (req/s) | C# / Rails |
|---|---|---|---|
| Room page | 2,762 | 6,280 | 2.3× |
| Messages page | 2,573 | 8,569 | 3.3× |
| Sidebar | 2,878 | 9,229 | 3.2× |
| Search | 3,056 | 8,837 | 2.9× |
| Post a message | 249 | 50 | 0.2× |
Measured during development on an Intel i9-11900K desktop running Linux, with Rails and C# both behind Thruster in the same session. The scoreboard further down comes from a different machine, so compare the ratios between the two tables, not the requests per second.
Two to three times Rails on reads is respectable, but I expected more from compiled C# against Ruby. Posting was the real problem: 50 messages a second, a fifth of Rails, flat run after run, with a p99 of 330 ms.
The Rust and Go ports use the same design, one SQLite writer plus a pool of readers, and they post thousands of messages a second, so the problem had to be in our code.
Profiling, one fix at a time
From there we worked in a loop. An AI agent attached dotnet-diagnostics to the C# container while a load generator hit one route. It sampled CPU, allocations and lock contention, picked the biggest cost it could clearly explain, fixed it in one commit, and proved the gain on the full harness. Each cycle started in a fresh chat.
The loop found these, in order:
| Fix | What was wrong | Effect |
|---|---|---|
| WAL checkpoint | The writer ran a SQLite checkpoint after every commit once the WAL file passed 4 MB, and SQLite never shrinks that file | Posting 50 → 1,449/s |
| Reverse proxy | A separate proxy process sat in front of Kestrel, competing for the same 4 CPUs | ~3× on reads |
| Cache lock | A SQLite query ran inside a global lock several times per request | +6–10% on reads |
| Log level | Two info log lines per request were written even with logging set to warn |
+15–20% on reads |
| WAL-index header | The cache checked for database changes with a query instead of reading the 48-byte header SQLite itself uses | +14–24% on reads |
| Cookie crypto | Each request built four fresh OpenSSL contexts for HMAC and AES-GCM | +10–14% on reads |
| Buffers | An 80 KB buffer for every bodyless request, and a 415 KB page copied into a growing buffer | +1–3% on reads |
Each effect was measured during development, one fix at a time, on the same i9 desktop as the first benchmark. They show how much each fix mattered, not the final numbers.
Every fix kept the output identical to Rails, and the benchmark validated the responses after each one.
Not all of it came from our profiling loop. A big thank-you to Jose Luis Chavez del Cid (@jlchavez), who contributed a good share of the performance work:
- A first pull request that honored
RAILS_LOG_LEVELthe way Rails does (we threw out our own version because his matched Rails more closely), added theCAMPFIRE_THRUSTER=0switch that runs the image without the proxy, which is how C# was measured in the scoreboard below, and turned off .NET’s GC DATAS in the container. His notes on it also traced the slow posting to the WAL checkpoint and suggested the same fix we landed. - Four allocation fixes that cut the memory each request allocates from 51 KB to 29 KB and added about 10% on every page. That work gets its own post in this series.
- An in-memory avatar cache. Taking the proxy out also took away its caching of avatars, and his cache put it back.
bench/PROFILING.md, a write-up of how he measured all of it.
Each row in that table gets its own post, with a lesson that applies to any .NET app. Looking at them together, very few made any code run faster. Most stopped the app doing work it didn’t need, like running a query, taking a lock or writing a log line.
The scoreboard
With every fix in, we ran all the ports through the same harness on one Google Cloud VM: a c3-standard-22-lssd with hyperthreading off, four dedicated cores per app, 16 clients, three rounds per route. We ran both profiles twice from scratch. All 196 million measured responses were checked against the route contracts first, and none were invalid.

| Workload (req/s) | Rails | C# | Rust | Go | Express | Laravel | C |
|---|---|---|---|---|---|---|---|
| Room page | 2,388 | 37,115 | 42,167 | 28,555 | 20,880 | 1,999 | 58,248 |
| Messages page | 2,183 | 71,154 | 40,027 | 29,344 | 40,521 | 2,062 | 60,615 |
| Sidebar | 2,407 | 78,359 | 48,775 | 32,711 | 49,147 | 2,283 | 64,849 |
| Search | 2,491 | 76,385 | 48,822 | 33,112 | 46,937 | 2,171 | 66,091 |
| Post a message | 265 | 2,598 | 3,198 | 3,264 | 1,228 | 463 | 1,729 |
C# is the fastest of the seven on the messages, sidebar and search pages: 16 to 21% ahead of C and 56 to 78% ahead of Rust, at 31 to 33 times Rails. It’s third on the room page, behind C and Rust, and it posts messages at about ten times Rails, a little behind Rust and Go.
Its weak spot is the room page under writes. With a writer posting 10 messages a second in the background, C#’s room page drops to about 13,000 requests a second, fifth of the seven, while the other three pages stay first or level with C. The full tables, both sessions and every raw sample are in bench/PORTS.md.
Read the fine print
A few things the table doesn’t show on its own:
- Rails runs behind a proxy here and C# doesn’t. Rails ships behind Thruster, a small Go proxy, and was measured that way. The Rust, Go and C ports serve HTTP from their own process, so C# was measured the same way, straight from Kestrel, and part of the gap over Rails is that proxy. The C# image as shipped still includes Thruster for TLS and certificates, and the README says so.
- Django and Elixir are missing. Django ran with a single worker in this setup, which isn’t a fair picture of it, and Elixir’s image no longer builds because of an outdated package pin upstream.
- It’s one machine, so the ratios matter more than the absolute numbers. The raw samples and the exact commit of every port are in the repo.
What I took away
Design sets the ceiling, and the language decides how close you get to it. C leads the room page and holds up best under writes because of design: one process with its own HTTP front end, and a response cache that never runs SQL while holding a lock. Our single worst problem, the 50 posts a second, came from a design choice about when to checkpoint the database, and the proxy hop cost 3× no matter what language sat behind it. Once the C# design borrowed the good ideas in the other ports, C# moved past Rust and C on three pages. Post 8 goes into this.
Proving it’s the same app was the hard part. Golden vectors, replay, fuzzing and a validating benchmark took more effort than the features, and they’re the reason I trust every number above.
It paid to measure before changing anything. Every fix came from a profiler, and several of my guesses about what was slow were wrong. Removing the biggest source of allocations gained 1 to 3%, while reusing crypto contexts, which I expected to be minor, was worth three times what a microbenchmark predicted.
What’s next in this series
Posts 1 to 7 each take one problem from the work above and turn it into a lesson you can apply to your own .NET app, with a LINQPad script to reproduce it. Post 7 is Jose’s work. Post 8 pulls the lessons together, and posts 9 and 10 show you how to run the C# port yourself, in Docker or inside a LINQPad script:
- Your background job is in the hot path: the WAL checkpoint
- What does your reverse proxy cost?
- The fastest query is the one you don’t run
- Your log level is a performance setting
- One-shot APIs hide their setup cost
- Big allocations, small gains
- Death by a thousand strings: small per-request costs
- Design sets the ceiling
- Run your own Campfire on .NET with Docker
- Run your own Campfire on .NET with LINQPad
The code, the harness and every raw result are on GitHub: ryanrodemoyer/once-campfire-csharp.