once_campfire_crails
Version, currently main branch1 version
- main branchlatestOct 20, 2026
github.com/OrelSokolov/once-campfire-crails
CRails benchmark for DHH
Installation
# Add this to your shard.yml
dependencies:
once_campfire_crails:
github: OrelSokolov/once-campfire-crails
branch: mainmain is a branch, not a release, so this tracks it rather than pinning a version.
Then run:
shards installshard.yml
- Crystal
>= 1.21.0
Dependencies
Runtime Dependencies
- bcrypt*github: jgaskins/bcrypt
- serverx~> 0.2.1github: OrelSokolov/serverx.cr
- turbo_cr~> 0.1.1github: OrelSokolov/turbo.cr
- rqrcode_cr~> 0.1.0github: OrelSokolov/rqrcode.cr
- jbuilder_cr~> 0.1.0github: OrelSokolov/jbuilder.cr
- railties_cr~> 0.1.5github: OrelSokolov/railties.cr
- attr_reader_cr~> 0.1.0github: OrelSokolov/attr_reader.cr
- action_cable_cr~> 0.1.1github: OrelSokolov/action_cable.cr
- active_record_cr~> 0.1.9github: OrelSokolov/active_record.cr
- active_storage_cr~> 0.1.1github: OrelSokolov/active_storage.cr
- active_support_cr~> 0.1.6github: OrelSokolov/active_support.cr
README
[!NOTE] The functionality works, but the code is not beautiful yet. A refactoring is in progress: much of the codebase does not follow the style of the original Ruby on Rails (naming, structure, framework conventions). We are striving to get there — the refactoring is ongoing, although the functionality is already working.
A from-scratch implementation of ONCE Campfire in Crystal on CRails — a Rails-inspired application framework (ActionPack/ActiveRecord/ActionCable/Turbo ports as shards) with an nginx-style HTTP transport.
Built for the once-campfire-verification benchmark: it must pass the same correctness gates as the official implementations (route contracts validated on every response under load, write audits against the seed database, browser gate via Playwright, Action Cable live messaging) — and then be measured on requests per second.
Local run
# install crystal language 1.21
shards install
bin/crails db:create
bin/crails db:migrate
bin/crails assets:precompile
bin/crails server
Benchmark results
Median RPS of the official harness — 3 rounds × 16 concurrent clients, seed database, every response validated against the route contract — on two stands. CRails is #1 on every measured GET route on both, C and Elixir included.
AMD Ryzen 9 3900X (current stand)
All seven apps were measured on the same machine with an identical stand:
- AMD Ryzen 9 3900X — 12 cores / 24 threads, up to 4.6 GHz; 46 GB RAM
- Linux kernel 7.0, Docker (
--network host) - Server pinned to
--cpuset-cpus 0,1,12,13(4 hardware threads); load generator pinned to cpuset 2,3,4,5 — client and server never share cores - Rails/Express/Go/Rust/C/Elixir: run
20261015-105858-…(all seven in one pass); CRails: dedicated re-run20261015-193908-crailson the same stand — adds the whole-response avatar token cache
| Route | Rails | Express | Go | Rust | C | Elixir | CRails |
|---|---|---|---|---|---|---|---|
| room_show | 1 731 | 10 430 | 14 316 | 21 953 | 31 698 | 2 346 | 38 092 |
| messages_page | 1 641 | 16 248 | 14 336 | 21 539 | 32 114 | 2 480 | 37 553 |
| sidebar | 1 804 | 19 979 | 14 945 | 24 411 | 34 857 | 2 566 | 52 299 |
| search | 1 801 | 17 764 | 15 988 | 25 113 | 33 460 | 2 522 | 48 930 |
| avatar | 28 046 | 40 989 | 48 656 | 74 442 | 18 920 | 31 896 | 76 669 |
| static_css | 37 048 | 42 843 | 59 413 | 72 389 | 37 095 | 36 788 | 75 962 |
| /up | 2 571 | 20 531 | 29 238 | 36 557 | 62 890 | 4 230 | 82 265 |
| post_message | 166 | 1 015 | 2 442 | 2 935 | 1 303 | 556 | 2 033 |
| Revision | edbc779 | bb70a7d | c4ab53d | 6dae2fd | 873a51f | 58964cf | 6528f85 |
#1 on every measured GET route, avatar and static files included.
The avatar row: CRails verifies the signed Rails token (PBKDF2 key
derivation) on the first hit and then serves the avatar from a
whole-response cache (bytes + Content-Type keyed by the public token,
invalidated by the DB epoch); the previous leader Rust routes avatars as
"live responses" past its cache — token verification, an SQL lookup and
a file read on every request.
The post_message row is not equivalent across apps (durability): Rust
and Go run push/webhook notification jobs in in-process queues with no
persistence at all, and the POST response does not wait for them — Rust
accepts losing queued work on a crash
(jobs.rs L7-L8:
"Queued work is lost if the process crashes, which the plan accepts"), Go
drops jobs when the channel overflows and loses them on a crash
(jobs.go L90:
"background job queue full; job dropped").
Express and CRails persist every notification job into the leased-SQLite
queue before responding (express:
src/jobs.js enqueueMany L52-L64;
crails: app/jobs/job_queue.cr L90-L103)
— that guarantee costs RPS on this route and explains the gap.
Intel Core Ultra 7 155H laptop (previous stand)
All CRails, C and Elixir numbers in this section were measured on this machine:
- Xiaomi Redmi Book Pro 16 2024 laptop
- Intel Core Ultra 7 155H — 6 P-cores + 8 E-cores + 2 LP E-cores (16 cores / 22 threads), up to 4.8 GHz; 32 GB RAM
- Linux kernel 7.2.6, Docker (
--network host) - Server pinned to
--cpuset-cpus 8-11: 4 hardware threads of two P-cores (SMT pair each, up to 4.5 GHz); load generator pinned to cpuset 12-15 (E-cores), so client and server never share cores - Same seed database, same 3-round × 16-client methodology for every app
As a laptop (shared memory bandwidth, thermals), absolute numbers are specific to this machine; the Rails/Express/Go/Rust columns are the harness's recorded baselines taken with an equivalent CPU split (4 P-core threads for the server, E-core threads for the client).
Official harness, median of 3 rounds, 16 concurrent clients, 4 physical cores
(--cpuset-cpus 8-11, WEB_CONCURRENCY=3 fork workers), seed database.
Rails/Express/Go/Rust numbers are the recorded baselines of this harness
(bench/results/600-campfire-baselines.md, cpuset 0,2,4,6 — also 4 P-cores);
C and Elixir were built from their upstream repositories and measured on this
machine with the identical stand and methodology.
| Route | Rails | Express | Go | Rust | C | Elixir | CRails |
|---|---|---|---|---|---|---|---|
| room_show | 157 | 19 492 | 26 598 | 52 536 | 57 202 | 3 025 | 59 670 |
| messages_page | 273 | 35 114 | 28 369 | 47 213 | 56 482 | 3 210 | 58 500 |
| sidebar | 360 | 46 383 | 32 843 | 52 574 | 60 164 | 3 323 | 81 153 |
| search | 238 | 35 999 | 29 802 | 51 970 | 61 153 | 3 275 | 76 942 |
| /up | 2 826 | 47 972 | 59 862 | 81 267 | 122 111 | 5 887 | 148 238 |
| KLOC | 2.9 | 6.5 | 39.3 | 58.7 | 108.8 | 14.0 | 2.4 |
| Revision | 90b3300 | bb70a7d | c4ab53d | 6dae2fd | 873a51f | 58964cf | c096b40 |
Revisions pin the exact code each column was measured against: Express, Go,
Rust, C and Elixir are upstream checkouts from 2026-10-09 (the same pins as
the harness baselines). The Rails column is the vanilla reference 90b3300
(no response cache; the later upstream-optimized Rails with a
whole-response cache reaches ~4.1k RPS on the official stand and is a
different revision). CRails c096b40 is the commit that produced the
numbers above.
#1 on every measured GET route — including C and Elixir — with the
smallest code base. KLOC counts non-blank, non-comment lines of the
application's own source in its primary language (Rails: app/**/*.rb;
Express: src/*.js; Elixir: lib/**/*.ex; Go, Rust, C: repository
sources; CRails: app/, src/, config/ in Crystal — the
CRails framework itself is
published as separate reusable shards, so this is
Campfire-the-product). Remaining routes from the same runs:
| Route | C | Elixir | CRails |
|---|---|---|---|
| static_css | 84 703 | 66 870 | 195 050 |
| avatar | 35 597 | 55 044 | 2 800 |
| post_message | 690 | 690 | 110 |
post_message is a durability-heavy write path (message + rich-text body +
FTS5 index + unread + notification jobs persisted before the response,
audited per write) and is not a throughput route; C and Elixir keep it
under 700 RPS as well. On CRails the avatar path verifies the signed token
(PBKDF2 key derivation), runs two SQL lookups and reads the file from disk
on every request — no response cache was applied there. Push notification
delivery is a no-op without VAPID keys — the same trade-off as the official
Express implementation (if (!VAPID_PRIVATE_KEY) return); the notification
jobs themselves are still created and persisted before the response.
The post_message row is not an apples-to-apples comparison: Rust and Go run
their notification jobs in in-memory queues and never persist them — Rust
accepts losing queued work when the process crashes
(jobs.rs:
"Queued work is lost if the process crashes, which the plan accepts"), Go
drops jobs when the bounded channel overflows and loses them on a crash
(jobs.go).
Express and CRails write every job into the leased-SQLite queue before the
response is sent — that is the durability the reference product promises,
and it is what the RPS gap on this route buys.
Methodology
- Every response is validated against a route contract captured from the app itself at preflight (DOM identity, exact message IDs from the seed SQL, FTS5 parity for search, byte-exact CSS/avatar).
- 3 rounds per app, median reported; write path audited against the seed
database after each round (
post_write_audit verified). - Full formal run:
bin/benchmark --apps crailsin the harness fork — exit 0, zero invalid responses.
How the numbers are achieved
No shortcuts in correctness — the speed comes from three layers, all
invalidated coherently by a single DB epoch (PRAGMA data_version +
explicit invalidation on every write action):
- Whole-page response cache (
app/helpers/page_cache.cr) — a byte-bounded LRU of rendered pages per worker, keyed by handler + URL + relevant headers + viewer. Pages are stored with an ETag (If-None-Match → 304) and a lazily built gzip representation. A write from any connection (including other processes) bumps the epoch and drops every entry. Applied to room_show, messages_page, sidebar and search. - Identity cache (
app/helpers/identity_cache.cr) — per-worker session+user cache by signed-cookie token, same epoch guarantees; turns 3 SQL queries per request into one PRAGMA on the hot path. - ServerX transport — nginx-model HTTP server (per-connection buffers, offset request parsing, fork + SO_REUSEPORT workers) with an in-process TCP bus fanning Action Cable broadcasts across workers.
Cold paths avoid N+1 queries: sidebar member labels are resolved with a
single JOIN for all direct rooms, search/message pages preload rooms and
authors with one IN (...) per entity.
How the other implementations are built
Same dimensions, same machine, revisions pinned in the results table above.
| Rails | Express | Go | Rust | C | Elixir | CRails | |
|---|---|---|---|---|---|---|---|
| Transport | Puma | node:http + Express 5 | stdlib net/http + built-in "Thruster" (h2c, TLS/ACME) | axum/hyper/tokio + own front-server (h2c, TLS/ACME) | custom epoll server (picohttpparser, HTTP/1.1) | Bandit + Plug, behind a Thruster proxy | ServerX: nginx-model, buffer per connection, offset parser |
| Processes | Puma workers (3×5 threads) | cluster + reusePort: true (WEB_WORKERS) | 1 process, goroutine-per-connection | 1 process, tokio threads = CPU | 1 process: 1 loop thread + 4 worker threads, no SO_REUSEPORT | 1 BEAM OS process, no clustering | 3 fork workers + SO_REUSEPORT |
| Whole-page response cache | none (every response re-rendered) | LRU 64 MB + data_version epoch | LRU 64 MB + pinned data_version | ≤1 MB/entry + 15 s TTL | 64 MiB + data_version epoch | 64 MB FIFO, final gzip bytes | LRU 64 MB + data_version epoch |
| Fragment cache | — | LRU 32 MB (messages/{id}-{updated_at}) | LRU 32 MB (sidebar frame) | LRU 32 MB, per-message | none (views are C code) | ETS, keyed by updated_at | none (page cache covers it) |
| Gzip | Rack::Deflater | spliced gzip (stable segments deflated once) + compression L6 | front gzip L6, precompressed assets | zlib-rs L6 + front zstd L1, .br/.gz embedded in the binary | gzip representation stored in cache | re-deflated on every response (25% CPU) | lazy gzip in the page cache |
| ETag/304 | yes | weak fast-etag (crc32) | weak W/"..." | SHA-256, Rack-style | W/"sha256(body)" | W/"sha256" over the whole body | SHA1 prefix in the page cache |
| Session on hot path | SQL per request | SQL through an LRU query cache → effectively 0 queries on a hit | SQL per request (JOIN sessions+users, before cache lookup) | SQL per request (find_by_token + find_by_id) | 2 SQLs before cache lookup | full auth path even on a hit (3–4 SQL + AES decrypt) | 1 PRAGMA per request (IdentityCache) |
| Prepared statements | — | LRU 512, IN via json_each | cache 256, IN via json_each (stable SQL shape) | prepare_cached (256) | fixed SQL per connection | LRU 64 per reader (writer re-prepares) | crystal-db cache |
| N+1 | — | none: IN (json_each(?)) batches | none: LEFT JOIN in messageSelect + IN json_each; deliberate N+1 on boosts | none: presenter memoization + fragment cache | none: IN json_each(?) | yes: ~200 SQL per room render | none: one IN (...) per entity, JOIN for sidebar |
| DB architecture | — | 1 connection + background checkpointer | 1 writer + N ro-readers + a pinned ro connection for data_version | 1 writer thread + N reader threads + checkpointer thread | ro pool + 1 writer thread (queue 256) | 1 writer GenServer + reader partitions, every query is a GenServer.call (row copies) | pool per fork worker, WAL |
| Notable extras | — | memoized AES-cookie decrypt | extra messages(room_id, created_at/updated_at) indexes at startup | jemalloc, LTO, THP disabled, RLIMIT_NOFILE | static HTML views compiled into the binary | keys cached in persistent_term | -Dwithout_mt (fork vs MT Crystal runtime) |
The winning combination is the last column's, and no other implementation
has all three pieces at once: multi-process SO_REUSEPORT transport (only
Express clusters too), a whole-page cache (Go/Rust/C/Elixir have one as
well), and a session cache that reduces the hot path to a single
PRAGMA data_version — every other implementation pays 2+ SQL per request
for auth before its cache is even consulted.
Build and run
The full comparison pipeline — download the pinned competitor repos, build every production image, verify with the official once-campfire-verification harness, then run the formal benchmark — is a single rake task:
rake deps # host dependencies (apt/brew, rustup, sqlite3 with FTS5)
rake download # clone competitor repos at their pinned revisions
rake build # production docker images (crails builds last)
rake harness # verifier checkout + crails patch + loadgen + seed DB
rake verify # preflight: route contracts against every image
rake benchmark # formal run: 3 rounds, 16 clients → benchmarks/
rake report # comparison table (routes × apps, medians) from benchmarks/ summaries
rake run # all of the above, sequentially
Granular per-app tasks: rake download:c, rake build:rust,
rake benchmark:c,go — the colon form is preferred, because bash expands
the bracket form (rake benchmark[crails]) against the benchmarks/
results directory and silently runs nothing; quoted
rake 'benchmark[crails]' also works. Knobs: BENCH_SERVER_CPUS/BENCH_CLIENT_CPUS
(default: auto-detected 2 SMT cores for the server, 4 separate cores for
the load generator), BENCH_ROUNDS, BENCH_DURATION, REBUILD=1,
DRY_RUN=1 — see the Rakefile header. Raw results are copied to
benchmarks/<UTC-timestamp>-<apps>/; the working tree of cloned repos,
the verifier and the seed live in gitignored bench-workspace/.
Two environment notes are collected in REPORT.md: the exact build stack is pinned in environment.md, and the digest assets must be generated before the image build (the rake tasks do this automatically):
node ../once-campfire-express/bin/build-assets.js # → assets/generated/public/assets
cp -r ../once-campfire-express/assets/generated/public/assets/. public/assets/
Standalone app image:
bin/docker-build.sh # docker build -t once-campfire-crails:app .
docker run --rm -p 3000:3000 \
-e SECRET_KEY_BASE=... -e HTTP_PORT=3000 once-campfire-crails:app
Or locally: shards install && crystal build src/app.cr -o bin/app --release -Dwithout_mt.
Requires a seed database (built by the harness's bin/seed) mounted at
/rails/storage/db/production.sqlite3; environment: DATABASE_URL,
SECRET_KEY_BASE, WEB_CONCURRENCY, CAMPFIRE_RESPONSE_CACHE_MB (default 64).
Documentation
Built from the current release. The first visit to a release nobody has asked for starts its build.
Links
This branch
- Branch
main- Seen
- Oct 20, 2026
- Crystal
>= 1.21.0- Indexed
- yes
Dependents
No indexed shard depends on this one yet.
Repository
github.com/OrelSokolov/once-campfire-crails
Metadata
- Created
- Oct 16, 2026
- Updated
- Oct 21, 2026
- Synced
- Oct 21, 2026
- Versions
- 1