ML.
← Posts

Is Rails Slow? I Built the Same Blog API Eight Times to Price the Framework

"Rails is slow" is an unanswerable question, because a Rails app and a Go app are never doing the same work. So I narrowed it: inside one runtime, what do the framework and the ORM cost? Four runtimes, each built twice, all returning identical JSON from the identical four SQL statements, measured three times on Docker Linux with MySQL. The magic tax in CPU per request came out at ×7.67 for Ruby, ×6.14 for Python, ×2.62 for Node and ×2.51 for Go — and I counted, object by object, exactly what Active Record builds on every request.

SeongHwa Lee··23 min read

Measured: 2026-08-31 Code and raw results: https://github.com/MartianLee/study-rails-compare Environment: Docker Linux containers + MySQL 8.4 · median of three independent runs


This article is mostly written by Claude Code

Contents

  1. Why this argument never resolves
  2. Rewriting it into a question that has an answer
  3. Hello-world measures a router
  4. What makes a benchmark believable is the gate, not the numbers
  5. The magic tax
  6. What is Active Record actually spending?
  7. Why god models cost — measuring column count
  8. Slicing one Rails request into layers
  9. Give it four cores and one process still cannot pass one
  10. Eight workers is not eight times the memory
  11. YJIT depends enormously on the workload
  12. Latency is a completely different story
  13. I threw away two campaigns — and that was the most useful result
  14. What this does not measure
  15. Running it yourself

1. Why this argument never resolves

"Is Rails slow?"

The question has no answer — not because the answer is hidden, but because four different questions are wearing one costume.

what "slow" meanswhat you would have to measure
users wait a long time for a responsep50 / p95 latency
the same traffic costs more serversthroughput per core
it eats memoryresident memory per process × process count
development is slowboot time, CI time, feedback loop

The four answers differ. So when one person answers #1 with "no" and another answers #2 with "yes," both are right and the conversation still goes nowhere.

Underneath sits a deeper problem. A Rails app and a Go app are never doing the same work. Rails takes a request through a middleware stack, routes it, turns database rows into objects, hangs association methods on those objects, arms dirty tracking, and runs callbacks. The Go net/http handler calls rows.Scan. Dividing one throughput by the other tells you far less about "how much faster Go is than Ruby" than about how much more work you asked one of them to do.

2. Rewriting it into a question that has an answer

So I narrowed it:

Inside one runtime, how much are you paying for the framework and the ORM?

That one is answerable, because you can put two servers that return the same response on the same language, the same HTTP server, the same driver and the same machine — and subtract.

runtimefull — framework + ORMbare — same server, raw SQL
RubyRails 8.1 + Active RecordRack + mysql2
NodeExpress 4 + Sequelize 6node:http + mysql2
PythonDjango 5.1 + Django ORMWSGI + mysqlclient
GoGin + GORMnet/http + database/sql

Each pair shares the runtime, the HTTP server, the driver, the MySQL wire protocol, the CPU limit and the machine. The only difference is the framework and the ORM. Because the subtraction happens inside one runtime, the result is not contaminated by the fact that Go is faster than Ruby.

I will call that difference the magic tax.

same runtime · same server · same driver · same CPU limit

full
framework + ORM

bare
same HTTP server + raw SQL

fairness gate
· JSON equal value-for-value
· identical statement set in MySQL's general_log

the difference = magic tax
CPU per request · throughput · memory

3. Hello-world measures a router

Most framework benchmarks return "Hello, World". That measures a router. Nobody deploys a router.

So the workload is a blog list endpoint — the most common shape in a CRUD service:

GET /api/posts?page=3
20 published posts, newest first
  → each with its author, its tags, and its comment count
4 SQL statements, all preloaded, no N+1

Plus a detail page that exercises nested serialisation (6 statements) and a write path that validates, inserts and bumps a counter cache in one transaction. Against 500 users, 5,000 posts, 40,000 comments and 15,167 post/tag links.

4. What makes a benchmark believable is the gate, not the numbers

The real failure mode of a benchmark post is not wrong arithmetic. It is that the things being compared are quietly doing different work. If one ORM fetches with a single join and the other fires three queries, comparing their throughput compares query plans, not frameworks.

So the gate came before the measurement. harness/verify.py boots each of the eight in turn and refuses to let a run count unless three things hold.

