Tuesday, 29 September 2026Independent intelligence from the feedEditor
Samuel TimesSignal over noise
Front Page/Nerd
Rails-to-Rust application port · Signal 8.2

Basecamp Ports Campfire From Rails to Rust

ONCE Campfire in Rust: one binary, the Rails app's data, 19–44× faster - basecamp/once-campfire-rust Campfire in Rust A port of ONCE Campfire from Rails to Rust. It was built to be impossible to tell apart from the Rails app: the same screens pixel for pixel, the same protocols, and the Rails app's existing SQLite database and storage directory.…

ONCE Campfire in Rust: one binary, the Rails app's data, 19–44× faster - basecamp/once-campfire-rust
Image from the primary source
Archived primary source · 23,282 chars

GitHub - basecamp/once-campfire-rust: ONCE Campfire in Rust: one binary, the Rails app's data, 19–44× faster

Campfire in Rust

A port of ONCE Campfire from Rails to Rust. It was built to be impossible to tell apart from the Rails app: the same screens pixel for pixel, the same protocols, and the Rails app's existing SQLite database and storage directory. With that parity reached, it now diverges from Rails where that makes it faster or better; each divergence is listed under Known differences . Everything moved to Rust except the frontend: the CSS, Stimulus controllers, Turbo, Lexxy and the other vendored JavaScript ship as they are, apart from the few files in crates/assets/overrides/ .

The port ships as a single campfire executable (plus libvips and ffmpeg). It replaces Ruby, Puma, Redis, Resque and Thruster. Against the Rails app it replaces, it serves pages, posts and real-time delivery 20–95× faster, and holds 10,000 connected clients in a fifth of the memory.

What was done

The Rails app lives in reference/ as a git submodule, pinned to the commit being matched. It is the oracle for everything: no expected output was written by hand. Golden vectors, screenshots and protocol recordings all come from running the real Rails app.

The port ( crates/ )

The plan behind it, including why it uses Axum and why pixel parity is tested the way it is, is in plans/rust-conversion.md .

How parity is proven ( parity/ )

A Playwright harness runs the Rails app and the Rust app side by side in pinned containers, on identical seed data generated by the Rails app, with frozen clocks. It compares 225 screen states across Chromium, Firefox and WebKit, four viewports, light and dark mode, and the CSS breakpoints.

For every state it compares: the server HTML the live DOM the accessibility tree every subresource the page loads the Action Cable frames the screenshot, pixel for pixel with zero tolerance

the server HTML

the live DOM

the accessibility tree

every subresource the page loads

the Action Cable frames

the screenshot, pixel for pixel with zero tolerance

Before any Rust code existed, the harness had to show the Rails app matching itself, so that nondeterminism couldn't hide real differences.

Results when the port was finished, before it started to diverge:

The only thing masked then was the random join code on the first-run screen. Since the port began to diverge, the harness also leaves out the CSRF tags Rails renders, masks the digests of the files in crates/assets/overrides/ , and ignores the session cookie writes and the manifest body that now differ on purpose; everything else still has to match. The latest lean gate, for the release that made the repository public, passed 873 of 874 cells in Chromium, Firefox and WebKit; the one left is the web app manifest, allowlisted as a deliberate difference ( parity/allowlist.yml ).

Performance

These numbers come from benchmarking the v0.1.1 image against the Rails app: production images of both, the same seed data, the same 4 pinned hardware threads, host networking, and 3 interleaved runs per app. The medians are below; the full tables with spreads are in bench/results/v0.1.1-20260928/report.md . The host ran other light work on other cores during the run; the spread between runs stays within a few percent for the Rust app.

Throughput (16 concurrent clients)

Latency

Real time (Action Cable, up to 10,000 clients in one room)

Every client subscribed in every run, for both apps.

Startup and memory

Rails' whole container adds Redis and Thruster to its app processes; the Rust app is one process.

Since the previous benchmark

The run before this one benchmarked main at 898653e the same way ( bench/results/scale-20260927 ). Since then came cached page parts , the new WebSocket layer , and opting out of transparent huge pages ( bench/results/thp-20260928 ):

100,000 clients, and a Raspberry Pi 5

