Turbocharge Your Rails App: A Comprehensive Guide to Async Queries
Tags: ruby, rails, tutorial, programming
Published: 2023-11-15 13:38:46

With the release of Rails 7.1, an intriguing feature has emerged: the implementation of Async Queries. This functionality opens new horizons for Ruby on Rails developers, providing an innovative approach to optimize controller performance.

Let's explore how async queries work, how to implement them, and what they truly bring, using a practical use case: optimizing an e-commerce dashboard.

# Dataset Presentation

As usual, we'll take a practical use case to illustrate all the examples in this article. Today, we'll use a simplified e-commerce scenario. Here's the basic database schema:


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0rm9qb7ac1fc675plhco.png)

- A **User** has many **Baskets**.
- A **Basket** has many **Products** through **BasketsProduct**.

Sufficient material to play around with some complex queries!

But before diving into our example, let's understand what async queries are fundamentally.

---

# What Are Async Queries?

In one sentence: Async Query allows you to **prepare your database queries** in the **background** before reaching the moment of exploiting that data.

Take a simple example: I want to get the most purchased items on my site. Let's say, in the `Product` model, I have a scope `order_by_baskets` that allows me to order products by the number of times they appear in the `BasketsProduct` table.

Without Active Query, it would look like this:

```ruby
# In your controller:
@most_loved_products = Product.order_by_baskets.count
sleep 1
# We add sleep to simulate other actions in the controller.
```

You can then use the value of `@most_loved_products` by calling it as is. The server-side result is as follows:

```ruby
Product Load (366.0ms)
Completed 200 OK in 1423ms
```

The SQL query in `@most_loved_products` is executed **only when needed**, i.e., in the view. That's why we have sequentially the one-second sleep and the data loading.

Now, let's add some asynchronous processing! Our controller becomes:

```ruby
# In your controller:
@most_loved_products = Product.group_by_baskets.async_count
sleep 1
# We add sleep to simulate other actions in the controller.
```

By replacing `.count` with `.async_count`, we completely change the behavior of `@most_loved_products`.

1. The variable is no longer an `Integer` but a `Promise`.
2. To use its value, you need to call `@most_loved_products.value`.
3. And most importantly, the return value is calculated in the background.

Looking at the server side, we get this:

```ruby
ASYNC Product Count (0.0ms) (db time 404.5ms)
Completed 200 OK in 1021ms
```

The line logging the Product count reveals everything. In the main thread, it took 0ms of bandwidth because the counting is asynchronous, as indicated by the keyword `ASYNC` at the beginning of the line. But in reality, the count took ~400ms of "db time." The DB Time is this action in the form of a `Promise` that is in the background.

When the thread is blocked by sleep, **it does not stop the async count** from doing its job on its side.

This is where asynchronous actions in Rails are very powerful!

---

# Different Types of Asynchronous Actions

In the `ActiveRecord::Calculations` module, you can find several asynchronous methods that are exposed. Here's an example of a controller that uses them all:

```ruby
def index
    # We want to know the average basket total
    @average_basket_price = Basket.async_average(:total)
    # We want to know the maximum basket total
    @maximum_basket_price = Basket.async_maximum(:total)
    # We want to know the minimum basket total
    @minimum_basket_price = Basket.async_minimum(:total)
    # We want to know the last user's email
    @last_user_email = User.order(:created_at).async_pick(:email)
    # We want to know the revenue this month
    @revenue_this_month = Basket.where(created_at: DateTime.now.all_month).async_sum(:total)
    # We want to know more about the most loved products
    most_loved_products = Product.order_by_baskets.limit(100)
    # We want to pluck their name
    @most_loved_products = most_loved_products.async_pluck(:name)
    # We want to know how many times they were bought
    @most_loved_products_count = most_loved_products.async_count
  end

```

Let's compare the "synchronous" and "asynchronous" versions of the two calls from our server:

**Synchronous:**

```ruby
Basket Average (28.1ms)
Basket Maximum (9.7ms)
Basket Minimum (11.4ms)
User Pluck (15.1ms)
Basket Sum (24.9ms)
Product Pluck (413.2ms)
Product Count (294.3ms)
Completed 200 OK in 817ms
```

**Asynchronous:**

```ruby
ASYNC Basket Average (47.0ms) (db time 48.4ms)
ASYNC Basket Maximum (0.0ms) (db time 45.1ms)
ASYNC Basket Minimum (0.0ms) (db time 42.5ms)
ASYNC User Pluck (0.0ms) (db time 41.6ms)
ASYNC Basket Sum (1.7ms) (db time 15.6ms)
ASYNC Product Pluck (344.8ms) (db time 358.5ms)
ASYNC Product Count (0.0ms) (db time 324.3ms)
Completed 200 OK in 410ms
```

The **Asynchronous** version is about 2 times faster than the **Synchronous** version while performing exactly the same processing!

---

# To Use or Not to Use Async Queries?

At this point, you might be thinking, "Great, I can finally optimize my dashboard that takes 2 seconds to load 🎉🎉." **It's not that simple.**

In fact, when you make an Async Query, you open a new connection to your database. But remember that you have a limited number of connections!

Suppose you have a dashboard that sends 10 very complex queries, and you have 10 admins loading the page simultaneously. This will require 100 available slots in your thread pool allocated to your database. This can quickly lead to thread pool saturation errors!

Another thing, when you surround your query with a Transaction, it won't work with an Async Query:

```ruby
Basket.transaction do
  @maximum = Basket.async_average(:total)
end
# Will raise an ActiveRecord::AsynchronousQueryInsideTransactionError
```

Keeping all this in mind, let me try to summarize my recommendations regarding the use of Async Queries.

## My Recommendations

Here is a set of questions to ask yourself before converting a query to an async query :

- Is my query short (-50ms)?
    - **YES** → No interest.
    - **NO** → You can consider it.
- Is my query called very frequently?
    - **YES** → It's dangerous because it multiplies your number of database accesses. Since this access is limited, it can cause problems.
    - **NO** → You can consider it.
- Does my query use a transaction?
    - **YES** → In this case, it's impossible.
    - **NO** → You can consider it.

---

# Conclusion

In conclusion, the Async Queries introduced with Rails 7.1 represent a significant advancement in performance optimization for Ruby on Rails developers. Beyond simple improvements, they revolutionize our approach to queries by enabling effective data preparation in the background.

However, it is imperative to adopt these asynchronous queries cautiously, carefully assessing the nature of each query, its frequency of use, and its interaction with transactions, to fully leverage this innovation without compromising system resources.

In summary, Async Queries offer revolutionary potential for Rails application efficiency, but their use must be judicious to ensure an optimal user experience.