Your SQL Is Fast but the API Is Slow: It's the Ruby Layer

ruby dev.to

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 %}shipments table).


💥 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
Enter fullscreen mode Exit fullscreen mode

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])
Enter fullscreen mode Exit fullscreen mode

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_json dumps all fields by default, and only: is a manual whitelist the next person easily rewrites back to render 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, use pluck(: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
Enter fullscreen mode Exit fullscreen mode

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] }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

@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 legitimately nil or false (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). (sum returns 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.

Source: dev.to

arrow_back Back to Tutorials