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

    Have you tried Rails load_async?

    Software Engineer
    Software Engineer
    Ruby on Rails
    Ruby on Rails
    published 2025-10-16 23:07:15 +0700 · 7 mins read
    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, ActiveRecord executes queries sequentially. What's interesting here is when they run. The query for @cars doesn't execute immediately; it's an ActiveRecord::Relation that is lazy-loaded, meaning the query only runs when @cars is first used in the view. However, .pluck is different - it executes the query for @bikes immediately. Once that finishes, the code moves on and executes the query for @trucks.

    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

    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

    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 .value yourself. When your Rails view starts iterating over @cars (e.g., <% @cars.each do |car| %>), Rails implicitly calls .value behind the scenes. The view rendering just pauses at that exact moment if, and only if, the data isn't ready yet.

    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

    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 ActiveRecord to use a shared pool of background threads for executing these queries. The default pool size is 4, meaning up to 4 queries will run in parallel. You can change this with config.active_record.global_executor_concurrency, but the default is a reasonable starting point.

    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.

    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)

    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

    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)

    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 ASYNC Truck Load line. The (222.6ms) is the blocking time, which is how long your view was frozen, waiting for the delayed truck query to finish.
    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 load_async in these scenarios:
    • 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.

    Related blogs