Rails 8 introduces a game-changing feature for database performance: load_async. This new method enables true asynchronous loading of ActiveRecord associations, allowing your application to perform multiple database queries concurrently rather than sequentially.
This article explores the implementation, benefits, and best practices for leveraging load_async to dramatically improve your application’s performance.
🧩 Understanding the Problem: Sequential Loading Bottleneck
Before Rails 8, even with techniques like includes, preload, and eager_load, associations were loaded sequentially:
1
2
3
4
5
6
7
| # Before Rails 8 - Sequential loading
users = User.includes(:posts, :comments, :profile).limit(10)
# Still loads in this order:
# 1. SELECT users (1 query)
# 2. SELECT posts WHERE user_id IN (...) (1 query)
# 3. SELECT comments WHERE user_id IN (...) (1 query)
# 4. SELECT profiles WHERE user_id IN (...) (1 query)
|
Even though these are separate queries, they execute one after another, creating a sequential bottleneck that limits concurrency.
⚡ Introducing load_async: True Parallel Loading
Rails 8’s load_async enables true asynchronous loading by leveraging Ruby’s concurrent capabilities:
1
2
3
4
5
6
7
8
9
10
| # Rails 8 with load_async - Concurrent loading
users = User.limit(10).to_a
# Concurrent loading of associations
users.load_async(:posts)
users.load_async(:comments)
users.load_async(:profile)
# All three association queries execute in parallel
# When you access the data, it's already loaded!
|
🔧 Setting Up Rails 8 for load_async
1. Upgrade to Rails 8
1
2
3
| # Gemfile
gem 'rails', '~> 8.0'
gem 'bootsnap', require: false
|
1
2
3
4
5
6
| # config/database.yml
production:
adapter: postgresql
pool: 25 # Increase pool size for concurrent queries
timeout: 5000
# Ensure your database can handle more connections
|
1
2
3
| # config/application.rb
config.active_record.async_query_executor = :global_thread_pool
config.active_record.async_query_executor_concurrency = 5 # Max concurrent queries
|
💡 Practical Examples
Example 1: Basic Association Loading
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| # Controller
def index
@users = User.active.includes(:posts, :comments).limit(20)
# Instead of multiple sequential N+1 queries
@users.each do |user|
user.posts.load_async if user.posts.loaded?
user.comments.load_async if user.comments.loaded?
end
# Or more concisely:
@users.load_async(:posts, :comments)
end
# View
<% @users.each do |user| %>
<h3><%= user.name %></h3>
<p>Posts: <%= user.posts.count %></p> <!-- Already loaded asynchronously -->
<p>Comments: <%= user.comments.count %></p> <!-- Already loaded asynchronously -->
<% end %>
|
Example 2: Complex Multi-Level Loading
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
| class DashboardController < ApplicationController
def show
@dashboard_data = fetch_dashboard_data_async
end
private
def fetch_dashboard_data_async
# Fetch base data
current_user = User.find(current_user.id)
# Load complex nested associations concurrently
current_user.load_async(:posts, :comments, :followers, :following)
# Load nested associations in parallel
posts = current_user.posts.to_a
posts.load_async(:category, :author, :tags)
comments = current_user.comments.to_a
comments.load_async(:post, :replies)
# Wait for all to complete and return
current_user.posts(true) # force reload to ensure async loading complete
current_user.comments(true)
{
user: current_user,
posts: posts,
comments: comments,
stats: calculate_stats_async(current_user, posts, comments)
}
end
def calculate_stats_async(user, posts, comments)
stats = {}
# Calculate statistics concurrently
Thread.new { stats[:post_count] = posts.size }
Thread.new { stats[:comment_count] = comments.size }
Thread.new { stats[:avg_engagement] = posts.sum(&:view_count) / posts.size.to_f }
Thread.new { stats[:recent_activity] = user.recent_activity }
# Wait for all calculations to complete
sleep 0.1 while stats.keys.length < 4
stats
end
end
|
Example 3: Batch Processing with load_async
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| class ReportGenerator
def generate_user_report_async(user_ids)
users = User.where(id: user_ids).to_a
# Load all associations concurrently across multiple users
users.load_async(:posts, :orders, :preferences, :notifications)
# Process each user with pre-loaded data
users.map do |user|
{
id: user.id,
name: user.name,
total_posts: user.posts.size,
total_orders: user.orders.size,
preferences: user.preferences.attributes,
unread_notifications: user.notifications.unread.count
}
end
end
end
|
Before load_async (Sequential):
1
2
3
4
| Benchmark.measure do
users = User.includes(:posts, :comments, :profile).limit(50)
# Takes ~450ms (180ms + 140ms + 130ms sequentially)
end
|
After load_async (Concurrent):
1
2
3
4
5
| Benchmark.measure do
users = User.limit(50).to_a
users.load_async(:posts, :comments, :profile)
# Takes ~180ms (max of 180ms, 140ms, 130ms in parallel)
end
|
Performance gain: ~60% faster for association loading!
⚠️ Important Considerations
1. Database Connection Pool
Ensure your database can handle increased concurrent connections:
1
2
3
4
5
6
7
8
| # config/environments/production.rb
Rails.application.configure do
config.active_record.connection_adapters.postgresql_adapter.async_query_executor = {
pool: ActiveRecord::ConnectionAdapters::ConcurrentAsyncQueryExecutor.new(
size: 10 # Adjust based on your DB capacity
)
}
end
|
2. Error Handling
1
2
3
4
5
6
7
8
9
10
11
12
| def load_user_data_async(user_ids)
users = User.where(id: user_ids).to_a
begin
users.load_async(:posts, :comments, :profile)
users
rescue ActiveRecord::QueryCanceled => e
# Handle async query cancellation
Rails.logger.error "Async query failed: #{e.message}"
User.includes(:posts, :comments, :profile).where(id: user_ids) # Fallback
end
end
|
3. Memory Usage
Monitor memory usage as concurrent loading can increase memory pressure:
1
2
3
4
5
6
7
8
9
| # Monitor memory during async operations
def load_with_memory_check(records, *associations)
initial_memory = `ps -o rss= -p #{Process.pid}`.to_i
records.load_async(*associations)
final_memory = `ps -o rss= -p #{Process.pid}`.to_i
Rails.logger.warn "Memory increase: #{final_memory - initial_memory} KB" if final_memory > initial_memory + 10000
end
|
🧠 Best Practices
1. Use Judiciously
Only use load_async when you’re certain you’ll access the associations:
1
2
3
4
5
6
7
8
9
10
11
| # ✅ Good - You'll definitely use the data
def show
@user = User.find(params[:id])
@user.load_async(:posts, :comments) # Only if you'll render them
end
# ❌ Avoid - May waste resources loading unused data
def show
@user = User.find(params[:id])
@user.load_async(:posts, :comments, :followers, :following) # Maybe you only need posts
end
|
2. Combine with Other Optimizations
1
2
3
4
5
6
7
8
9
| def dashboard
@users = User
.includes(:profile) # For frequently needed associations
.where(active: true)
.limit(20)
.to_a
@users.load_async(:posts, :comments) # For less frequently used associations
end
|
3. Monitor and Measure
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| # app/controllers/concerns/async_monitoring.rb
module AsyncMonitoring
extend ActiveSupport::Concern
def with_async_monitoring
start_time = Time.current
result = yield
duration = Time.current - start_time
Rails.logger.info "Async loading completed in #{duration}s"
Rails.logger.info "Active async queries: #{ActiveRecord::Base.connection.active_query_count}" if Rails.env.development?
result
end
end
|
🧪 Testing load_async
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| # spec/models/user_spec.rb
RSpec.describe User, type: :model do
describe '#load_async' do
let(:users) { FactoryBot.create_list(:user, 5) }
it 'loads associations concurrently' do
# Warm up the connection pool
User.count
expect do
users.load_async(:posts, :comments)
# Access the loaded associations
users.each { |u| [u.posts, u.comments] }
end.to perform_under(200).ms
end
it 'handles async query errors gracefully' do
# Test error handling scenarios
allow_any_instance_of(ActiveRecord::Relation)
.to receive(:load_async).and_raise(ActiveRecord::QueryCanceled)
expect { users.load_async(:posts) }.not_to raise_error
end
end
end
|
🔮 Future Considerations
Rails 8.1 and beyond may include:
- Automatic async detection: Rails automatically detects when async loading would be beneficial
- Better error handling: More granular control over async query failures
- Integration with caching: Async loading combined with smart caching for maximum performance
🏁 Conclusion
Rails 8’s load_async is a powerful addition that enables true asynchronous loading of ActiveRecord associations. By leveraging concurrent database queries, you can achieve significant performance improvements—often 50-70% faster loading for complex association trees.
However, success with load_async requires:
- Proper database connection pool configuration
- Careful consideration of when to use async loading
- Monitoring of memory usage and error conditions
- Proper fallback strategies for error scenarios
When implemented correctly, load_async can dramatically improve your application’s responsiveness and user experience. Start small, measure performance, and gradually expand your async loading strategy across your application.
The future of Rails performance is concurrent, and load_async is the first major step in that direction.