Campfire on Ruby 4 Ractors:
where the speed comes from
A port of ONCE Campfire that keeps Ruby but drops Rails. It serves byte-identical HTML from one process: Falcon running in worker Ractors, plain modules over SQLite, and message passing between Ractors instead of Redis. On the same 4 CPU threads it serves dynamic pages 15–27× faster than the Rails app it was ported from, and beats the Go port on most of them.
1 · The shapeOne process, many Ractors, no Redis
Rails Campfire runs Thruster, several Puma processes, Resque workers and Redis, which carries Action Cable broadcasts and the job queue. The port follows the Go port's architecture instead: one module per concern over SQLite, with queues and the cable hub inside the process. In Ruby 4 the pieces that need to run in parallel are Ractors. Each one has its own lock, its own heap and its own SQLite connection. They talk only by sending frozen messages, which cross between Ractors without being copied. Pick a flow to watch it run.
2 · What got deletedKeep the wire contracts, drop the framework
The browser and the database can't tell the difference: same cookies, same CSRF tokens, same signed ids, same Action Cable frames, same Active Storage URLs, the same SQLite file. Everything between them is new, smaller code. The Rust repo's parity suite checks the result: 956 of 956 checks pass across five seeds (HTML, DOM, accessibility tree, assets, Cable frames and screenshots).
3 · ReadsOne snapshot per request
A room page runs 5–30 queries. In autocommit mode, SQLite opens a read transaction for each one:
WAL-index locks, an fstat, cache validation. That costs about 4.5 µs. Inside an open transaction the
same point query takes about 0.2 µs. So every GET runs in one BEGIN … COMMIT, and the first write
in a request commits the snapshot first, so a stale read can never be upgraded into a write.
4 · RenderingAppend, cache, and splice the gzip
Templates compile to Ruby methods that append into one pre-sized String per response. There's no
interpolation, no join and no intermediate arrays, and ids come out of SQL as text. Message fragments are
cached per Ractor and keyed like Rails' cache calls. But the room page is about 460 KB of HTML, and
gzipping it took 1.9 ms of a 2.4 ms request. The fix, adapted from the Rust port: deflate
each cached fragment once, using the fragments before it as the dictionary, flushed to a byte boundary.
At request time, only the layout around the fragments (which carries the per-request CSRF tokens) is compressed;
the cached pieces are copied straight into the stream.
5 · WritesA single writer, handed out in arrival order
SQLite allows one writer at a time. Left to the file lock, worker Ractors raced for it: BEGIN IMMEDIATE,
SQLITE_BUSY, sleep, retry. Retries that kept losing waited 100 ms or more while others walked straight in, and the lock
sat idle between sleepers. Now a writer Ractor hands out the write permit first-come, first-served.
Connection#transaction takes it without callers changing anything, and only the waiting fiber suspends, so the
rest of that Ractor keeps serving. The SQL itself still runs on the caller's own connection, so passing the permit costs
two messages per transaction rather than a round trip per statement.
Rails callbacks become SQLite triggers
touch: true on boosts and messages, Room#receive marking memberships unread, dependent deletes and
the search-history trim now run as triggers inside the write that causes them. A post is one short
transaction with three statements from Ruby. One rule keeps them honest: triggers never read the clock.
SQLite's clock only has millisecond precision and could move updated_at backwards, so a trigger only copies times from the row being written.
Checkpoints off the request path
SQLite's auto-checkpoint runs inside whichever COMMIT crosses 1,000 WAL pages, and flushes the database to disk
right there: about every 30th post took 10–20 ms instead of 0.4. Request connections now never checkpoint. A thread in
the otherwise idle main Ractor runs a non-blocking PASSIVE checkpoint every 250 ms, and a short
RESTART (holding the writer's permit) once the WAL passes about 32 MB.
6 · The ledgerWhat each change bought
Measured on a local benchmark pinned to the same 4 CPUs, before and after each round of changes. These are not the Docker bench numbers further down, which are higher on every route.
7 · The resultAgainst Rails, Go and Rust
The bench harness is once-campfire-rust's
bench/run with Go and Ruby added. All four are unmodified production images on the same seed, each pinned to 4 hardware
threads of a Threadripper PRO 7975WX, with the load generator on 4 others. Values are medians of three interleaved reps.
Each route has its own linear scale; hover over a bar for its latency.
8 · Ruby headThe same code on ruby/ruby master
Same app, same harness, same CPUs: the release image against one built from
ruby/ruby master (Dockerfile.head),
run once with YJIT and once with ZJIT, the new method-based JIT. No application changes. Pages where Ruby does most of the work gain the most (the messages page 1.5×). The sidebar,
which spends its time in zlib, barely moves. Peak memory drops by about 40%. Posts are bound by the single writer and don't move. ZJIT works with Ractors and runs within a few percent of YJIT.
Medians of three reps: 4.0.7 and head · YJIT interleaved (bench/results/ruby-head-zjit-20261005), head · ZJIT rerun on its own with each container's boot line saved, all three reading JIT: ZJIT (bench/results/ruby-zjit-20261005). Head's cold start is slower (2.9 s against 1.0 s), likely
from the Bundler 4.1 beta it ships with. Cable p99 swings between runs on every build, so read the throughput, not the tail. One trap on the way:
the first ZJIT run was really YJIT. Master's Bundler re-executed the server under the lockfile's older Bundler, which dropped the
--zjit flag, so the head image now sets BUNDLE_VERSION=system.