Rails Performance: Lessons from Production — #6
By now the DB queries, caching, and background work are handled. But sometimes the SQL is genuinely fast and the API is still slow, the CPU still high — because the bottleneck moved into Ruby itself: heavy serialization, allocating too many objects, recomputing the same thing. This post is about the cost in the application layer, after the request reaches the controller. Same example throughout (a {% raw %}
shipmentstable).
💥 SQL took 30ms, the API took 800ms
A shipments-list API. The APM showed SQL was only 30ms, but the whole request took 800ms. N+1 and indexes were already checked — the DB was fine. The slow part was "what Ruby does after the data comes back."
That remaining 770ms went to three common places: bloated serialization, too many intermediate objects, and the same computation running repeatedly. Let's take them one at a time.
📦 Trap 1: serializing a pile of fields nobody uses
The most common one — the API turns the whole model into JSON:
render json: @shipments # every field of every shipment gets dumped
Problems:
- A shipment has 40 columns, the frontend uses 5 — serializing the other 35 × thousands of rows is pure waste.
- You might also leak sensitive fields (internal cost, API keys) by accident.
The fix: return only the fields you need. Spell them out:
render json: @shipments.as_json(only: [:id, :tracking_no, :status])
Serialization is pure CPU work — fewer fields × more rows, more saved.
Two things worth adding:
-
Safety should come from a structural whitelist, not from remembering
only:.as_jsondumps all fields by default, andonly:is a manual whitelist the next person easily rewrites back torender json: @shipments. To make "new fields don't leak by default" a structural guarantee, use a serializer (Alba / blueprinter / AMS) that pins the visible schema. -
as_json(only:)saves serialized fields, but doesn't save "instantiating every row into an AR object + building a Hash per row" — which is exactly the cost in Trap 2. When fields are few but rows are huge and you want to skip instantiation too, usepluck(:id, :tracking_no, :status)to fetch arrays and never build AR objects at all.
♻️ Trap 2: allocating too many intermediate objects in a hot path (GC pressure)
Every object Ruby creates costs memory; lots of allocation increases GC (garbage collection) work and grows the heap, which drags throughput once it scales. (MRI's GC is generational + incremental, so short-lived objects are actually relatively cheap — the truly expensive thing is "many, long-lived, growing the heap." So the point is: don't allocate pointlessly in a hot path.) It shows up most on a path that runs "for every row":
# ❌ each shipment builds a new hash, a new string, runs a format
@shipments.map do |s|
{ label: "#{s.tracking_no} - #{s.status.upcase} - #{format_date(s.created_at)}" }
end
Thousands of rows × several intermediate objects each = tens of thousands of short-lived objects thrown at the GC.
The fix: allocate fewer intermediates in the hot path — push what the DB can compute to the DB (back to #3), merge steps, and don't rebuild the same thing inside the loop.
# pull the invariant out of the loop, don't rebuild it each iteration
status_labels = { "delivered" => "Delivered", ... } # built once
@shipments.map { |s| status_labels[s.status] }
Concept: object allocation has a cost. One or two don't matter, but amplified by "every row × every request" the GC becomes a hidden bottleneck.
🧮 Trap 3: the same computation, run many times in one request
def total_weight
shipments.sum(&:weight) # recomputes on every call
end
# in one request, the view, a helper, and the serializer each call it → computed three times
The fix: memoization — compute once, reuse within the same request.
def total_weight
@total_weight ||= shipments.sum(&:weight) # compute first time, then just return it
end
@x ||= ... means "compute only when @x is nil or false, otherwise return it." Within the same object, in the same request, this runs once.
⚠️ The
||=trap: it tests "is the value nil/false," not "has it been computed." If the result is legitimatelynilorfalse(e.g. a lookup that returns nil), every call recomputes and memoization is effectively dead — a hidden perf bug on an expensive query. For that, write@x = compute unless defined?(@x). (sumreturns a number so it dodges this, but you should know the general gotcha.)Also: memoization works "within one request" because the object being memoized on is itself built per-request (GC'd when the request ends) — not because the mechanism is request-scoped. Memoize on a class-level / singleton / long-lived service object and it persists across requests, causing stale data or memory leaks. Be careful.
🏁 Wrap-up
When the DB is already fast but the API is still slow, the bottleneck is usually in the Ruby layer:
| trap | symptom | fix |
|---|---|---|
| bloated serialization | returning fields nobody uses | return only what's needed (only: / a serializer / pluck) |
| too much allocation | hot path builds many intermediates, high GC | allocate fewer intermediates, push to the DB |
| repeated computation | same computation many times per request | memoization (`@x |
One principle:
DB optimization is "ask the database for less"; application-layer optimization is "do less busywork after the data comes back."
Each of these looks like "saves a tiny bit" on its own, but they share one amplifier — "every row × every request." One field, one object, one computation, times thousands of rows and thousands of requests, is exactly those vanished hundred-odd milliseconds. Confirm with an APM that it's genuinely the Ruby layer (SQL fast, request slow) before digging in here.