0%
Reading Settings
Font Size
18px
Line Height
1.5
Letter Spacing
0.01em
Font Family
Table of contents

Have you tried Rails load_async?
Software Engineer
Software Engineer
Ruby on Rails
Ruby on Rails

When you're building a dashboard, it's common to fetch multiple, independent datasets. The page loading might be slow because it has to fetch all data to render a page. A common solution is using AJAX to load pieces of the dashboard, which is great, but we can often speed things up on the server first with parallel fetching. This technique is useful in many situations, so keep it in mind when you face a performance issue.
1. Example
Consider a standard DashboardsController :
// language: ruby class DashboardsController < ActionController::Base def index @cars = Car.order(:created_at).limit(10) @bikes = Bike.order(:created_at).limit(10).pluck(:id, :name) @trucks = Truck.order(:created_at).limit(10).pluck(:id, :name) end end
By default,
The problem? These three queries are independent. There's no reason to make them wait on each other. This sequential waiting is an unnecessary bottleneck.
Since Rails 7, there's a better way: load_async .
Ref: https://www.rubydoc.info/github/rails/rails/ActiveRecord%2FRelation:load_async
Ref: https://www.rubydoc.info/github/rails/rails/ActiveRecord%2FRelation:load_async
2. ActiveRecord::Relation#load_async
We can tell ActiveRecord to execute these queries in parallel by adding .load_async to the relation. For methods that trigger an immediate query like .pluck , Rails provides a direct async alternative like .async_pluck .
Ref: https://www.rubydoc.info/github/rails/rails/ActiveRecord/Calculations
Ref: https://www.rubydoc.info/github/rails/rails/ActiveRecord/Calculations
Here is the refactored controller:
// language: ruby class DashboardsController < ActionController::Base def index @cars = Car.order(:created_at).limit(10).load_async @bikes = Bike.order(:created_at).limit(10).async_pluck(:id, :name) @trucks = Truck.order(:created_at).limit(10).async_pluck(:id, :name) end end
Now, Rails will use a background thread pool to run all three queries concurrently. The total response time will be dictated by the slowest individual query, not the sum of all three.
3. ActiveRecord::Promise
When you call an async method, ActiveRecord doesn't immediately return the data. Instead, it returns an ActiveRecord::Promise . This is a placeholder object that will be resolved once the database query completes.
This promise object gives you a few useful methods to interact with the pending result:
#value : This is the primary method. Calling it returns the query result. If the query is still running, your main thread will pause right here and wait for it to finish. If it's already done, you get the result instantly.#pending? : Returns true if the background query is still executing and false otherwise. This can be useful for debugging.#then(&block) : Allows you to chain an operation that will execute only after the promise is fulfilled (the query completes). This is similar to how promises work in JavaScript.
You often don't need to call
4. Configuration is Required
Out of the box, load_async runs synchronously for backward compatibility. To enable it, you must configure the async_query_executor in your application configuration.
Ref: https://guides.rubyonrails.org/configuring.html#config-active-record-async-query-executor
Ref: https://guides.rubyonrails.org/configuring.html#config-active-record-async-query-executor
Set this in config/application.rb or the relevant environment file:
// language: ruby # config/application.rb config.active_record.async_query_executor = :global_thread_pool
This tells
A crucial part of this setup is your database connection pool. Each async query requires its own database connection. If you try to run 4 parallel queries, you need at least 4 available connections in your pool. If your pool size is too small, your async queries will get stuck waiting for a free connection, defeating the purpose of running them in parallel.
Check your config/database.yml and ensure the pool size is large enough for your concurrency needs.
// language: yaml # config/database.yml default: &default adapter: postgresql encoding: unicode pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> development: <<: *default database: myapp_development
Thus, the pool size should be at least thread_count + global_executor_concurrency + 1. For example, if your web server has a maximum of 3 threads, and global_executor_concurrency is set to 4, then your pool size should be at least 8.
Ref: https://guides.rubyonrails.org/configuring.html#config-active-record-global-executor-concurrency
5. A Quick Benchmark
Let's simulate three queries that each take 300ms using PostgreSQL's pg_sleep() function.
Synchronous Execution
Here, we call pluck, which triggers the query (and the sleep) immediately:
// language: ruby def index @cars = Car.where("pg_sleep(0.3) IS NOT NULL") @bikes = Bike.where("pg_sleep(0.3) IS NOT NULL") @trucks = Truck.where("pg_sleep(0.3) IS NOT NULL") end # Clean log output | Started GET "/admin/dashboards" for ::1 at 2025-10-16 xx:yy:zz +0700 | Processing by Admin::DashboardsController#index as HTML | Rendering layout layouts/application.html.erb | Rendering admin/dashboards/index.html.erb within layouts/application | Car Load (304.7ms) SELECT "cars".* FROM "cars" WHERE (pg_sleep(0.3) IS NOT NULL) | ↳ app/views/admin/dashboards/index.html.erb:1 | Bike Load (310.3ms) SELECT "bikes".* FROM "bikes" WHERE (pg_sleep(0.3) IS NOT NULL) | ↳ app/views/admin/dashboards/index.html.erb:6 | Truck Load (305.5ms) SELECT "trucks".* FROM "trucks" WHERE (pg_sleep(0.3) IS NOT NULL) | ↳ app/views/admin/dashboards/index.html.erb:11 | Rendered admin/dashboards/index.html.erb within layouts/application (Duration: 961.4ms | GC: 0.0ms) | Rendered layout layouts/application.html.erb (Duration: 970.1ms | GC: 0.0ms) | Completed 200 OK in 973ms (Views: 51.3ms | ActiveRecord: 920.3ms (3 queries, 0 cached) | GC: 0.0ms)
The database queries run one after another, so the total ActiveRecord time is the sum of each query: 304.7ms + 310.3ms + 305.5ms
The total request time is 973ms
The total request time is 973ms
Asynchronous Execution
Using async_pluck runs them all in parallel:
// language: ruby def index @cars = Car.where("pg_sleep(0.3) IS NOT NULL").load_async @bikes = Bike.where("pg_sleep(0.3) IS NOT NULL").load_async @trucks = Truck.where("pg_sleep(0.3) IS NOT NULL").load_async end # Clean log output | Started GET "/admin/dashboards" for ::1 at 2025-10-16 aa:bb:cc +0700 | Processing by Admin::DashboardsController#index as HTML | Rendering layout layouts/application.html.erb | Rendering admin/dashboards/index.html.erb within layouts/application | ASYNC Car Load (298.9ms) (db time 303.8ms) SELECT "cars".* FROM "cars" WHERE (pg_sleep(0.3) IS NOT NULL) | ↳ app/views/admin/dashboards/index.html.erb:1 | ASYNC Bike Load (0.0ms) (db time 305.2ms) SELECT "bikes".* FROM "bikes" WHERE (pg_sleep(0.3) IS NOT NULL) | ↳ app/views/admin/dashboards/index.html.erb:6 | ASYNC Truck Load (0.0ms) (db time 305.2ms) SELECT "trucks".* FROM "trucks" WHERE (pg_sleep(0.3) IS NOT NULL) | ↳ app/views/admin/dashboards/index.html.erb:11 | Rendered admin/dashboards/index.html.erb within layouts/application (Duration: 354.4ms | GC: 0.0ms) | Rendered layout layouts/application.html.erb (Duration: 368.8ms | GC: 0.0ms) | Completed 200 OK in 377ms (Views: 43.8ms | ActiveRecord: 941.7ms (3 queries, 0 cached) | GC: 0.0ms)
The total request time has dropped to 377ms. The ActiveRecord time of 941.7ms now represents the total combined work done by the database across all threads. Because they ran concurrently, the actual wait time was only as long as the slowest query (~305ms).
This results in a 2.58x faster response time for the user (973ms → 377ms).
The Impact of global_executor_concurrency
What happens if we set global_executor_concurrency = 2 but try to run three async queries?
// language: ruby # Clean log output | ASYNC Car Load (296.8ms) (db time 302.0ms) SELECT "cars".* FROM "cars" WHERE (pg_sleep(0.3) IS NOT NULL) | ↳ app/views/admin/dashboards/index.html.erb:1 | ASYNC Bike Load (0.0ms) (db time 345.5ms) SELECT "bikes".* FROM "bikes" WHERE (pg_sleep(0.3) IS NOT NULL) | ↳ app/views/admin/dashboards/index.html.erb:6 | ASYNC Truck Load (222.6ms) (db time 315.9ms) SELECT "trucks".* FROM "trucks" WHERE (pg_sleep(0.3) IS NOT NULL)
Look at the
This happened because with global_executor_concurrency = 2 , the Car and Bike queries took all the available threads. The Truck query had to wait for its turn. A blocking time this high completely undermines the benefit of running queries in parallel.
This highlights the need to carefully tune this setting. It's a balance between:
- Too low: Creating bottlenecks in your application, as queries get stuck waiting for available threads.
- Too high: Risking overwhelming your database with too many simultaneous connections and queries, which can slow down the entire system.
Therefore, it's essential to monitor your application's performance and database load to find the optimal concurrency level for your specific workload.
6. When to Use Async Queries
This pattern is most effective for:
- Dashboards and reports: Pages that aggregate data from multiple independent models.
- Complex views: Any page with multiple, non-dependent queries that contribute to the total response time.
Avoid using
- Dependent queries: If a subsequent query relies on the result of a previous one, you cannot run them in parallel.
- Inside transactions: Async queries run on separate database connections from the pool and will not be part of the main thread's transaction. This can lead to data inconsistencies.
- Extremely fast queries: The overhead of managing threads can make ultra-fast queries (<5ms) slightly slower. That's why, if you test on your local machine without
pg_sleep , the render time might increase.
7. Conclusion
For pages slowed down by multiple independent database calls, load_async is a powerful, low-effort tool for improving performance. By enabling the async_query_executor and ensuring your connection pool is sufficient, you can significantly reduce user wait time.
Always remember to measure the impact of any optimization on key metrics, such as response time and memory. There is no one-size-fits-all solution, so benchmark your changes to confirm they provide a net benefit for your specific scenario.
Always remember to measure the impact of any optimization on key metrics, such as response time and memory. There is no one-size-fits-all solution, so benchmark your changes to confirm they provide a net benefit for your specific scenario.
Related blogs

Choose the Correct HTTP Status Codes for CRUD APIs
When building REST APIs, many people default to returning 200 OK for everything. But HTTP provides a rich set of status codes that communicate exactly what happened. Using them correctly makes your API more predictable, debuggable, and self-documenti...
Software Engineer
Software Engineer


Reducing ActiveRecord Queries to Speed Up Your Requests
In Rails, ActiveRecord is one of the things that makes the framework so enjoyable to use. It reads almost like natural language and lets you write database logic in a clean, Ruby-style way.But every line of Active Record still turns into real SQL beh...
Software Engineer
Software Engineer
Ruby on Rails
Ruby on Rails
