While there were plenty of exciting changes that shipped with Rails 7, asynchronous querying has by far been one of the most talked about features. This is because asynchronous queries can drastically improve the performance of your application by executing long-running queries or complex queries in the background. To better understand why and how this works, let's start by digging into the core of what a Rails async query is.
What are asynchronous queries in Rails?
First, let's make sure we understand what the words "synchronous" and "asynchronous" mean. When something is executed synchronously, it means that the work is connected, or dependent in some way, often requiring one task to be completed before the next can start. Synchronous things happen one after another, like people moving through a line.
This is of course in contrast to "asynchronous" which means that the tasks are independent of one another and thus can be completed at the same time. Asynchronous things can (but don't have to) happen at the same time. For a more tangible, real-world example, consider the following.
Grocery store queue with an actual cashier
Standing in line at a grocery store checkout line to be served by a human cashier would be an example of a synchronous process. You cannot begin checking out your items until the person in front of you is done, and the person behind you must wait for you to be finished, and so on.
If you've ever been to a grocery store with only one checkout lane open, you know this can be painfully inefficient. If anything slows down someone in the line, it affects everyone behind that person.
Grocery store queue self-checkout
Many grocery stores are now equipped with self-checkout areas with multiple point-of-sale systems allowing more than one customer to check out simultaneously. This would be a great example of an asynchronous process because multiple people can check out their groceries at once, and one person completing their checkout is not dependent on another.
This example should make it apparent why asynchronous queries in our Rails applications can help performance so much. Queries that can execute without blocking the rest of a path of execution can really speed up a codebase. -- I don't know about you, but I always prefer self-checkout lines because they are often faster to get through.
A programming example
Now that we understand the difference between synchronous and asynchronous, let's discuss what it means for Rails queries.
Consider that you are working on an application similar to Reddit with multiple message boards where users can discuss different topics. You could imagine that you might have a controller action that loads the following from the database (with the average load time in parentheses):
- All of the posts for a given topic (300 ms)
- All of the comments on each post (200 ms)
- All of the views on a given page (100 ms)
If we were to run these queries synchronously, each SQL query blocking the execution of the next, it would take 600 ms (300 + 200 + 100). However, if we could run them asynchronously, it would take at most 300ms since that is the longest query time.
Before Rails 7, if we wanted to take advantage of multiple threads in our application, we could use gems like concurrent-ruby
or async
. However, doing so would require a great deal of expertise in the inner workings of threads because managing the entire lifecycle of the thread pool would be on us. If you're curious about that, it may be helpful to read this article on Ractors, which were released as part of Ruby 3.0. The beginning of the article discusses the difference between parallelism and concurrency, which is relevant to our discussion on how asynchronous queries work in Rails. For now, we'll focus on the asynchronous features built right into Rails.
How do asynchronous queries work in Rails 7+?
With the release of Rails 7, we now have the opportunity to take advantage of utilizing multiple threads simply by appending .load_async
to an Active Record relation object. This schedules the query to be performed asynchronously from a thread pool via Concurrent::ThreadPoolExecutor
under the hood so that the main thread of your Rails application is not blocked -- this allows your app to continue to respond to requests while queries can run in the background.
This underlying implementation is abstracted away from the developer in classic Rails fashion!
To try out asynchronous queries in your Rails app, you must first enable them in your config/environments/_whatever_env_you_are_using_.rb
file by adding this line:
config.active_record.async_query_executor = :global_thread_pool
Now that you have your application configured, so let's dive a bit deeper. Imagine you have this controller method that runs one ActivRecord query to fetch a Post
model from the database:
def show
@top_posts = Posts.order_by_comments.count
sleep 1
end
Note: For this example, we are adding the sleep
here to simulate other actions in the controller so we can show the benefits of asynchronous execution.
If we were to run @top_posts
in a view, this method would execute and the output in the console would look like this:
Post Load (466.0ms)
Completed 200 OK in 1423ms
Friendly remember for those unfamiliar - Rails utilizes lazy loading, which means SQL queries are only executed when needed!
OK, now let's change our code to utilize the .async_count
method. (For more methods like this one, check out all of these new methods Rails released in 7.1!)
def show
@top_posts = Posts.order_by_comments.async_count
sleep 1
end
Now, let's see what our output is now in the console:
ASYNC Post Load (0.0ms) (db time 304.2ms)
Completed 200 OK in 1021ms
Now we can see that in our main thread, it took 0ms of bandwidth and 304ms of database time running in the background. So when the thread is blocked by sleep, it does not inhibit the async count from being queried in the background. Cool!
One thing to note is that by replacing .count
with .async_count
, we receive back a Promise
. If we want to check if the query has finished, we can call .loaded?
on the relation object. To use its value, we must call .value
on the promise. This is an important distinction because if you try to use the value
of the Promise without accessing it by the method call, you'll get an error. Hurray for dynamic typing!
What are the benefits of Rails asynchronous queries and when should you use them?
In short, there are a few huge benefits of leaning on these asynchronous methods:
- Increased performance
- Increased overall responsiveness
- Reducing application complexity for multithreading
- Preserving memory
We have already touched on (and even walked through an example that showed) the benefit of increased performance by utilizing asynchronous queries. Because we can run multiple queries at the same time, our application can go through code that makes those queries faster.
Asynchronous queries also help us increase overall responsiveness because our application can continue to respond to incoming web requests from users while complex queries are running in the background.
We also discussed how the new .load_async
and other aggregate load methods have reduced the complexity it used to take advantage of multiple threads in a Rails application. You no longer have to introduce the complexity and cognitive overhead of multithreading to take advantage of multithreading.
Finally, asynchronous queries can also help preserve memory by executing operations in completely separate threads.
When should you not use asynchronous queries
So all of this sounds great and we should immediately start utilizing .load_async
everywhere, right? WRONG! First and foremost, it is recommended to run benchmarking and use monitoring to understand and determine where your bottlenecks are. You shouldn't sprinkle in asynchronous queries for no reason.
There is absolutely no reason to utilize async queries for simple queries as this is just adding unnecessary overhead with little benefit. You also cannot use asynchronous queries inside transaction blocks, eager-loaded queries, or callbacks (an important caveat).
Also, it is important to understand that every time you call .load_async
you are opening a new connection to your database -- multiple open database connections can drastically degrade your database performance and you also can quickly eat up all of your available connections and end up with ActiveRecord::ConnectionTimeoutError
's. The tradeoffs you make for application performance can come at the cost of database performance. Thus, we need to be very, very careful where we are utilizing asynchronous queries, and definitely set up some observability to make sure we can monitor database clients and memory usage.
Async queries are powerful when used correctly
Ultimately, there are many reasons to be pretty excited about the built-in ability to utilize asynchronous queries in your Rails apps out of the box. I suspect most applications can find at least a few instances where it would be a significant performance improvement to lean on Rails async queries. One common use case is for endpoints that have slow queries AND perform third-party HTTP requests. This way the SQL query that is slow and the HTTP request can be parallelized to hopefully improve overall response time.
Like this article? We have plenty more where that came from. Join the Honeybadger newsletter for more Ruby tips and tricks!