One Campfire holds 100,000 connected clients in 1.5 GB: every one connects in about 13 s, and memory stays flat while messages fan out to all of them. On a Raspberry Pi 5's CPU budget (emulated: four pinned cores capped at 1.2 cores' worth), the app delivered over a million messages a second to those clients, the load generator's limit, using 0.71 of its 1.2 cores. What limits a Pi is its gigabit Ethernet: about 51,000 compressed deliveries a second. That's 100,000 chatters in rooms of 100, each posting every five minutes, with a third to spare; a single room of 100,000 can't be busy on one gigabit link. Details and caveats in bench/results/pi-100k-20260928/report.md .

What changed: Action Cable sockets use their own small WebSocket implementation, which writes each broadcast's shared bytes to every socket without copying them per connection, and compresses a broadcast once for all of its subscribers ( permessage-deflate , which browsers offer). Connections run on threads of their own, so page loads and posts don't queue behind a fan-out. The app also raises its own open-file limit, which in Docker would otherwise stop it at 65,536 clients.

Where the speed came from

A straight translation was already 3–10× faster than Rails. Profiling (in plans/perf-attribution.md ) then showed where the time went, and each change since has been measured before and after, keeping the test suite and the parity gate green. In the order they landed:

Against Rails, the room page went from 4.4× in the preliminary benchmark to 95× in the latest one.

gzip and ETags from cached page parts

Every response is gzipped at level 6, as Rails' Rack::Deflater does, and after the passes above that was 60–76% of the CPU on large pages. Most of a room page is cached messages, whose bytes are the same on every request, so the app stopped compressing them per request, in two steps:

Spliced gzip. Each cached message is compressed once and kept, and pages splice the stored pieces into the gzip stream. Compressing each message on its own would make a room page 4.4× larger, because consecutive messages share most of their markup, so each piece is compressed against the message before it as a preset dictionary, and reused only when that same message (with the same text between them) comes before it again: the steady state for a room page. The layout around the messages was still compressed live, because every page carried a fresh CSRF token.

Cached page parts. Without CSRF tokens (see Known differences ), a page renders byte for byte the same until what it shows changes, so the layout can be stored too. A page is now split into parts that cover it end to end: its cached messages and the text between them. Each part is compressed once, against the part before it, and kept under the part's identity (the cached fragment, or the SHA-256 of the text) and its predecessor's; a message keeps pieces for the few predecessors it's seen with (its room, a page of older messages, search results). The ETag comes from the parts' digests instead of a SHA-256 over the whole body.

For a 466 KB room page, gzip and the ETag took ~1,200 µs per request at first, ~460 µs after splicing, and 42 µs now; the first request after a page changes pays ~2 ms, once, to compress its new parts. The decoded body is unchanged, and the compressed page is within 1% of compressing it whole.

Each step was measured natively against the commit before it, in its own session, so the columns come from different runs (the page-parts run on a busy host, which understates it). Details in bench/results/splice-20260927 , bench/results/header-csrf-20260927 and bench/results/page-parts-20260927 .

Running it

It's a drop-in replacement for the Rails image: the same environment variables, ports and storage layout. Point it at an existing Campfire's storage and everyone stays signed in.

With ONCE , on any server with Docker:

once deploy ghcr.io/basecamp/once-campfire-rust --host chat.example.com

ONCE provides the secrets, TLS, backups and upgrades. The image is published for amd64 and arm64: :latest and a version tag for each release , and :main for every change to main (see .github/workflows ).

Or with Docker alone:

docker run -d -p 80:80 -p 443:443 \ -e SECRET_KEY_BASE=... -e VAPID_PUBLIC_KEY=... -e VAPID_PRIVATE_KEY=... \ -e TLS_DOMAIN=chat.example.com \ -v campfire:/rails/storage \ ghcr.io/basecamp/once-campfire-rust

TLS: with TLS_DOMAIN set, the app gets and renews its own Let's Encrypt certificate. It keeps certificates where Thruster did, so an existing install keeps its certificate.

Plain HTTP: set DISABLE_SSL instead, for running behind another proxy.