① The JSON is equal value-for-value. Every stack's response is diffed against the Rails app's. Key order and integer formatting are normalised; nothing else may differ.

② The SQL MySQL actually received is the same. This is the important one. Rather than trusting each stack's own query log, the harness turns on MySQL's general_log and reads back the statements the server actually received. Four for the list, six for the detail, same tables, same predicates.

③ The write path really works. Returns 201, collapses whitespace, actually increments the counter cache, returns 422 on a blank body.

The full text of all 80 statements MySQL received is committed at docs/sql-emitted.md. Check that file rather than trusting this paragraph.

The concession I had to make

The tag load on the list endpoint is two statements, not one join. Active Record's has_and_belongs_to_many preload and GORM's many2many preload both fetch the join rows first and the tags second, and neither can reasonably be talked out of it. Rather than bend two ORMs into an unnatural shape, I wrote the other six stacks to emit what those two emit.

There are a few more concessions like it, all written down in SPEC.md. In a benchmark, the lies live in the silences, not in the numbers.

The setup

where it runsLinux containers throughout (Docker Compose), MySQL 8.4
limitsidentical cgroup CPU and memory limits per app container
load generatora container on the same bridge network
concurrencyexactly one app container runs at a time
repeatsmedian of 3 runs, database reloaded from seed before every run

Putting the load generator inside the network is not a preference. Measuring from the macOS host would send every packet through Docker's port forwarder and put that latency in every sample. Host ports are used only for readiness and correctness checks. The load generator itself is in the repo too (loadgen/main.go, 190 lines of dependency-free Go) — a benchmark whose measuring instrument is a third-party image nobody can pin is not evidence.

5. The magic tax

Normalised to one core, one process, one thread.

stackwhat it isrpsCPU per requestmemory
railsRails 8 + Active Record5811.113 ms107.1 MB
rails-bareRack + raw SQL2,9040.145 ms48.5 MB
nodeExpress + Sequelize1,4060.567 ms183.7 MB
node-barenode:http + raw SQL4,5980.216 ms80.6 MB
pythonDjango + Django ORM6131.439 ms71.4 MB
python-bareWSGI + raw SQL1,9110.234 ms27.7 MB
goGin + GORM3,4200.277 ms25.4 MB
go-barenet/http + database/sql7,9860.110 ms15.4 MB

Divided inside each pair, that is the magic tax:

runtimeCPU per requestthroughputmemory
Ruby×7.67÷5.00×2.21
Python×6.14÷3.12×2.58
Node×2.62÷3.27×2.28
Go×2.51÷2.34×1.65

Three things read out of this.

① On throughput alone, Rails is genuinely slow. One fifth of Gin+GORM, one half of Express+Sequelize. That multiplies straight onto your server bill. This axis is not defensible.

② But the same table contains a number pointing the other way. Ruby with the magic removed — Rack plus raw SQL — costs 0.145 ms per request, which is 3.9× cheaper than Express+Sequelize (0.567 ms) and the same order of magnitude as Gin+GORM (0.277 ms). Same language, same web server, same driver; remove Active Record and you get 7.67×. The accurate sentence is not "Ruby is slow" but "Active Record is expensive."

③ And it isn't a Ruby problem. Django's ORM costs ×6.14. The amount of magic is the price, and the two frameworks that do the most of it cost the most. Go's tax is small not because Go is fast but because GORM has no callbacks, no dirty tracking, and no generated association methods.

On memory the folk wisdom inverts. Express+Sequelize uses 183.7 MB — 1.7× Rails' 107.1 MB. "Ruby is a memory hog" does not survive an equal-functionality comparison. The impression that Rails eats memory comes not from a heavy runtime but from having to run several workers, which is what sections 9 and 10 are about.

6. What is Active Record actually spending?

"The ORM is expensive" explains nothing by itself. So I stacked the same four queries up in four steps inside the Rails process, counting time and objects allocated. The SQL is identical at every step.

steptimeobjectsdelta
① raw mysql2 + hand-built hashes0.213ms602baseline
② same 4 statements via AR, pluck (no models)0.420ms1,473+0.207ms · +871
includes(:user,:tags).to_a — models built, attributes untouched0.755ms3,222+0.335ms · +1,749
④ + full serialisation — every attribute read0.870ms3,896+0.115ms · +674

