ONCE Campfire in Rust: one binary, the Rails app's data, 19–44× faster
Rust
70
180 commits
updated Sep 28, 2026
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.
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/)
| Crate | What it replaces |
|---|---|
rails_compat | Rails' signed and encrypted cookies, signed IDs, signed global IDs, Turbo stream names and bcrypt, byte-compatible with Rails so sessions carry over |
kit | Rack, Action Dispatch and Thruster, on Axum: Rails-style nested params, sessions, flash, format negotiation, forgery protection by Sec-Fetch-Site, ETags and gzip built from a page's cached parts, plus an in-process front server with TLS and ACME, HTTP/2 and Thruster's response cache |
db | Active Record over the existing schema (rusqlite), with the same callbacks, timestamps and STI values, and a Rails-compatible fixture loader |
richtext | The Action Text pipeline: sanitizing, mentions, opengraph embeds and autolinking, byte-identical to Rails on a 647-case corpus apart from the deliberate differences below |
storage | Active Storage: the same blob keys, disk layout, variants (libvips) and video previews (ffmpeg), with byte-identical thumbnails |
cable | The Action Cable protocol server and pub/sub, frame-for-frame with Rails, on a WebSocket implementation of its own that shares and compresses broadcasts |
assets | Propshaft and importmap-rails, with identical fingerprinted filenames and tags |
views | The ERB templates as Askama templates at the same paths, DOM-identical apart from the deliberate differences below |
routes | Path helpers from config/routes.rb |
campfire | Every controller, the channels, the jobs, Web Push, opengraph unfurling, bot webhooks and search |
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/)
Results when the port was finished, before it started to diverge:
| Check | Result |
|---|---|
| Rust vs Rails, full matrix (all engines, viewports, schemes and breakpoints, 5 seeds) | All 5,018 cells compared; every cell passes on the fixed build |
| Rust vs Rails, lean gate (the default per-change check) | 970/970, 0 flaky, nothing allowlisted |
| Response header shape, about 70 request types | 0 differences |
| Rollback | Rails boots on, reads, searches and edits a database the Rust app wrote |
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).
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.
| Route | Rails | Rust | Rust advantage |
|---|---|---|---|
| Room page | 215 req/s | 20,479 req/s | 95× |
Messages page (?before=) | 406 req/s | 23,365 req/s | 58× |
| Sidebar | 528 req/s | 12,642 req/s | 24× |
| Search | 385 req/s | 23,399 req/s | 61× |
| Post a message | 274 req/s | 5,452 req/s | 20× |
/up | 4,069 req/s | 136,228 req/s | 33× |
| Measurement | Rails | Rust | Rust advantage |
|---|---|---|---|
| Room page p50, one client | 10.3 ms | 0.19 ms | 54× |
| Room page p99, 64 clients | 515 ms | 5.1 ms | 101× |
| Post a message p99, one client | 13.4 ms | 1.67 ms | 8× |
| Post a message p99, 64 clients | 349 ms | 16.7 ms | 21× |
| Upload a 505 KB JPEG until its thumbnail is served | 132 ms | 29.4 ms | 4.5× |
| Measurement | Rails | Rust | Rust advantage |
|---|---|---|---|
| Deliveries per second, 100 clients | 7,892 | 312,272 | 40× |
| Deliveries per second, 1,000 clients | 10,771 | 503,302 | 47× |
| Deliveries per second, 5,000 clients | 11,930 | 595,886 | 50× |
| Deliveries per second, 10,000 clients | 9,585 | 638,688 | 67× |
| Post to all 1,000 clients received, p50 | 107 ms | 6.5 ms | 16× |
| Post to all 10,000 clients received, p50 | 1,171 ms | 40 ms | 29× |
| Post to all 10,000 clients received, p99 | 1,519 ms | 61 ms | 25× |
| Connect and subscribe 10,000 clients | 29.1 s | 2.4 s | 12× |
Every client subscribed in every run, for both apps.
| Measurement | Rails | Rust | Rust advantage |
|---|---|---|---|
Cold start (docker run until /up answers) | 2,567 ms | 149 ms | 17× |
| Idle memory (container) | 309 MB | 15 MB | 21× |
| App process, 1,000 idle cable clients (Pss) | 645 MB | 169 MB | 3.8× |
| App process, 10,000 idle cable clients (Pss) | 1,507 MB | 313 MB | 4.8× |
| App process, 10,000 cable clients under load (Pss) | 2,191 MB | 310 MB | 7.1× |
| Whole container, 10,000 cable clients under load (Pss) | 3,519 MB | 310 MB | 11× |
| Image size, unpacked | 933 MB | 169 MB | 5.5× |
| Image size, compressed download | 359 MB | 67 MB | 5.4× |
Rails' whole container adds Redis and Thruster to its app processes; the Rust app is one process.
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):
| Rust app | 898653e | v0.1.1 | Change |
|---|---|---|---|
| Room page, 16 clients | 6,002 req/s | 20,479 req/s | 3.4× |
| Search, 16 clients | 8,970 req/s | 23,399 req/s | 2.6× |
| Deliveries per second, 10,000 clients | 379,608 | 638,688 | 1.7× |
| Post to all 10,000 clients received, p50 | 42 ms | 40 ms | 1.05× |
| Idle memory (container) | 47 MB | 15 MB | 3.1× less |
| App process, 10,000 idle cable clients (Pss) | 582 MB | 313 MB | 1.9× less |
| App process, 10,000 cable clients under load (Pss) | 876 MB | 310 MB | 2.8× less |
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.
| At 100,000 clients | Before | After |
|---|---|---|
| Memory, idle | 3.2 GB | 1.5 GB |
| Memory, while fanning out | 5.9 GB | 1.6 GB |
| A message on the wire | ~10 KB | ~2.3 KB (compressed) |
| Posting while a message fans out to everyone, p50 | 637 ms | 43 ms |
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.
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:
| Change | Effect |
|---|---|
Look up a message's cached fragment before building its view, as Rails' cache does | Room page 989 → 1,664 req/s; messages page 1,116 → 2,234 req/s |
| gzip on the zlib-rs backend instead of miniz_oxide | Room page 662 → 955 req/s |
| Run SQLite WAL checkpoints on their own thread, off the writer | POST p99 at one client: 12.5 → 1.7 ms |
| Cache prepared statements for every query | POSTs about 10% faster |
| Share cached fragments instead of copying them; build stylesheet tags once per process | 6–20% less CPU per page |
| Cable: 4 KiB read buffers instead of zero-filling 128 KiB per read; encode each broadcast once and share it across subscribers; batch socket writes | 4.7× less CPU per delivery; half the latency and memory under fan-out |
| Fat LTO, one codegen unit, jemalloc | A further 5–14% per route |
| Splice precompressed messages into gzipped pages (below) | Room page 2,527 → 5,461 req/s; messages page 3,709 → 16,523 req/s; search 2,123 → 5,526 req/s |
Forgery protection by Sec-Fetch-Site instead of CSRF tokens (Known differences) | Room page +9%, messages page +6%, search +10%; pages render the same until their content changes, so revalidation gets a 304 |
Index messages by (room_id, created_at); check "more than a page" without counting the room | In a room with 236k messages: room page 95 → 6,051 req/s (64×), messages page 87 → 17,972 req/s (208×). Before, a room page sorted the room's whole history, so rooms slowed as they grew; now a long room serves as fast as a new one |
| Cache every part of a page, not just its messages, and take the ETag from the parts (below); send cookies only when they change | Room page 2.9×, search 2.8×, messages page 1.3× |
| Cable: own WebSocket framing with shared, once-compressed frames; connections on their own runtime (above) | 100,000 clients in 1.6 GB instead of 5.9 GB while fanning out; a post during a 100,000-client fan-out 637 → 43 ms; frames 10 KB → 2.3 KB on the wire |
No transparent huge pages for the process or jemalloc (bench/results/thp-20260928) | Idle memory 37 → 11 MB on two cores and 160 → 15 MB on 32, where the kernel's THP setting is always; throughput unchanged |
Against Rails, the room page went from 4.4× in the preliminary benchmark to 95× in the latest one.
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:
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.
| Route (16 clients) | Before | Spliced gzip | Cached page parts |
|---|---|---|---|
| Room page | 2,527 req/s | 5,461 req/s | 16,881 req/s |
Messages page (?before=) | 3,709 req/s | 16,523 req/s | 22,580 req/s |
| Search | 2,123 req/s | 5,526 req/s | 16,097 req/s |
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.
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_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.DISABLE_SSL instead, for running behind another proxy.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.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./rails/storage: the SQLite database, uploaded files and
backups.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)./hooks/pre-backup runs campfire backup, which uses SQLite's online backup API.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
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.
Deliberate:
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.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.JOB_CONCURRENCY workers, so a slow bot's webhooks can't hold
up push notifications.mailto:support@37signals.com; this uses VAPID_SUBJECT, or https:// and the first
TLS_DOMAIN, or the project's URL.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.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.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.meta tag's first 256 attributes are read.Net::HTTP's 60 seconds per step; a slow service would
otherwise hold one of the few push workers for minutes.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.MATCH as they
are, so NOT, AND, OR or NEAR in the wrong place is a 500. Each word is now matched as
itself./rooms/directs/:id redirects to the room instead of answering 500.Edge/), Rails answers
profile and room pages with a 500 because the partial names an image that isn't there
(install-edge.svg); the Rust app ships it.< and > left
raw in attribute values. A URL after a > in, say, a title was taken for text and linked, and
the inserted <a href="..."> closed the attribute, turning the rest of its value into live
markup (a stored XSS; it affects the Rails app). The port escapes < and > in attribute values
before autolinking, so URLs inside attributes stay as they were. The same DOM otherwise.data-copy-to-clipboard-url-value), and the copy-to-clipboard controller (an override) makes it
absolute against the page. The bot API's cached JSON, whose URLs must be absolute, is cached per
base URL instead.name attributes. Rails' default sanitizer allowlist keeps them, which lets
a message clobber the page's DOM globals (<img name="body"> shadows document.body). Nothing
Campfire's composer writes has one.style. Where Rails runs style through Loofah's
CSS scrubber, the sanitizer keeps only color and background-color with a plain color value
(a keyword, hex, rgb()/hsl(), or a custom property like Lexxy's var(--highlight-1)), which
is all Lexxy writes. It shows in the HTML body the bot API and webhooks send; message pages drop
style altogether, as they did.webmanifest.json, so a name with \ or " broke the manifest and the small logo's URL read
?size=small&v=.... They're JSON strings now.<action-text-attachment> carrying HTML in its
content renders that content, attachments included; each level parses and sanitizes
everything below it again, so a 336 KB body of nested ones took 10 seconds to render. Deeper
levels now render empty. Campfire's composer doesn't nest them at all.session_token cookie Rails' Active Storage streaming sends; and
legacy AES-CBC encrypted cookies, since Campfire started on GCM.Not fully covered:
The port was built in about a day by coordinated Claude Code agents, each owning one crate or
harness component. They followed the plan in plans/rust-conversion.md, which Codex also reviewed.
plans/overnight-report.md logs the unattended overnight run: the
parity gate going green, the Thruster replacement, the benchmarks, and each optimization with its
before and after numbers. The optimizations and divergences since then were made the same way, one
pull request each, with their measurements in bench/results/.
MIT, like Campfire. See MIT-LICENSE.
Rust
45.7%
HTML
43.6%
Ruby
3.6%
TypeScript
2.9%
Python
1.9%
Shell
1.6%
ONCE Campfire in Rust: one binary, the Rails app's data, 19–44× faster
Rust
70
180 commits
updated Sep 28, 2026
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.
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/)
| Crate | What it replaces |
|---|---|
rails_compat | Rails' signed and encrypted cookies, signed IDs, signed global IDs, Turbo stream names and bcrypt, byte-compatible with Rails so sessions carry over |
kit | Rack, Action Dispatch and Thruster, on Axum: Rails-style nested params, sessions, flash, format negotiation, forgery protection by Sec-Fetch-Site, ETags and gzip built from a page's cached parts, plus an in-process front server with TLS and ACME, HTTP/2 and Thruster's response cache |
db | Active Record over the existing schema (rusqlite), with the same callbacks, timestamps and STI values, and a Rails-compatible fixture loader |
richtext | The Action Text pipeline: sanitizing, mentions, opengraph embeds and autolinking, byte-identical to Rails on a 647-case corpus apart from the deliberate differences below |
storage | Active Storage: the same blob keys, disk layout, variants (libvips) and video previews (ffmpeg), with byte-identical thumbnails |
cable | The Action Cable protocol server and pub/sub, frame-for-frame with Rails, on a WebSocket implementation of its own that shares and compresses broadcasts |
assets | Propshaft and importmap-rails, with identical fingerprinted filenames and tags |
views | The ERB templates as Askama templates at the same paths, DOM-identical apart from the deliberate differences below |
routes | Path helpers from config/routes.rb |
campfire | Every controller, the channels, the jobs, Web Push, opengraph unfurling, bot webhooks and search |
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/)
Results when the port was finished, before it started to diverge:
| Check | Result |
|---|---|
| Rust vs Rails, full matrix (all engines, viewports, schemes and breakpoints, 5 seeds) | All 5,018 cells compared; every cell passes on the fixed build |
| Rust vs Rails, lean gate (the default per-change check) | 970/970, 0 flaky, nothing allowlisted |
| Response header shape, about 70 request types | 0 differences |
| Rollback | Rails boots on, reads, searches and edits a database the Rust app wrote |
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).
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.
| Route | Rails | Rust | Rust advantage |
|---|---|---|---|
| Room page | 215 req/s | 20,479 req/s | 95× |
Messages page (?before=) | 406 req/s | 23,365 req/s | 58× |
| Sidebar | 528 req/s | 12,642 req/s | 24× |
| Search | 385 req/s | 23,399 req/s | 61× |
| Post a message | 274 req/s | 5,452 req/s | 20× |
/up | 4,069 req/s | 136,228 req/s | 33× |
| Measurement | Rails | Rust | Rust advantage |
|---|---|---|---|
| Room page p50, one client | 10.3 ms | 0.19 ms | 54× |
| Room page p99, 64 clients | 515 ms | 5.1 ms | 101× |
| Post a message p99, one client | 13.4 ms | 1.67 ms | 8× |
| Post a message p99, 64 clients | 349 ms | 16.7 ms | 21× |
| Upload a 505 KB JPEG until its thumbnail is served | 132 ms | 29.4 ms | 4.5× |
| Measurement | Rails | Rust | Rust advantage |
|---|---|---|---|
| Deliveries per second, 100 clients | 7,892 | 312,272 | 40× |
| Deliveries per second, 1,000 clients | 10,771 | 503,302 | 47× |
| Deliveries per second, 5,000 clients | 11,930 | 595,886 | 50× |
| Deliveries per second, 10,000 clients | 9,585 | 638,688 | 67× |
| Post to all 1,000 clients received, p50 | 107 ms | 6.5 ms | 16× |
| Post to all 10,000 clients received, p50 | 1,171 ms | 40 ms | 29× |
| Post to all 10,000 clients received, p99 | 1,519 ms | 61 ms | 25× |
| Connect and subscribe 10,000 clients | 29.1 s | 2.4 s | 12× |
Every client subscribed in every run, for both apps.
| Measurement | Rails | Rust | Rust advantage |
|---|---|---|---|
Cold start (docker run until /up answers) | 2,567 ms | 149 ms | 17× |
| Idle memory (container) | 309 MB | 15 MB | 21× |
| App process, 1,000 idle cable clients (Pss) | 645 MB | 169 MB | 3.8× |
| App process, 10,000 idle cable clients (Pss) | 1,507 MB | 313 MB | 4.8× |
| App process, 10,000 cable clients under load (Pss) | 2,191 MB | 310 MB | 7.1× |
| Whole container, 10,000 cable clients under load (Pss) | 3,519 MB | 310 MB | 11× |
| Image size, unpacked | 933 MB | 169 MB | 5.5× |
| Image size, compressed download | 359 MB | 67 MB | 5.4× |
Rails' whole container adds Redis and Thruster to its app processes; the Rust app is one process.
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):
| Rust app | 898653e | v0.1.1 | Change |
|---|---|---|---|
| Room page, 16 clients | 6,002 req/s | 20,479 req/s | 3.4× |
| Search, 16 clients | 8,970 req/s | 23,399 req/s | 2.6× |
| Deliveries per second, 10,000 clients | 379,608 | 638,688 | 1.7× |
| Post to all 10,000 clients received, p50 | 42 ms | 40 ms | 1.05× |
| Idle memory (container) | 47 MB | 15 MB | 3.1× less |
| App process, 10,000 idle cable clients (Pss) | 582 MB | 313 MB | 1.9× less |
| App process, 10,000 cable clients under load (Pss) | 876 MB | 310 MB | 2.8× less |
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.
| At 100,000 clients | Before | After |
|---|---|---|
| Memory, idle | 3.2 GB | 1.5 GB |
| Memory, while fanning out | 5.9 GB | 1.6 GB |
| A message on the wire | ~10 KB | ~2.3 KB (compressed) |
| Posting while a message fans out to everyone, p50 | 637 ms | 43 ms |
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.
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:
| Change | Effect |
|---|---|
Look up a message's cached fragment before building its view, as Rails' cache does | Room page 989 → 1,664 req/s; messages page 1,116 → 2,234 req/s |
| gzip on the zlib-rs backend instead of miniz_oxide | Room page 662 → 955 req/s |
| Run SQLite WAL checkpoints on their own thread, off the writer | POST p99 at one client: 12.5 → 1.7 ms |
| Cache prepared statements for every query | POSTs about 10% faster |
| Share cached fragments instead of copying them; build stylesheet tags once per process | 6–20% less CPU per page |
| Cable: 4 KiB read buffers instead of zero-filling 128 KiB per read; encode each broadcast once and share it across subscribers; batch socket writes | 4.7× less CPU per delivery; half the latency and memory under fan-out |
| Fat LTO, one codegen unit, jemalloc | A further 5–14% per route |
| Splice precompressed messages into gzipped pages (below) | Room page 2,527 → 5,461 req/s; messages page 3,709 → 16,523 req/s; search 2,123 → 5,526 req/s |
Forgery protection by Sec-Fetch-Site instead of CSRF tokens (Known differences) | Room page +9%, messages page +6%, search +10%; pages render the same until their content changes, so revalidation gets a 304 |
Index messages by (room_id, created_at); check "more than a page" without counting the room | In a room with 236k messages: room page 95 → 6,051 req/s (64×), messages page 87 → 17,972 req/s (208×). Before, a room page sorted the room's whole history, so rooms slowed as they grew; now a long room serves as fast as a new one |
| Cache every part of a page, not just its messages, and take the ETag from the parts (below); send cookies only when they change | Room page 2.9×, search 2.8×, messages page 1.3× |
| Cable: own WebSocket framing with shared, once-compressed frames; connections on their own runtime (above) | 100,000 clients in 1.6 GB instead of 5.9 GB while fanning out; a post during a 100,000-client fan-out 637 → 43 ms; frames 10 KB → 2.3 KB on the wire |
No transparent huge pages for the process or jemalloc (bench/results/thp-20260928) | Idle memory 37 → 11 MB on two cores and 160 → 15 MB on 32, where the kernel's THP setting is always; throughput unchanged |
Against Rails, the room page went from 4.4× in the preliminary benchmark to 95× in the latest one.
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:
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.
| Route (16 clients) | Before | Spliced gzip | Cached page parts |
|---|---|---|---|
| Room page | 2,527 req/s | 5,461 req/s | 16,881 req/s |
Messages page (?before=) | 3,709 req/s | 16,523 req/s | 22,580 req/s |
| Search | 2,123 req/s | 5,526 req/s | 16,097 req/s |
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.
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_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.DISABLE_SSL instead, for running behind another proxy.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.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./rails/storage: the SQLite database, uploaded files and
backups.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)./hooks/pre-backup runs campfire backup, which uses SQLite's online backup API.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
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.
Deliberate:
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.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.JOB_CONCURRENCY workers, so a slow bot's webhooks can't hold
up push notifications.mailto:support@37signals.com; this uses VAPID_SUBJECT, or https:// and the first
TLS_DOMAIN, or the project's URL.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.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.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.meta tag's first 256 attributes are read.Net::HTTP's 60 seconds per step; a slow service would
otherwise hold one of the few push workers for minutes.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.MATCH as they
are, so NOT, AND, OR or NEAR in the wrong place is a 500. Each word is now matched as
itself./rooms/directs/:id redirects to the room instead of answering 500.Edge/), Rails answers
profile and room pages with a 500 because the partial names an image that isn't there
(install-edge.svg); the Rust app ships it.< and > left
raw in attribute values. A URL after a > in, say, a title was taken for text and linked, and
the inserted <a href="..."> closed the attribute, turning the rest of its value into live
markup (a stored XSS; it affects the Rails app). The port escapes < and > in attribute values
before autolinking, so URLs inside attributes stay as they were. The same DOM otherwise.data-copy-to-clipboard-url-value), and the copy-to-clipboard controller (an override) makes it
absolute against the page. The bot API's cached JSON, whose URLs must be absolute, is cached per
base URL instead.name attributes. Rails' default sanitizer allowlist keeps them, which lets
a message clobber the page's DOM globals (<img name="body"> shadows document.body). Nothing
Campfire's composer writes has one.style. Where Rails runs style through Loofah's
CSS scrubber, the sanitizer keeps only color and background-color with a plain color value
(a keyword, hex, rgb()/hsl(), or a custom property like Lexxy's var(--highlight-1)), which
is all Lexxy writes. It shows in the HTML body the bot API and webhooks send; message pages drop
style altogether, as they did.webmanifest.json, so a name with \ or " broke the manifest and the small logo's URL read
?size=small&v=.... They're JSON strings now.<action-text-attachment> carrying HTML in its
content renders that content, attachments included; each level parses and sanitizes
everything below it again, so a 336 KB body of nested ones took 10 seconds to render. Deeper
levels now render empty. Campfire's composer doesn't nest them at all.session_token cookie Rails' Active Storage streaming sends; and
legacy AES-CBC encrypted cookies, since Campfire started on GCM.Not fully covered:
The port was built in about a day by coordinated Claude Code agents, each owning one crate or
harness component. They followed the plan in plans/rust-conversion.md, which Codex also reviewed.
plans/overnight-report.md logs the unattended overnight run: the
parity gate going green, the Thruster replacement, the benchmarks, and each optimization with its
before and after numbers. The optimizations and divergences since then were made the same way, one
pull request each, with their measurements in bench/results/.
MIT, like Campfire. See MIT-LICENSE.
Rust
45.7%
HTML
43.6%
Ruby
3.6%
TypeScript
2.9%
Python
1.9%
Shell
1.6%