Fetching latest headlines…
Your SQL Is Fast but the API Is Slow: It's the Ruby Layer
NORTH AMERICA
🇺🇸 United StatesJune 28, 2026

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

0 views0 likes0 comments
Originally published byDev.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

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_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

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 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.

Comments (0)

Sign in to join the discussion

Be the first to comment!