Rails 8 load_async: Run Database Queries in Parallel and Cut Response Times
A typical Rails controller action fires 3-5 database queries sequentially. Each one waits for the previous to finish. On a dashboard page hitting four queries that each take 50ms, that’s 200ms of database time — even though none of those queries depend on each other.
Rails 8 ships with load_async and its siblings (async_count, async_sum, async_minimum, async_maximum, async_average, async_pluck) to run independent queries in parallel. I’ve used this on production dashboards and consistently seen 40-60% reductions in total database wait time.
How load_async Works Under the Hood
When you call load_async on an ActiveRecord relation, Rails schedules the query on a background thread from the async executor pool. The query fires immediately rather than waiting for lazy evaluation. When you finally access the results, they’re already loaded — or Rails blocks briefly until they are.
# Sequential (default) — total: ~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 with load_async — total: ~60ms (longest 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
The key insight: load_async returns the same ActiveRecord::Relation. Your view code doesn’t change at all. You iterate over users the same way you always did.
Configuration in Rails 8
Rails 8 configures the 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
The max_threads setting caps how many queries can run concurrently. I’ve found max_threads: 10 works well for most apps. Set it too high and you’ll exhaust your connection pool — your pool value should always exceed max_threads.
If you’re using multiple databases with read replicas, async queries work with any configured database connection. Each replica can have its own executor pool.
Real-World Example: Dashboard Controller
Here’s a before-and-after from a SaaS dashboard I optimized:
# Before: sequential queries, ~320ms total DB time
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
# After: parallel queries, ~95ms total DB time
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
Five queries that previously ran one after another now fire simultaneously. The total wall-clock time dropped to roughly the duration of the slowest query.
The async_ Aggregate Methods
Rails 8 includes async versions of all common aggregate methods:
# These return an ActiveRecord::Promise
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>>
Each returns an ActiveRecord::Promise. When you call .value on the promise (or use it in a view where Rails calls .to_s or iterates), it blocks until the result is ready. In ERB templates, this happens transparently:
<p>Total revenue: <%= number_to_currency(@total_revenue) %></p>
<%# Rails calls .value automatically when rendering %>
When NOT to Use load_async
Not every query benefits from async execution. Skip it when:
Queries depend on each other. If query B uses results from query A, they can’t run in parallel. This sounds obvious, but it’s easy to miss with scoped queries:
# DON'T — user_ids comes from the first query
user_ids = User.where(active: true).pluck(:id)
orders = Order.where(user_id: user_ids).load_async # Too late, already waited
Single-query actions. If your action runs one query, async adds thread scheduling overhead for zero benefit.
Transactions. Async queries run on separate connections, outside your current transaction. Never use load_async inside a transaction block where you need consistent reads.
Tiny queries. If a query takes 1-2ms, the thread scheduling overhead (~0.5ms) eats into your gains. Focus async on queries taking 20ms+.
Monitoring Async Query Performance
Add instrumentation to verify your async queries actually run in parallel. Rails emits sql.active_record notifications that include a :async flag:
# config/initializers/async_query_monitor.rb
ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
if event.payload[:async]
Rails.logger.debug(
"[ASYNC SQL] #{event.payload[:name]} — #{event.duration.round(1)}ms"
)
end
end
In production, I track the ratio of async-to-sync queries per controller action using OpenTelemetry custom metrics. This helps spot controller actions that could benefit from parallelization.
Connection Pool Sizing
The biggest gotcha with load_async is connection pool exhaustion. Each async query checks out a separate database connection. If you have 5 async queries per request and 50 concurrent requests, you need 250+ connections available.
The math:
Required pool size ≥ (puma_threads × max_async_queries_per_request) + puma_threads
For a typical Puma setup with 5 threads and up to 4 async queries per request:
Pool size ≥ (5 × 4) + 5 = 25
If you’re using PgBouncer in transaction mode, async queries work fine — each query gets its own connection from the bouncer pool. Just make sure PgBouncer’s max_client_conn accounts for the additional connections.
Watch your database connection metrics closely after deploying async queries. A spike in “waiting for connection” events means your pool is too small.
Benchmarking with Benchmark.ms
Before committing to async queries, benchmark the difference in your specific app:
# In rails console
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] # Force resolution
end
puts "Sync: #{sync_time.round(1)}ms | Async: #{async_time.round(1)}ms"
In my tests against a PostgreSQL 16 database with realistic data volumes (500K users, 2M orders), the async version consistently ran in 35-40% of the sequential time when all four queries took 30ms+.
FAQ
Does load_async work with SQLite?
Yes, but you won’t see meaningful gains. SQLite uses a single writer and limited concurrency. The queries still execute, but they serialize at the database level. load_async shines with PostgreSQL and MySQL where the database handles concurrent connections natively.
Can I use load_async in background jobs?
You can, but it rarely helps. Background jobs typically process one record or batch at a time. The parallelism benefit comes from controller actions serving web requests where multiple independent queries need to complete before rendering.
What happens if an async query raises an error?
The exception is captured by the promise and re-raised when you access the result. If your view calls @users.each, the error bubbles up at that point with the original exception class and message intact. Standard rescue_from in your controller handles it normally.
Does load_async work with includes and preloading?
Yes. Post.includes(:comments, :author).load_async fires the main query and preload queries asynchronously. The associations are available when you iterate. This is one of the highest-impact uses — preload queries that normally run sequentially after the primary query now overlap.
How is this different from Ruby’s Fiber scheduler async?
Ruby’s Fiber scheduler operates at the I/O level, making all blocking operations (network, file, DNS) non-blocking within a single thread. Rails’ load_async is more targeted: it specifically parallelizes ActiveRecord queries using a thread pool. You can use both together — the Fiber scheduler handles general I/O while load_async specifically optimizes database query parallelism.
About the Author
Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.
Get in TouchRelated Articles
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