Web Push: VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY are a P-256 key pair in URL-safe Base64 (as the Rails image takes them). They're checked at boot; without a valid pair, push notifications are off and the log says why. VAPID_SUBJECT is the contact push services see (a mailto: or https: URL); it defaults to https:// and your TLS_DOMAIN .

The app port: as with Puma behind Thruster, the app also answers on TARGET_PORT (3000) without the front server's cache and compression, but only on loopback. Set TARGET_BIND (e.g. 0.0.0.0 ) to open it further; it trusts X-Forwarded-* from whoever reaches it.

Storage: everything lives under /rails/storage : the SQLite database, uploaded files and backups.

Media: the image builds libvips 8.16.1 (thumbnails and other variants) and ffmpeg 7.1.5 (video posters, and ffprobe for video and audio metadata) from the Debian trixie source packages the Rails image installs, with the same flags and libraries, so thumbnails and posters are byte for byte the ones Rails makes. Only what Campfire can reach goes in: libvips loads PNG, GIF, JPEG, TIFF, WebP, AVIF and HEIC/HEIF (with EXIF orientation and ICC profiles) and saves PNG, JPEG, GIF and WebP; ffmpeg keeps every built-in demuxer and decoder plus dav1d for AV1, the filters that pick and orient a poster frame, and only the MJPEG encoder and image2 muxer that write it; other encoders and muxers, hardware, network and external codec libraries are left out. That took the image from 640 MB to 169 MB unpacked, and from 246 MB to 67 MB to download (see the Dockerfile ).

