🔧 Programmierung 🕛 kürzlich 5 Min Lesezeit
0

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

↗ Quelle (dev.to)
🗣️ Stimme:
📑 Inhaltsübersicht

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:




CODE
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:




CODE
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":




CODE
# ❌ 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.




CODE
# 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






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




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

Vollständiger Original-Bericht
Ausführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
↗ Original-Artikel auf dev.to lesen
Wie bewertest du diesen Beitrag?
1 Klick Feedback
Teilen mit Netzwerk & Team:

Community-Analysen & Experten-Meinungen 0

Verfasse deine eigene Analyse, teile Workarounds oder diskutiere diesen Vorfall im Blog.
Noch keine Community-Analyse verfasst. Markiere einen Textabschnitt oder klicke oben auf Eigene Analyse verfassen“!
Community Pulse: Relevanz-Einschätzung
1 Klick Experten-Votum
🔴 Akute Relevanz 50%
🟡 In Evaluierung 31%
🟢 Keine Auswirkung 14%
Spannende Innovation 5%
Verwandte Story-Cluster & Quellen (Vektor-KI)
Port 8095 Engine
2 Quellen
ChatGPT, Claude, and Grok all went down at once; enterprises need a backup plan
2 Quellen
OpenAI launches GPT-6 Astra
1 Quelle
What JPMorgan does differently with AI that any company can apply
Ähnliche Beiträge
🔍 Verwandte News

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

Thematisch verwandte Begriffe: Your, Fast, Slow, Ruby · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...