Running exactly the same SQL costs 121 extra objects and 22 µs per row. The cost splits three ways — query layer (Arel, relations, result handling) 32%, model instantiation 51%, attribute reads and type casts 17%. Note that passing through Active Record already doubles the cost before a single model is built.

Counting the objects one request actually creates, by class, shows why:

classinstanceswhat it is
ActiveModel::Attribute140one object per attribute — a wrapper so type casting can be deferred
ActiveModel::AttributeSet137one attribute set per model instance
ActiveModel::LazyAttributeSet137and a lazy version of that set
…::BelongsToAssociation86a proxy per association so post.user can exist
ActiveRecord::Relation66post.tags is a fresh relation each time
Post / User / Tag20 / 20 / 31the things we actually wanted

71 objects we wanted, dragging 566 objects of machinery behind them.

That is the mechanical identity of "Active Record is slow." There is no slow algorithm anywhere; it is that every time a row becomes an object, the machinery for everything that object might later do gets built alongside it. Dirty tracking, deferred type casting, post.user — all of them are what that machinery buys. The magic isn't free; it's prepaid.

7. Why god models cost — measuring column count

Real-world Rails leans hard on god models. The intuition that "a bigger model is slower" is right, but what it costs depends entirely on which thing gets bigger. Same 20 rows, same table, same index; only the number of columns that become attributes changes:

columns selectedtimeobjects
20.095ms353
40.104ms435
70.122ms438
110.179ms862

Going from 2 to 11 columns costs +0.084 ms and +509 objects — about 0.5 µs and 2.8 objects per row per column. A 60-column god model adds roughly 0.5 ms and 2,700 objects to the same 20-row request, which about doubles it.

What is genuinely free per request is just as clear:

  • Method count. Attribute methods are defined once on first use (measured: 0 before, 245 after) and inline caches take it from there. A 3,000-line model with 12 columns costs the same per request as a thin one.
  • Association declarations you don't traverse.
  • Lines of code in the model file — that is boot time only.

So the cost is not "the model is big" but "the table is wide."

That said, what actually makes god models slow in production hits harder than column count. after_commit fires on every save while the call site shows one line of save, default_scope adds a predicate to every query, and a wide serialisation surface makes forgetting includes structurally likely. That is N+1, and it is tens of times the column cost.

A god model is not slow because it is heavy. It is slow because you cannot see, at the call site, what comes with it.

8. Slicing one Rails request into layers

Same Rails process, same middleware, same router, same renderer — only the inside of the action changes, so every difference belongs to that layer.

what runs inside the actionrpsCPU per request
Rails, no database (/api/static)5,562rps0.127 ms
same 4 queries, pluck, no AR objects854rps0.675 ms
same 4 queries, full Active Record581rps1.113 ms

What's slow is not Rails, it's Active Record. Thirteen middlewares, the router, the controller and the renderer together account for 0.127 ms per request — 11.4% of the total. The other 88.6% is the ORM.

And this is not a fixed cost you pay once. The fixed part is only 11.4%; 88.6% scales with rows and objects. Active Record is not a runtime you switch on and use for free — it is the work of building an object per row and attaching machinery to each one.

What that means in practice: trimming middleware or tuning routing is carving up an 11% pie. Avoiding AR object construction with pluck or select is a 1.6× lever, and dropping columns you don't need is the same kind of lever.

9. Give it four cores and one process still cannot pass one

Ruby has a GVL (Global VM Lock). One lock per process; to run Ruby code you must hold it. Create a hundred threads and exactly one of them runs Ruby code.

There is a moment when the lock is released, though — while waiting for the database. So the received wisdom is that more threads let you overlap that wait. I measured it. One process, 1 CPU, concurrency 8, varying only the thread count:

Puma threadsrpsvs 1 threadcores usedCPU per request
16161.00×0.661.072 ms
26060.98×0.841.395 ms
34520.73×0.811.801 ms
53980.65×0.812.038 ms
104220.69×0.831.962 ms

Throughput goes down, not up. The threads do claim the idle CPU — 0.66 → 0.81 cores. But that CPU goes into GVL handoff and context switching rather than into requests. CPU per request nearly doubles, 1.07 → 2.04 ms.

