package

github.com/OrelSokolov/once-campfire-crails

main / published Oct 15, 2026 / repository

CRails benchmark for DHH

[!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.

ONCE Campfire on CRails

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-run 20261015-193908-crails on the same stand — adds the whole-response avatar token cache
RouteRailsExpressGoRustCElixirCRails
room_show1 73110 43014 31621 95331 6982 34638 092
messages_page1 64116 24814 33621 53932 1142 48037 553
sidebar1 80419 97914 94524 41134 8572 56652 299
search1 80117 76415 98825 11333 4602 52248 930
avatar28 04640 98948 65674 44218 92031 89676 669
static_css37 04842 84359 41372 38937 09536 78875 962
/up2 57120 53129 23836 55762 8904 23082 265
post_message1661 0152 4422 9351 3035562 033
Revisionedbc779bb70a7dc4ab53d6dae2fd873a51f58964cf6528f85

#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.

RouteRailsExpressGoRustCElixirCRails
room_show15719 49226 59852 53657 2023 02559 670
messages_page27335 11428 36947 21356 4823 21058 500
sidebar36046 38332 84352 57460 1643 32381 153
search23835 99929 80251 97061 1533 27576 942
/up2 82647 97259 86281 267122 1115 887148 238
KLOC2.96.539.358.7108.814.02.4
Revision90b3300bb70a7dc4ab53d6dae2fd873a51f58964cfc096b40

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:

RouteCElixirCRails
static_css84 70366 870195 050
avatar35 59755 0442 800
post_message690690110

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 crails in 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):

  1. 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.
  2. 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.
  3. 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.

RailsExpressGoRustCElixirCRails
TransportPumanode:http + Express 5stdlib 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 proxyServerX: nginx-model, buffer per connection, offset parser
ProcessesPuma workers (3×5 threads)cluster + reusePort: true (WEB_WORKERS)1 process, goroutine-per-connection1 process, tokio threads = CPU1 process: 1 loop thread + 4 worker threads, no SO_REUSEPORT1 BEAM OS process, no clustering3 fork workers + SO_REUSEPORT
Whole-page response cachenone (every response re-rendered)LRU 64 MB + data_version epochLRU 64 MB + pinned data_version≤1 MB/entry + 15 s TTL64 MiB + data_version epoch64 MB FIFO, final gzip bytesLRU 64 MB + data_version epoch
Fragment cache—LRU 32 MB (messages/{id}-{updated_at})LRU 32 MB (sidebar frame)LRU 32 MB, per-messagenone (views are C code)ETS, keyed by updated_atnone (page cache covers it)
GzipRack::Deflaterspliced gzip (stable segments deflated once) + compression L6front gzip L6, precompressed assetszlib-rs L6 + front zstd L1, .br/.gz embedded in the binarygzip representation stored in cachere-deflated on every response (25% CPU)lazy gzip in the page cache
ETag/304yesweak fast-etag (crc32)weak W/"..."SHA-256, Rack-styleW/"sha256(body)"W/"sha256" over the whole bodySHA1 prefix in the page cache
Session on hot pathSQL per requestSQL through an LRU query cache → effectively 0 queries on a hitSQL per request (JOIN sessions+users, before cache lookup)SQL per request (find_by_token + find_by_id)2 SQLs before cache lookupfull auth path even on a hit (3–4 SQL + AES decrypt)1 PRAGMA per request (IdentityCache)
Prepared statements—LRU 512, IN via json_eachcache 256, IN via json_each (stable SQL shape)prepare_cached (256)fixed SQL per connectionLRU 64 per reader (writer re-prepares)crystal-db cache
N+1—none: IN (json_each(?)) batchesnone: LEFT JOIN in messageSelect + IN json_each; deliberate N+1 on boostsnone: presenter memoization + fragment cachenone: IN json_each(?)yes: ~200 SQL per room rendernone: one IN (...) per entity, JOIN for sidebar
DB architecture—1 connection + background checkpointer1 writer + N ro-readers + a pinned ro connection for data_version1 writer thread + N reader threads + checkpointer threadro 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 decryptextra messages(room_id, created_at/updated_at) indexes at startupjemalloc, LTO, THP disabled, RLIMIT_NOFILEstatic HTML views compiled into the binarykeys 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).

API

  • JbuilderTemplate

    jbuilder-партиалы (compile-time регистрация — рантайм-подстановки файлов шаблоны Crystal себе позволить не так, как в Ruby; пути — относительно корня запуска crystal, как у compile_template).

  • ServerXAppHandler

    Обёртка приложения в HTTP::Handler (роутер + cable — внутри chain) для транспорта ServerX.