Many clients: every connected browser is a socket, and the app raises its open-file limit to the hard limit at startup (Docker's default soft limit would stop it at 65,536). Past that, it's memory (~15 KB per client) and bandwidth; see 100,000 clients .

ONCE hooks: /hooks/pre-backup runs campfire backup , which uses SQLite's online backup API.

Other options: see crates/campfire/src/config.rs .

To build the image yourself: docker build -t campfire-rust . (the reference/ submodule must be checked out). For development:

cargo test --workspace --exclude html5ever # all crates cargo run -p campfire -- server # needs SECRET_KEY_BASE or SECRET_KEY_BASE_DUMMY=1

Verifying changes

parity/bin/reference build && parity/bin/candidate build # Rails and Rust images parity/bin/seed build # seed data, generated by the Rails app parity/bin/candidate compare # lean parity gate, Rust vs Rails parity/bin/compare --matrix full ... # full matrix, for release checks reference-tools/http_shape/sweep.py < rails-url > < rust-url > # response header shape bench/run # benchmark both apps bench/results/pi-100k-20260928/run100k.sh BIN LABEL # 100,000 cable clients (PI=1: a Pi 5's budget)

parity/SCREENS.md documents the screen inventory, masks and the flake policy. AGENTS.md describes the repository layout and working rules, and CONTRIBUTING.md how to propose changes. Report security issues as SECURITY.md describes.

Known differences

Deliberate:

Compressed WebSocket frames. The app accepts the permessage-deflate compression browsers offer (without context takeover, so each broadcast is compressed once for all of its subscribers); Rails' Action Cable doesn't negotiate it. The frames decode to the same messages.

No CSRF tokens. Forgery protection checks the Sec-Fetch-Site header browsers send, as Rails main's protect_from_forgery using: :header_only does, instead of per-request tokens. Writes are accepted from same-origin and same-site requests; cross-site ones, and HTTPS requests without the header, get a 422. The Origin check still applies. On plain HTTP, where browsers don't send the header, a missing one is accepted, and the SameSite=Lax session cookie and the Origin check protect writes. Pages have no csrf-token meta tag or authenticity_token fields, so they render byte for byte the same until what they show changes: ETags now match on revalidation, and a page's markup can be cached. Browsers from before 2023 that don't send the header (e.g. Safari before 16.4) can't submit forms over HTTPS. Tabs opened before an upgrade keep working: their tokens are ignored, and the header does the job.

Redis and Resque are gone. Jobs run in-process and are best-effort: a crash loses queued webhooks and pushes, as a Redis restart would under Rails. Each kind of job (pushes, webhooks, purges, ...) has its own queue and JOB_CONCURRENCY workers, so a slow bot's webhooks can't hold up push notifications.

Push subscriptions are kept through our own failures. Rails destroys a push subscription on any OpenSSL error, which includes a bad VAPID key and any TLS failure (an empty CA store, a skewed clock), so a configuration mistake deleted everyone's subscriptions on the next message. The VAPID keys are now checked once at boot (Web Push is off, with a log line, when they're missing or don't form a key pair), and a subscription is destroyed only when the push service answers 410 or 404 (RFC 8030; Rails keeps it on a 404) or its own key isn't a valid P-256 point.

Long messages still get push notifications. Rails puts the whole message in the notification, and one over about 4 KB fails to encrypt (a Web Push message holds 4096 bytes), so nobody is notified. The notification's body is now cut short with an ellipsis at 3 KB, and its title at 256 bytes.

The VAPID subject is configurable. Rails identifies every install to push services as mailto:support@37signals.com ; this uses VAPID_SUBJECT , or https:// and the first TLS_DOMAIN , or the project's URL.

Cookies are only sent when they change. Rails rewrites the session cookie, re-signs the session_token cookie and re-sets last_room on nearly every response. The session cookie is now written only when the session changed, and deleted once it's empty (it only holds the flash and a return-to URL); session_token is re-signed when the session's hourly activity refresh runs, which keeps its 20-year expiry rolling; last_room is set when it changes. An authenticated request whose session doesn't need that refresh also no longer passes through the database writer.

ETags aren't a digest of the body on pages made of cached messages (room, messages and search pages): they're a SHA-256 over the page's parts. Identical pages still get identical ETags, and any change gets a new one.

One more index. On boot the app adds index_messages_on_room_id_and_created_at to the Rails schema if it's missing (a one-time 49 ms for 236k messages). Rails' schema pages a room's messages through index_messages_on_room_id alone, which sorts the room's whole history for every page. The index is additive, so the database still works with the Rails image.

Leaner libvips and ffmpeg. The image builds both from the same Debian sources as the Rails image, leaving out what Campfire can't reach (see Running it ). Thumbnails, video posters and metadata come out byte for byte the same for every image and video format either image handles. libvips loses only loaders that Vips.block_untrusted already blocks (ImageMagick, SVG, PDF, JPEG XL, JPEG 2000, OpenEXR, FITS, Matlab, OpenSlide). ffmpeg loses the decoders and demuxers that come from external libraries with no built-in equivalent: tracker modules (libopenmpt), game-console music (libgme), JPEG XL and SVG frames, codec2 speech, teletext subtitles, and DASH/IMF manifests. Tracker modules and game-console music attached to a message are now stored without duration or bit rate, which Campfire never shows.

Limits where Rails had none, or raised. Request bodies other than file uploads are capped at 16 MiB (a 413), and so are Active Storage direct uploads, which Campfire's editor doesn't use: asking for a larger one is a 413. A QR code for more than a QR code can hold is a 422, not a 500. Page numbers are capped at a billion. A WebSocket connection holds up to 64 subscriptions with identifiers of up to 4 KiB, and a client that doesn't read what it's sent for 30 seconds is disconnected. Deactivating or banning a user closes their open connections once the change commits.

Link unfurling is bounded in time. Rails gives each connect and read of an unfurl 60 seconds, across up to 10 redirects and the image check. Now an unfurl gets 10 seconds in all and 5 per connect or read, and a page that takes longer unfurls nothing. At most 16 unfurls run at once, and only a meta tag's first 256 attributes are read.

Bot webhooks are bounded. A delivery gets 60 seconds in all, on top of Rails' 7 per connect or read; one that runs out answers "Failed to respond within 60 seconds", as a 7-second timeout answers with its own. A reply larger than 100 MB (after decompression) fails the delivery and posts nothing; Rails read replies of any size into memory.

Push deliveries are bounded in time. A push service gets 10 seconds per connect or read and 30 in all, where the web-push gem leaves Net::HTTP 's 60 seconds per step; a slow service would otherwise hold one of the few push workers for minutes.

The front server is stricter than Thruster. The app's own listener on TARGET_PORT binds loopback only (Puma bound every interface) and has the front's timeouts and MAX_REQUEST_BODY (see Running it ). The response cache counts its keys toward CACHE_SIZE , skips URIs longer than 2 KB, keys on the raw path (Thruster decoded it, so /a%2Fb and /a/b shared an entry), and lets range requests through to the app instead of answering them with a whole cached body.

Media is processed off the database writer. Rails saves a blob's row and then uploads its file after commit; here the upload is copied into storage first, straight from the request's tempfile, and deleted again if the save fails. Variants, video posters and analysis run on background threads (at most four at a time), and only their rows are written in a transaction, so a large image or video doesn't hold up other writes. A variant or poster is saved already analyzed, where Rails analyzes it in a job after commit; the rows end up the same. Two requests for the same missing variant may both transform it: the first to save wins and the other's file is deleted. ffmpeg is stopped after 60 seconds of drawing a poster and ffprobe after 30 seconds of reading a file, which Rails doesn't limit.

i think overall DHH means well but it would be better if he said “we choose to do this rewrite in X because [analysis of the problem and solution on engineering basis]” saying “we have unlimited tokens” (we don’t) and “just rewrite it in Rust” is doing a disservice to engineers

Been running a beta version of Campfire rewritten in Rust today! The code itself is still ugly as sin, and 6x as verbose, but the conversion was free*, and I never had to look at the Rust directly, so this is awesome 🤩 https://t.co/g5H3uDCHmd

Media posted by @dhh
Media posted by @dhh

Dude! We always knew Rust is faster. But we chose rails for different purposes. If you're done with rails, hand over the trademark and let the "losers" like me continue with what we actually love. And please don't come to next year's conf, esp don't do the keynote. Enjoy your unlimited tokens. The rest of us still have to deal with reality.

Been running a beta version of Campfire rewritten in Rust today! The code itself is still ugly as sin, and 6x as verbose, but the conversion was free*, and I never had to look at the Rust directly, so this is awesome 🤩 https://t.co/g5H3uDCHmd

Media posted by @dhh
Media posted by @dhh

"the conversion was free" Let that sink in How many of you can actually afford the tokens to do this? God this man keeps getting lower in my eyes.

Been running a beta version of Campfire rewritten in Rust today! The code itself is still ugly as sin, and 6x as verbose, but the conversion was free*, and I never had to look at the Rust directly, so this is awesome 🤩 https://t.co/g5H3uDCHmd

Media posted by @dhh
Media posted by @dhh

I’ve said it before and I’ll say it again: Rust is a *really* bad idea for any software you need to have working a few years from now (AI generated or not), or want to continue developing into the future. Not because Rust is ugly (which… it is). And not because Rust is, overwhelmingly, a cult (again… it is). But simply because Rust is still a moving target. The language is not yet “complete”… and neither is the compiler. Changes occur often enough (and in significant enough ways) to throw serious monkey wrenches into any sizable project. Once Rust stabilizes (which could happen quickly if the Cult of Rust…

Read full post

actually wanted it to), this problem will quickly go away. Until then I will be sticking with C. Or C++. Or, heck, any language that is more stable than Rust.

Been running a beta version of Campfire rewritten in Rust today! The code itself is still ugly as sin, and 6x as verbose, but the conversion was free*, and I never had to look at the Rust directly, so this is awesome 🤩 https://t.co/g5H3uDCHmd

Media posted by @dhh
Media posted by @dhh

This is the reason why I’ve disliked Ruby and Rails since I got exposed to it. I never was able to overcome the ridiculous slowness of it.

Been running a beta version of Campfire rewritten in Rust today! The code itself is still ugly as sin, and 6x as verbose, but the conversion was free*, and I never had to look at the Rust directly, so this is awesome 🤩 https://t.co/g5H3uDCHmd

Media posted by @dhh
Media posted by @dhh

Been running a beta version of Campfire rewritten in Rust today! The code itself is still ugly as sin, and 6x as verbose, but the conversion was free*, and I never had to look at the Rust directly, so this is awesome 🤩 https://t.co/g5H3uDCHmd

Media posted by @dhh
Media posted by @dhh
Continue with the primary sourceOpen github.com

Original title: GitHub - basecamp/once-campfire-rust: ONCE Campfire in Rust: one binary, the Rails app's data, 19–44× faster

Samuel Times preserves the original link so every selection remains auditable.