35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
Rails 8 load_async: Voer Database Queries Parallel Uit en Halveer je Responstijden

Rails 8 load_async: Voer Database Queries Parallel Uit en Halveer je Responstijden

roger
Gebruik load_async, async_count en async_sum in Rails 8 om database queries te paralleliseren. Met benchmarks die 40-60% snellere controller actions laten zien.

Een doorsnee Rails controller action vuurt 3-5 database queries sequentieel af. Elke query wacht tot de vorige klaar is. Bij een dashboard-pagina met vier queries van elk 50ms is dat 200ms aan database-wachttijd — terwijl geen van die queries van elkaar afhankelijk is.

Rails 8 biedt load_async en zijn varianten (async_count, async_sum, async_minimum, async_maximum, async_average, async_pluck) om onafhankelijke queries parallel uit te voeren. In productie-omgevingen heb ik hiermee consistent 40-60% reductie in totale database-wachttijd gezien.

Hoe load_async Werkt

Wanneer je load_async aanroept op een ActiveRecord-relatie, plant Rails de query in op een achtergrondthread uit de async executor pool. De query wordt direct uitgevoerd in plaats van te wachten op lazy evaluation. Wanneer je de resultaten gebruikt, zijn ze al geladen — of Rails blokkeert kort tot ze klaar zijn.

# Sequentieel (standaard) — totaal: ~200ms
users = User.where(active: true).load           # 50ms
orders = Order.where(created_at: 1.day.ago..).load  # 60ms
stats = ProductView.group(:product_id).count     # 45ms
notifications = Notification.unread.limit(20).load   # 40ms

# Parallel met load_async — totaal: ~60ms (langzaamste query)
users = User.where(active: true).load_async
orders = Order.where(created_at: 1.day.ago..).load_async
stats = ProductView.group(:product_id).async_count
notifications = Notification.unread.limit(20).load_async

Het mooie: load_async retourneert dezelfde ActiveRecord::Relation. Je view-code verandert niet. Je itereert over users precies zoals je gewend bent.

Configuratie in Rails 8

Rails 8 configureert de async executor pool in database.yml:

# config/database.yml
production:
  adapter: postgresql
  pool: 20
  async_executor:
    min_threads: 0
    max_threads: 10
    max_queue: 50
    idletime: 300

De max_threads instelling beperkt hoeveel queries tegelijk kunnen draaien. In de praktijk werkt max_threads: 10 goed voor de meeste apps. Zet het te hoog en je put je connection pool uit — je pool waarde moet altijd groter zijn dan max_threads.

Als je werkt met meerdere databases en read replicas, werken async queries met elke geconfigureerde databaseconnectie. Elke replica kan zijn eigen executor pool hebben.

Praktijkvoorbeeld: Dashboard Controller

Een voor-en-na van een SaaS-dashboard dat ik geoptimaliseerd heb:

# Ervoor: sequentiële queries, ~320ms totale DB-tijd
class DashboardController < ApplicationController
  def show
    @total_revenue = Order.where(created_at: 30.days.ago..).sum(:total)
    @active_users = User.where(last_seen_at: 7.days.ago..).count
    @recent_orders = Order.includes(:customer).order(created_at: :desc).limit(25)
    @top_products = OrderItem.group(:product_id)
                             .select("product_id, SUM(quantity) as total_qty")
                             .order("total_qty DESC")
                             .limit(10)
    @pending_tickets = SupportTicket.where(status: :open).count
  end
end
# Erna: parallelle queries, ~95ms totale DB-tijd
class DashboardController < ApplicationController
  def show
    @total_revenue = Order.where(created_at: 30.days.ago..).async_sum(:total)
    @active_users = User.where(last_seen_at: 7.days.ago..).async_count
    @recent_orders = Order.includes(:customer).order(created_at: :desc).limit(25).load_async
    @top_products = OrderItem.group(:product_id)
                             .select("product_id, SUM(quantity) as total_qty")
                             .order("total_qty DESC")
                             .limit(10)
                             .load_async
    @pending_tickets = SupportTicket.where(status: :open).async_count
  end
end

Vijf queries die eerder na elkaar draaiden, worden nu tegelijk afgevuurd. De totale wall-clock tijd daalde naar de duur van de langzaamste query.

De async_ Aggregatiemethoden

Rails 8 bevat async-versies van alle veelgebruikte aggregatiemethoden:

User.async_count                    # => Promise<Integer>
Order.async_sum(:total)             # => Promise<BigDecimal>
Product.async_minimum(:price)       # => Promise<BigDecimal>
Product.async_maximum(:price)       # => Promise<BigDecimal>
Review.async_average(:rating)       # => Promise<Float>
User.async_pluck(:email)            # => Promise<Array<String>>

Elk retourneert een ActiveRecord::Promise. Wanneer je .value aanroept (of het in een view gebruikt waar Rails automatisch .to_s aanroept), blokkeert het tot het resultaat klaar is. In ERB-templates gebeurt dit transparant:

<p>Totale omzet: <%= number_to_currency(@total_revenue) %></p>
<%# Rails roept automatisch .value aan bij het renderen %>

Wanneer load_async NIET Gebruiken

Niet elke query profiteert van async uitvoering. Sla het over wanneer:

Queries van elkaar afhankelijk zijn. Als query B resultaten van query A gebruikt, kunnen ze niet parallel draaien.

# NIET DOEN — user_ids komt uit de eerste query
user_ids = User.where(active: true).pluck(:id)
orders = Order.where(user_id: user_ids).load_async  # Te laat, al gewacht

Enkele-query actions. Bij één query voegt async alleen thread-scheduling overhead toe zonder voordeel.

Transacties. Async queries draaien op aparte connecties, buiten je huidige transactie. Gebruik load_async nooit binnen een transactieblok waar je consistente reads nodig hebt.

Hele kleine queries. Bij queries van 1-2ms eet de thread-scheduling overhead (~0.5ms) je winst op. Focus async op queries van 20ms+.

Connection Pool Sizing

De grootste valkuil bij load_async is uitputting van de connection pool. Elke async query checkt een aparte databaseconnectie uit.

De berekening:

Benodigde pool size ≥ (puma_threads × max_async_queries_per_request) + puma_threads

Voor een typische Puma-setup met 5 threads en maximaal 4 async queries per request:

Pool size ≥ (5 × 4) + 5 = 25

Gebruik je PgBouncer in transaction mode, dan werken async queries prima — elke query krijgt zijn eigen connectie uit de bouncer pool. Zorg ervoor dat PgBouncer’s max_client_conn rekening houdt met de extra connecties.

Houd je database-connectiemetrics goed in de gaten na het deployen van async queries.

Benchmarking

Benchmark het verschil in je specifieke app voordat je commit:

sync_time = Benchmark.ms do
  User.where(active: true).count
  Order.where(created_at: 1.day.ago..).sum(:total)
  Product.where(featured: true).to_a
  SupportTicket.where(status: :open).count
end

async_time = Benchmark.ms do
  a = User.where(active: true).async_count
  b = Order.where(created_at: 1.day.ago..).async_sum(:total)
  c = Product.where(featured: true).load_async
  d = SupportTicket.where(status: :open).async_count
  [a.value, b.value, c.to_a, d.value]
end

puts "Sync: #{sync_time.round(1)}ms | Async: #{async_time.round(1)}ms"

In mijn tests tegen een PostgreSQL 16 database met realistische datavolumes (500K users, 2M orders) draaide de async versie consistent in 35-40% van de sequentiële tijd.

FAQ

Werkt load_async met SQLite?

Ja, maar je ziet weinig winst. SQLite gebruikt een single writer met beperkte concurrency. De queries worden uitgevoerd, maar serialiseren op database-niveau. load_async schittert met PostgreSQL en MySQL waar de database concurrent connections native afhandelt.

Kan ik load_async gebruiken in background jobs?

Dat kan, maar het helpt zelden. Background jobs verwerken typisch één record of batch tegelijk. Het parallelisme-voordeel zit in controller actions die web requests bedienen waar meerdere onafhankelijke queries moeten voltooien voor het renderen.

Wat gebeurt er als een async query een fout geeft?

De exceptie wordt opgevangen door de promise en opnieuw gegooid wanneer je het resultaat opvraagt. Als je view @users.each aanroept, bubbelt de fout daar op met de originele exception class en message. Standaard rescue_from in je controller handelt het normaal af.

Werkt load_async met includes en preloading?

Ja. Post.includes(:comments, :author).load_async vuurt de hoofdquery en preload-queries asynchroon af. De associaties zijn beschikbaar wanneer je itereert. Dit is een van de meest impactvolle toepassingen — preload queries die normaal sequentieel na de primaire query draaien, overlappen nu.

Hoe verschilt dit van Ruby’s Fiber scheduler async?

Ruby’s Fiber scheduler opereert op I/O-niveau en maakt alle blokkerende operaties non-blocking binnen een enkele thread. Rails’ load_async is gerichter: het paralleliseert specifiek ActiveRecord queries met een thread pool. Je kunt beide samen gebruiken — de Fiber scheduler handelt algemene I/O af terwijl load_async specifiek database query-parallelisme optimaliseert.

#ruby-on-rails #performance #database #rails-8
r

About the Author

Roger Heykoop is een senior Ruby on Rails ontwikkelaar met 19+ jaar Rails ervaring en 35+ jaar ervaring in softwareontwikkeling. Hij is gespecialiseerd in Rails modernisering, performance optimalisatie, en AI-ondersteunde ontwikkeling.

Get in Touch

Share this article

Need Expert Rails Development?

Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise

Schedule a Free Consultation