Strip Rails away and it is starker. Rack plus raw SQL on the same Puma goes 2,938 → 824 rps (0.28×), with CPU per request rising 0.139 → 0.834 ms — six times. The less Ruby work per request, the larger the handoff cost looms.

To check this wasn't a single-core artifact I repeated it with four CPUs and concurrency 32:

Puma threads (4 CPUs)rpsvs 1 threadcores used / 4.0CPU per request
14621.00×0.481.041 ms
25121.11×0.701.358 ms
33830.83×0.711.855 ms
53620.78×0.742.040 ms
104250.92×0.811.915 ms

This is the cleanest picture of the GVL in the whole study. Four cores available, ten threads running, and the process uses 0.81 cores. The other 3.2 sit idle with no way to reach them. And CPU per request comes out nearly identical to the single-core run (1.041/1.072, 2.040/2.038), so this is a real cost, not scheduler noise. It is why Rails 7.2 dropped Puma's default thread count from 5 to 3.

This conclusion is bound to the workload, though. This endpoint fires four statements at a local MySQL and returns; the wait is milliseconds. An action that waits 200 ms on a third-party API has far more to overlap, and threads clearly pay there. What threads buy is directly proportional to the share of request time not spent executing Ruby, and that share is app-specific.

So the only way to use your cores is more processes. And that is the memory bill.

10. Eight workers is not eight times the memory

If workers are the memory problem, is eight workers eight times the memory? No. And the misconception costs real money — it buys instances you don't need and sets container limits too high.

workersactual memorynaive estimateoverestimateper extra workerrps
1122.6 MB122.6 MB×1.00364
2198.2 MB245.2 MB×1.2475.6 MB847
4316.5 MB490.4 MB×1.5564.6 MB1,585
8553.2 MB980.8 MB×1.7761.5 MB2,291

Workers are made with fork, and fork does not copy memory — the child points at the parent's pages until someone writes (copy-on-write). A Rails app's code, classes and method tables do not change after boot, so all workers share them outright. The first worker costs 122.6 MB; each additional one costs about 61.5 MB.

In the same measurement throughput went from 364 rps at one worker to 2,291 at eight — 6.3×. You put in 8× and got 6.3× for 4.5× the memory. That is the actual exchange rate when you buy concurrency with RAM.

11. YJIT depends enormously on the workload

1 core, blog list APIthroughputCPU per requestmemory
YJIT off370rps2.021 ms89.1 MB
YJIT on (Rails 7.2+ default)581rps1.113 ms107.1 MB
×1.57−45%+18MB

What makes this interesting is that measuring the same Rails against a sqlite workload gave YJIT +0.3% — it didn't pay for itself. That is not a contradiction but a question of what YJIT compiles. YJIT only shortens time spent executing Ruby code. The sqlite endpoint spent most of its time inside a C extension, leaving little Ruby to shorten; this blog endpoint, as section 6 showed, builds 3,900 Ruby objects per request — precisely YJIT's range.

The practical lesson is not "turn YJIT on" but "measure it on your workload." The same YJIT on the same Rails is worth +0.3% or ×1.57.

12. Latency is a completely different story

Every number so far was measured at saturation. To see what a user waits for, you have to look at an unsaturated system. Measured separately at concurrency 4:

stackp50p95p99
Rails 8 + Active Record3.06 ms7.79 ms9.30 ms
Django + Django ORM2.99 ms6.76 ms8.93 ms
Gin + GORM1.48 ms2.61 ms3.24 ms
Express + Sequelize1.06 ms1.49 ms2.24 ms
Rack + raw SQL0.99 ms1.74 ms2.24 ms
WSGI + raw SQL0.85 ms1.27 ms1.54 ms
net/http + database/sql0.70 ms1.28 ms1.72 ms
node:http + raw SQL0.69 ms1.08 ms1.32 ms

The slowest, Rails, is 2.4 ms behind the fastest. Against a real API response of 100–300 ms, the framework accounts for 1–2% of it.

Measure the same endpoint at concurrency 50 and Rails' p99 jumps to 552 ms. That is not performance — it is Puma's max_fast_inline default, which serves up to ten keep-alive requests on one connection before yielding, producing a low p50 and an exploding p99. Rack plus raw SQL on the same Puma shows the same shape at 183 ms, and Node and Go show none of it. It is a property of Puma, not of Ruby.

