Basecamp's Campfire is a small Rails chat app: about 3,700 lines of application Ruby across seventeen models, forty-odd controllers and eighty views. It ships as a Docker image that wants Ruby, a job runner and Redis beside it. Somebody took that app, didn't rewrite any of it, and turned it into one native executable that serves five times the throughput on 366 MB of resident memory, where the original needs 4.6 GB.
That's roughly a twelfth of the RAM for the same product, with the same pages and the same WebSocket behaviour. The running process has no Ruby interpreter in it. It's one file plus a SQLite database.
Two separate projects make this possible.
What Roundhouse and Spinel do
Roundhouse is a compiler, written in Rust, and the whole idea is that Rails gets treated as a spec and not as the thing that runs. It never boots your app and never touches a database, it just reads the source and spits out a standalone project in some other language. Right now it accepts thirteen target names: Rust, Go, TypeScript, Crystal, Elixir, Kotlin, Swift, Python and C#, then a plain Ruby tree with no Rails in it, a JRuby variation of that, a browser bundle (it runs in a SharedWorker, with SQLite compiled to WebAssembly), and finally the one I care about in this post.
It's doable because Rails has been a typed language all along, we just never bothered to write the types down. Look at has_many :comments, that's a type declaration. And db/schema.rb already describes every attribute on every model, plus what each column deserializes to. You also know that a find_by gives you back something nullable and a find doesn't. Roundhouse digs all of this out with whole-program inference on your unmodified source, and you don't add a single annotation. It's quick too. One pass over Mastodon (1,173 files, all 337 controllers and the HAML views included) takes about 1.5 seconds.
Then there's Spinel, the other half, and this one is Matz's own project. It's an ahead-of-time compiler for Ruby that comes as a single self-contained C binary. The steps are easy to follow: it parses your Ruby through libprism, runs whole-program type inference, emits one C file, and passes that file to the system cc. What you get out is a native executable, and there's no Ruby runtime in it, no parser, no interpreter loop either. It's very new, the first dated release is 2026.09.12. As for speed, Spinel's own benchmark suite (28 benchmarks) reports a geometric mean of around 8.5x over Ruby 4.0.4 with --yjit, or about 15x if you compare against the plain interpreter.
Both projects go after the same cost. A running Rails app makes some decisions on every request even though the answer can't differ between two requests, and it pays for them every time.
How a Rails app becomes one native binary
The pipeline has three stages. First, Roundhouse analyzes the application and lowers it. Anything it can resolve at build time, like route matching, association loading, validation dispatch and template rendering, gets resolved once, and only the work that depends on the request stays in the generated code. For the Spinel lane, the output of this stage is plain Ruby without Rails that implements Active Record, Action Controller and Action View behaviour for this one application.
Second, that emitted Ruby is written into Spinel's subset on purpose: no eval, no method_missing, no reopening classes at runtime, and every method's types statically resolvable. You wouldn't write this Ruby by hand, and you aren't meant to read it as a codebase you own. Think of it as an intermediate representation that happens to be valid Ruby.
Third, Spinel compiles it to C, and C to a binary. The result serves HTTP on port 3000, speaks Action Cable at /cable, reads storage/development.sqlite3, and handles concurrency with a green thread per connection scheduled across one OS worker per core. It doesn't need Puma, a fork pool, or Redis for the cable.
# Analyze first: this tells you what the analyzer does not understand in your app
roundhouse check --continue /path/to/your/rails/app
# Then emit the Spinel project and compile it
roundhouse --target spinel -o out/spinel /path/to/your/rails/app
cd out/spinel
spin build
sqlite3 storage/development.sqlite3 < db/seed.sql
./build/bin/myapp
spin is Spinel's project driver, shaped like cargo or mix. It resolves dependencies (real bcrypt when the app uses has_secure_password, ruby-vips when it declares image variants), compiles the tree to one C translation unit and links the binary. A Campfire-sized first build takes a few minutes, and most of that is the C compiler working through a 12 MB translation unit. Clang is about twice as fast as gcc on it.
The generated spin.toml names jemalloc as the allocator, and the build fails when it would otherwise link glibc's malloc. Glibc's allocator can eat a third of a server's CPU under load, and if the build varied silently by machine, a benchmark would end up comparing two different binaries.
Where the 5x and the 366 MB come from
Two independent multipliers stack here. The first is Roundhouse alone, with the runtime held constant: same CRuby, same YJIT, same Puma. The only variable is whether the framework at runtime is Rails or Roundhouse's lowered emit. On the project's blog fixture, measured on a 6-core Ryzen 5 3600 with one worker and 64 concurrent connections, the HTML index goes from 327 requests per second on stock Rails to 3,221 on the emit. That's 9.8x from deleting interpretive layers.
The second multiplier is the runtime swap. The same emitted framework compiled by Spinel and served behind its own thread-per-connection server reaches 44,667 requests per second on that endpoint. For a JSON show action the spread runs from 895 on Rails to 5,241 on the emit to 72,108 compiled.
Memory drops because of structure. CRuby has a global lock, so you scale concurrency by forking Puma workers, and every worker carries its own copy of the framework in resident memory. A single compiled image with green threads handles the same concurrency without that duplication. The project publishes requests per second per gigabyte of RSS as a separate chart, and the spread across targets covers three orders of magnitude. That's the ratio that matters if you pay for GB-hours.
Campfire's numbers are lower than the blog's, which makes sense. An app dominated by held-open WebSocket connections isn't bottlenecked on template rendering, so five times the throughput there is a different kind of win than 130x on a cached HTML index. The 366 MB against 4.6 GB is what would change a deployment decision.
Every experienced Rails engineer raises the same objection first: Rails apps are I/O-bound, so the runtime doesn't matter. The arithmetic doesn't support it. If the emit runs a real-world benchmark about 3x faster than Rails, and the database is doing identical work in both cases, then the database can be at most a third of the original request time. Otherwise no runtime change could have produced a 3x total. Profiling puts it much lower than that: the query is roughly a twentieth of a stock Rails request, and about 95% of the rest is CPU. That CPU time goes to interpreting the framework, turning rows into objects, rendering templates and serializing responses, and a faster runtime can reduce all of it.
How do they know the compiled app still behaves like Rails
Whether any of this is more than a demo depends on this question, and I find the answer the most credible part of the project. The gate is differential. The same URL is fetched from live Rails and from each target, and the responses must match: DOM node for node on HTML, value for value on JSON, frame for frame on Action Cable broadcasts. A single missing attribute or an extra whitespace text node is a red build. Every target on the published list passes that gate on every push, or it drops off the list for the next snapshot.
Campfire specifically is compared page by page against live Rails on every push, runs its own test suite against the transpiled code, and gets checked on cable frames. The compiled lane runs that suite a second way, with each test file built into its own native binary. That surfaces a failure the interpreted lane can't even express: a test file that never linked. Under CRuby a test passes or fails, but compiled it can also fail to build, and then nothing behind that error was ever observed.
That bar is higher than "the tests pass" on purpose. A browser or a scraper sees the emitted app as the same app, which is a stronger claim than agreeing with Rails on the paths someone remembered to test.
What Ruby code survives an AOT compiler
Even if you never point Roundhouse at anything, Spinel's list of limitations tells you exactly which Ruby constructs require a live interpreter.
Some refusals can't be fixed, because they come from whole-program compilation itself: eval and its string-argument relatives need a parser at runtime, method_missing can't be dispatched when every call site is a direct C call, define_method works only with literal names, ObjectSpace needs an allocation registry the GC doesn't keep, TracePoint needs an interpreter loop to hook, refinements need scope-keyed dispatch, and Class.new(parent) { ... } needs a class graph that isn't baked at compile time. Block forms survive. instance_eval { } compiles fine because the block is real code, and only the string form fails.
When the compiler hits one of these it refuses loudly with a spinel: FILE:LINE: line naming the construct. It reports every refusal in the program in one run before failing once with the count, so you port from a complete list and don't fix errors one build at a time.
In ordinary application code the problem looks like this. A registry like this one is idiomatic, concise Ruby, and an AOT compiler can't handle it:
class PlanLimits
LIMITS = { seats: 5, projects: 10, storage_gb: 2 }.freeze
# Every reader is invented at runtime, so no compiled body exists for it
def method_missing(name, *args)
key = name.to_s.sub(/_limit\z/, "").to_sym
LIMITS.fetch(key) { super }
end
def respond_to_missing?(name, include_private = false)
LIMITS.key?(name.to_s.sub(/_limit\z/, "").to_sym) || super
end
end
PlanLimits.new.seats_limit # => 5, resolved by a runtime hook
The static version says the same thing with literal names, which is all the compiler needs. Spinel accepts define_method only with a literal name, so a loop that builds the names from a constant does not qualify. Plain method definitions do:
class PlanLimits
LIMITS = { seats: 5, projects: 10, storage_gb: 2 }.freeze
# Each name is written out, so each reader gets a real compiled body
def seats_limit
LIMITS.fetch(:seats)
end
def projects_limit
LIMITS.fetch(:projects)
end
def storage_gb_limit
LIMITS.fetch(:storage_gb)
end
end
PlanLimits.new.seats_limit # => 5, an ordinary method call
In Roundhouse's pipeline you don't do this rewrite yourself, since that's what the lowering step is for. The distinction still predicts which parts of your app are cheap and which force the framework to do work on every request.
How to try it on your own app without committing to anything
The analyzer is useful without the compiler, and it's the cheapest thing to run. roundhouse check gives you a survey report listing every construct the analyzer didn't recognize and every gem it doesn't model. There's also an LSP server for your editor, an MCP server for your coding agent, and a browser IDE that reads a folder from your disk without uploading it.
No other Ruby tool is static, deep and annotation-free at the same time. ruby-lsp is static but stops at names. ruby-lsp-rails and Tidewave go deep but need your app booted. Sorbet and Steep are static and deep but you pay in annotations. Roundhouse needs only the source.
- Run
docker buildon the published Campfire archive and openlocalhost:3000to see the compiled end state before pointing anything at your own code. - Install the Roundhouse binary and run
roundhouse check --continueagainst your app. - Read the unrecognized-construct list and the gem census, which is your real porting distance.
- Open the app in the browser IDE to inspect inferred types and request traces.
- Only then try
roundhouse --target spinel, with a C toolchain,libsqlite3-devandlibjemalloc-devinstalled. - Expect failures in one of two places:
spin buildcould not type something, or it built and diverged from Rails, which the compare oracle reproduces.
Seeing the compiled product takes about a minute of compile time and roughly 5 MB of download. The resulting image is around 160 MB, and its only additions over debian:trixie-slim are the SQLite and jemalloc runtime libraries.
Should you put this in production
No, and the project says so first. Roundhouse's own documentation says it isn't ready for production use today: the work is far enough along for the argument to be testable, but nobody should bet a live deployment on it. Campfire's status is written as "it runs": a signed-in room serving pages and fanning a posted message out over the cable to a second browser tab.
There is also a version-skew cost right now. Spinel moves fast, Roundhouse's CI tracks its master unpinned on purpose, and each Roundhouse snapshot names the Spinel release it was tested against. A mismatch in either direction is the first thing to check when a build breaks.
Coverage is uneven, in two tiers. The blog fixture is what all ten server targets pass the DOM-equivalence gate against. Campfire is the higher tier, and only the Ruby shape on CRuby and the Spinel compilation of it reach it. So has_many :through, polymorphic associations, Active Storage attachments with variants, has_rich_text, STI, signed cookies, Current, rate_limit and fragment caching work on those two lanes and arrive at Rust or Go as their emitters catch up.
The Spinel lane leads for a structural reason, and it will keep leading. Every other target runs a translation of the framework runtime into that language. The Spinel lane runs the framework runtime itself, the actual Ruby that implements Active Record and friends, compiled as-is, so new Rails coverage lands there first.
Is this better than a rewrite, JRuby or TruffleRuby
The traditional response to outgrowing CRuby is a rewrite, and it usually runs over time and budget while operating alongside the system it was meant to replace for years. Roundhouse proposes keeping Rails as the thing you write and making the deployment target a build flag, so you can change the target later without a rewrite.
JRuby is the option that ships today, and its gains aren't small. On that same fixture, Rails on JRuby does about 1,066 requests per second against 326 on CRuby, and the Roundhouse emit on JRuby hits 24,172, which is roughly a 23x lift over stock Rails on the JVM against 10x on CRuby. If you want a real speedup this quarter with production-grade tooling, that's the lane with a track record.
TruffleRuby attacks the same problem from the opposite direction: a JIT that keeps all of Ruby's dynamism, where Spinel is a compiler that refuses part of it.
My read is that Roundhouse plus Spinel is the most interesting Ruby performance work happening right now, and also the least ready. I'd give it an afternoon. I wouldn't write a migration plan around it yet.
FAQ
Does Spinel support all of Ruby?
No, and by design it can't. Whole-program AOT compilation rules out eval on strings, method_missing dispatch, runtime-computed define_method, ObjectSpace, TracePoint, refinements, Continuation, runtime class creation and general reflection with non-literal names. Literal-name forms of send, instance_variable_get and instance_variable_set do work, because they resolve to a known slot at compile time.
Do I need to install Ruby to run the compiled app?
No. The binary has no Ruby runtime inside it. At run time it needs libsqlite3, libjemalloc2, and libvips42 only when the app declares image variants. To build it you need the Spinel compiler with spin on your PATH, a C toolchain, and the libsqlite3-dev and libjemalloc-dev headers, plus libvips-dev when the app declares image variants. Node is needed only for the Playwright end-to-end suite.
Can I still use my gems?
Not as gems. Spinel compiles source trees into the binary, so there is no runtime loading and no native extension building. Roundhouse models specific dependencies (bcrypt for has_secure_password, ruby-vips for variants) and its gem census tells you which of your gems are not modelled. A gem that relies on heavy metaprogramming has to be redesigned, which is more work than porting.
How fast is Spinel compared to YJIT?
Its own suite reports a geometric mean near 8.5x on 28 benchmarks against Ruby 4.0.4 with --yjit, and about 15x against the interpreter, measured on a 32-core Linux box with gcc 13.3. Compute-heavy cases go much further: mandelbrot at 53x, recursive fib at 44x. Those are microbenchmarks, though, and your web request isn't one.
Does the compiled app still support Action Cable and WebSockets?
Yes, and that's why Campfire is the reference app and not a blog. The binary serves the cable at /cable, holds the connections itself with a green thread per connection, and its broadcast frames are compared against live Rails frame for frame on every push. There's no Redis and no separate job runner involved.
Does Roundhouse require type annotations like Sorbet or Steep?
No annotations anywhere. It recovers types from Rails conventions and db/schema.rb by whole-program inference on unmodified source. Spinel can optionally read RBS files to seed its analyzer with --rbs DIR, but those seeds are advisory and inference still runs on top.
Can I compile just one hot endpoint instead of the whole app?
Not through this pipeline, which emits a whole application. The project's own reasoning does acknowledge the motivation: most large apps find that one or two endpoints dominate the bill, and rewriting everything to win on those routes is a steep price for a local problem.
Happy compiling!
