What Happens After You IPO?


2026-07-28T23:47:33Z

lobsters.png

Go look at rubyonrails.org right now. The first thing you see:

Accelerate your agents with convention over configuration.

Ruby on Rails scales from PROMPT to IPO. Token-efficient code that's easy for agents to write and beautiful for humans to review.

I want to sit with that for a second, because I think it's more interesting than it first appears.

The property Rails is selling

The argument on that homepage is that conventions make Rails good for machines. An agent doesn't have to discover where things live, or infer a naming scheme, or read your configuration to find out what you decided this week. A model named Story has a table named stories, a controller named StoriesController, and views under app/views/stories. You know that without looking. So does the agent. That's what "token efficient" means — the conventions carry information that would otherwise have to be spelled out.

Here's the thing: that is exactly the property a compiler needs.

Static analysis is easy when structure is predictable and hard when it isn't. Every convention Rails imposes is a fact an analyzer gets for free, and every configuration escape hatch is a fact it has to prove. Rails spent twenty years building what amounts to an ideal input language for a transpiler, and has now put that on the homepage as a feature — for a different reason, aimed at a different consumer.

An agent and a compiler want the same thing. The homepage's other half stays true either way: still beautiful for humans to review, because nobody had to contort the source to make it machine-legible. The conventions were already there.

The part after the arrow

"Scales from PROMPT to IPO" is a good line, and Rails has genuinely earned the first half of it. Nothing gets you from an idea to a running application faster.

So: what happens after you IPO?

The historical answer is that you leave. You hit a wall — throughput, latency, infrastructure cost, headcount spent on servers — and the remedy is a rewrite in something that compiles. That story has played out enough times to be a genre. The rewrite is expensive, it takes years, it loses the conventions that made you fast in the first place, and the new thing is worse to work in.

I don't think that trade is necessary, and the goal of this project is narrower and more specific than "make Rails fast":

You shouldn't have to change your application.

Not port it. Not annotate it. Not restructure it to suit a tool, not adopt a subset, not maintain a parallel version. The Rails you already wrote — the one your team knows, the one your agents are good at writing — should be the input. If a compiler needs something the app doesn't provide, that's the compiler's problem to solve, not yours.

That constraint is what makes this hard, and it's also the only version of the idea worth pursuing. A transpiler that requires you to rewrite your app has just reinvented the rewrite.

The blog, and a closed chapter

Three months ago, in an issue on matz/spinel that was mostly about four type-inference gaps, I floated a hope: that a Rails blog demo running on top of Spinel — Matz's ahead-of-time Ruby compiler — would be a valuable addition to the demos already in place. The answer, in issue #83:

the Rails-blog-as-input demo is a great forcing function and a different shape of input from the small repros that drive most of the issue tracker today, so yes, a Spinel-backed Rails blog would be welcome alongside the existing demos.

The same reply set the working method, and it's the one still in use: file each gap as its own minimal reproduction, title it by symptom rather than by mechanism, one concrete failure per issue. Small, contained reproducers are easy to triage; the fix usually lands in a commit or two.

Three months later the blog transpiles, compiles to a native binary, and runs. It was on the Main Stage at RubyConf twelve days ago, with Matz in the room, and the talk and its demos are online.

A forcing function does its job and then stops being interesting. A generated blog is a scaffold — it has the shape Rails intends, not the shape applications acquire. Closing that chapter means picking a harder input.

Two Lobsters, measured on purpose

Lobsters is a real, community-run Rails application: the link-aggregation site, not a scaffold. It carries what real apps carry — hand-rolled scopes, concerns, callbacks, view helpers, query patterns no generator produces. It's also a known quantity, because the Ruby and Rails Infrastructure team at Shopify turned it into a benchmark for YJIT for exactly this reason: it stresses a real Rails request the way a microbenchmark can't.

Getting Lobsters onto Spinel is a big job. Rather than treat it as one push, it's split into two smaller ones that measure different things — and, more usefully, that catch each other lying.

The benchmark lane runs the capture frozen into ruby/ruby-bench, pinned to a fixed commit. A benchmark has to freeze its input or the numbers stop comparing between runs. The trade is that it describes the app as it was when the snapshot was taken.

The conformance lane answers the other question: does this handle the code they have now? It tracks upstream HEAD, records the commit it ran against instead of pinning it, and runs Lobsters' own RSpec suite against a transpile of that same checkout.

That second lane is the one that tests the actual thesis. Their tests, their app, unmodified — if the output passes the suite the original passes, then "you don't have to change your application" is a measurement rather than a slogan. And a test suite is a far finer oracle than a route returning 200: each failure names a specific construct that isn't modeled yet, which turns the suite into a worklist generator.