Always state the concurrency when you quote a latency. 3.06 ms and 552 ms are the same code on the same endpoint.

13. I threw away two campaigns — and that was the most useful result

Getting to the final numbers meant discarding two complete campaigns. The reasons are useful to other people, so here they are.

First — I gave MySQL only four CPUs. The fast bare stacks pushed it to 3.2 cores, and from there they were queueing on the database rather than on themselves. Run-to-run spread reached ±95%. Raising MySQL to eight cores settled go-bare at 7,449 / 7,421 / 7,444. Every measurement window now records how many cores MySQL burned.

Second — the Go pair was tilted in GORM's favour. database/sql's db.Query(sql, args...) does not cache: it prepares, executes and closes on every call — three round trips plus a fresh parse in MySQL. GORM with PrepareStmt: true caches. The thing GORM was being compared against was handicapped. I added a statement cache to the bare app and verified from general_log that Prepare rows per request are zero.

Also in that campaign — the write test inserts and deletes 150,000 rows per stack, which fragments the tables. A run against a fragmented table and a run against a freshly loaded one are not the same experiment. The database is now reloaded from seed before every run.

Out of all that came the most transferable result in this post.

Across two campaigns with different configurations, app CPU per request reproduced within a few percent (Rails 1.13 → 1.11, Rack 0.144 → 0.140) while throughput moved by a third.

The reason is structural: a bare stack does so little per request that its throughput is set by database round-trip latency. Therefore —

The magic tax measured as a throughput ratio depends on how fast your database is. It is at its maximum against a local unloaded MySQL; against a managed database across a network the framework's share shrinks and the ratio falls. The CPU ratio does not move, because it is a property of the code rather than of the wire.

Quote CPU per request when you quote a multiple; state the database conditions when you quote throughput.

14. What this does not measure

In a benchmark, the lies live in the silences. So, explicitly:

Nothing about developer productivity. The entire reason frameworks exist is missing from every number here. A magic tax of ×7.67 is not an argument against magic; it is the price tag on it. Buying with the tag visible differs from buying blind, and that difference is all this post is trying to make.

Memory is "container working set under load" (cgroup memory.current minus page cache) — the basis on which you are billed and scheduled, not the size of your live data. I did not measure how much a forced GC reclaims; neither V8 nor Ruby promptly returns freed pages to the OS, so the effect runs the same direction for both runtimes.

In the two-core configuration, MySQL saturated in some measurement windows. Those windows measured the database rather than the app, so mysql_cores is reported alongside every result. The one-core configuration is clean (MySQL peaked at 2.24 of 8.0).

One endpoint, uniform load. No cache, no CDN, no background jobs. The whole dataset fits in the buffer pool — on purpose, since the goal is to measure the app rather than the disk. In a real service with longer database waits, the framework's share is smaller than it is here.

No framework was tuned. Every stack is close to what its new command or its documentation hands you. One machine, arm64. The ratios and the direction travel; the absolute numbers do not.

15. Running it yourself

Docker and Python 3 are the only requirements. The host OS does not matter — everything runs inside Linux containers.

git clone https://github.com/MartianLee/study-rails-compare
cd study-rails-compare

docker compose up -d mysql     # MySQL 8.4
./db/load.sh                   # generates the seed deterministically, loads it
docker compose build           # 8 app images + the load generator

python3 harness/verify.py      # the fairness gate — must pass
python3 harness/run.py --runs 3
python3 harness/report.py      # -> docs/RESULTS.md

The seed comes from a fixed PRNG seed, so any machine produces identical bytes. Image builds are pinned to the committed Gemfile.lock / package-lock.json / go.sum.

Full code, raw per-run results, and the SQL MySQL actually received:

https://github.com/MartianLee/study-rails-compare


So, the answer to the original question. On latency, no — 3.06 ms, 1–2% of a real request. On server count, yes — one fifth of Gin+GORM. And the source of that gap is not Ruby, it is Active Record: same language, same Puma, same driver, and CPU per request drops by 7.67×.

Which makes the real question not "is Rails slow" but "what are we paying for this magic, and do we know the price when we pay it?"