Speed without fidelity is a benchmark for the wrong program. Fidelity without speed is a slower Rails. You need both lanes, and you need them reported separately, because the standard way this kind of post lies is to prove one and imply the other.

Where it actually stands

Both lanes ran today, against Lobsters 4227239d and Roundhouse f0b3ab24. Numbers below are from those runs.

Conformance. 354 examples across 25 of 104 spec files — model specs, the ones that exercise the compiler surface most densely; request, feature, mailbox and routing specs aren't run yet. 39 pass. 315 fail.

That is not a good score, and I'd rather publish it than round it. What makes it worth publishing is the shape: two causes account for 211 of the 315. Keystore.upsert isn't implemented (133 examples), and the SearchParser class emits empty because a parslet DSL is dropped on the floor (78). The rest of the ranked list runs 24, 20, 16, 11, 5, 5 — a Symbol used where an integer index was expected, sixteen cases of a saved_change_to_<column>? method that isn't synthesized, nil reaching integer arithmetic. Every one of them names something specific. None of them says "Rails is too dynamic."

The transpile itself produces 743 files with zero errors and 2,361 warnings. The warnings are the honest part: modeling debt, itemized, on the exact line.

Benchmark. The Ruby emit — Rails semantics compiled to plain Ruby, still running on CRuby — serves 26 of 26 routes with a 200, and 21 of those render what Rails renders. At 133.3 ms/iteration against Rails' 491.3 under YJIT, that's 3.69× faster; without JIT, 167.2 against 821.0, or 4.91×. Those numbers are solid and they are not the Spinel story — that's what removing framework overhead buys you before any compiler gets involved.

The Spinel lane, which is the actual target, serves 10 of 26 routes. Sixteen return 500. And the benchmark page says this about its own timings:

The performance numbers on this page are not trustworthy.

It says that because the AOT lane failed 72 visits during the timing run, which makes the measurement optimistic in a way that isn't recoverable by squinting. A lane that dies on two-thirds of its routes and still posts a time is posting the time of the routes that didn't die.

If you click through and find different numbers — or a lane that isn't running at all — that's why the commit is published next to them. Both lanes sit on upstream that moves underneath them, and that page is the live record. This is a snapshot of one afternoon.

One asymmetry bears on every number above. Lobsters caches its heaviest work through Rails.cache — the user tree for a day, the front page for 45 seconds, individual stories for a minute. The CRuby lane has a real in-memory store behind that, close enough to ActiveSupport's MemoryStore to behave like one. The Spinel lane has a no-op: every fetch recomputes through its block. The answers still come out right and all of the work gets redone, which makes it less a missing optimization than a different program from the one Rails is running — and it means the compiled lane is doing strictly more work than the lanes it's measured against.

Why only CRuby has it is this project in miniature. A real store wants Marshal, a Mutex, and time-based eviction — exactly the dynamically typed shapes the shared runtime's typing bar excludes — so it lives in a CRuby overlay, and every other target keeps the no-op until its turn comes. That trade recurs constantly: something is easy in Ruby, and making it available to a compiler means either modelling it properly or admitting in writing that it's target-specific for now. Some of the gap above is work that hasn't been done, not work that can't be.

The method

There is no version of this where the next commit finishes it. What there is instead: run the assessment periodically, harvest whatever is cheap, keep the upstream issue queue loaded, and publish the ledger whether or not it flatters.

Today's pass was a representative day. I re-measured the compile of current Lobsters against Spinel and peeled it one blocker at a time — thirty-two distinct causes before I stopped counting, twenty-nine of them mine. Two were Spinel gaps with small reproductions, filed in the shape Matz asked for in April — #3414 and #3415, both fixed upstream within hours, each with a second defect found behind the one I reported. Four more were cheap enough to fix on the spot. That ratio — twenty-nine mine, two upstream — is the honest shape of this work, and it's why the assessment gets rerun rather than assumed.

One of those four is a nice illustration of why two lanes beat one. An emitted view was referencing a class its own module namespace shadowed: inside module Views; module Stats, an unqualified Stats resolves to the view module, not the model. The strict ahead-of-time lane refuses that at compile time. The CRuby lane had been shipping it happily and would have raised at request time. The lane that can't run the code found the bug in the lane that can.

You can read all of this as it happens. The Lobsters page links the type inference, the lowering, the benchmark results and the conformance results. Open the app in the IDE and every unmodeled construct is a squiggle on the exact line. Those squiggles are the point: the coverage gap made legible, rather than a README that says "most things work."

It is genuinely early. The snapshot server serves real pages on a minority of its routes, the first conformance tests pass, and the benchmark page declines to stand behind its own timings. All three of those are true at once, and all three are progress on the only question that matters — whether the application you already have can come with you.


Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.