---
title: Async Ruby on Rails
teaser: Ruby and Rails have several features to make your code more performant using
  async programming. Here's a list of these tools and how to use them.
tags: ruby,rails,concurrency
author: Matheus Richard
published_on: 2024-06-07
---

Async programming can make your apps faster. I'll share how you can use async in Ruby on Rails to speed up your app. While there are examples in Ruby, the principles apply to any language.

<aside class="info">
  <p>If you want an introduction to async programming or prefer a video format,
  I also <a href="https://youtu.be/CfC2blkrkys?si=rBQO-Lt8HozZeIiY">gave a talk about it</a>
  at <a href="https://www.tropicalonrails.com/archive-2024/">Tropical.rb 2024</a>.</p>
</aside>

I'll group the examples into two basic principles. Here's the first one:

## Don't do now what you can do later

Delay doing stuff as much as possible. Being lazy is not necessarily a bad
thing. In practice, that means a few things:

Pay attention when you use a method that ends with `_now`. They're strong
candidates for things that can be done async. A common example is sending emails.
Imagine a Rails controller that sends an email after a user registers:

```rb
class RegistrationsController
 def create
    @registration = Registration.new(params)
    if @registration.save
      RegistrationMailer
        .welcome_email(@registration)
        .deliver_now
      redirect_to @registration
    else
    # ...
    end
  end
end
```

The request doesn't need to wait for the email to be sent to complete. Using
[`deliver_later`][`deliver_later`] here can make the request faster. The same applies to any other
kind of job! If you're saving statistics, processing data, or
something else that doesn't need to be done right now, [`perform_later`][`perform_later`].

You can also delete Active Storage files async with [`purge_later`][`purge_later`]:

```rb
class User < ApplicationRecord
  has_one_attached :avatar
end

User.first.avatar.purge_later # enqueue a job to delete the file
```

And, since Rails 6.1, you can delete dependent associations async with [`dependent: :destroy_async`][`dependent: :destroy_async`]:

```rb
class Team < ApplicationRecord
  has_many :players, dependent: :destroy_async
end

class Player < ApplicationRecord
  belongs_to :team
end

Team.destroy_by(name: "Flamengo")
# Enqueued ActiveRecord::DestroyAssociationAsyncJob
```

You [can configure] the maximum number of records that will be destroyed in a
background job by the [`dependent: :destroy_async`][`dependent: :destroy_async`] association option.

<aside class="warn">
  <p>You need to have <code>ActiveJob</code> configured to use this feature.</p>
  <p>Also, do not use this option if the association is backed by foreign key constraints in your database.</p>
</aside>

Cool, so that's the first principle. But here comes the second one:

## Don't stand still

Being lazy is nice, but you cannot wait doing nothing! Consider this example:

```rb
puts(
  Benchmark.realtime do
    5.times do
      Net::HTTP.get(URI.parse("https://httpbin.org/delay/2"))
    end
  end
)
```

Because the requests are synchronous, the total time will be around 10 seconds
(+ some network overhead). What's bad is that the code doesn't do a lot: it is
mostly waiting on those requests to complete. Visually, it executes like this:

![HTTP Requests being made sequentially](https://images.thoughtbot.com/9x2ifndcpktpwnxebpbey2505h7a_sync-requests.webp)

We could be proactive and start making more requests while we wait for the
previous ones to complete. Here's what that would look like using [the `async`
gem]:

```rb
puts(
  Benchmark.realtime do
    Sync do
      5.times.map do
        Async do
          Net::HTTP.get(URI.parse("https://httpbin.org/delay/2"))
        end
      end.map(&:wait)
    end
  end
)
```

[the `async` gem]: https://rubygems.org/gems/async

Not a lot of changes, but that now only takes about 2 seconds to finish! We're
firing another request as soon as possible, which basically means that we're
waiting for them to be completed in parallel.

<aside class="info">
  <p>If you want more details on how <code>async</code> works, check this article about <a href="https://thoughtbot.com/blog/my-adventure-with-async-ruby">my adventures with it</a>.</p>
</aside>

Here's a visual representation of what's happening:

![Requests being made concurrently, which makes the time waiting on I/O parallel](https://images.thoughtbot.com/s4xe9sw35p5kb3m9d694i20hx6in_async-requests.webp)

<aside class="info">
  <p>I <a href="https://github.com/MatheusRich/end_of_life/pull/19">did this very refactoring</a> in a real project and the response time went from 25s to 2.5s! 📈</p>
</aside>

This used HTTP requests as an example, but try to apply this principle to any other kind of I/O-bound operation. File operations, system calls, and database queries are good candidates for this kind of optimization. Speaking of database queries...

### Async database queries

Since Rails 7, you can use
[`ActiveRecord::Relation#load_async`](https://edgeapi.rubyonrails.org/classes/ActiveRecord/Relation.html#method-i-load_async)
to run a database query in a background thread. This is useful when you want to
load a relation in the background, but you don't need the result immediately.

So, say we have a controller that does several queries to render a page:

```rb
class ReportsController
  def create
    @new_authors = Author.recent
    @new_books = Book.recent
    @new_reviews = BookReview.recent
  end
end
```

If each query took 1 second to run, the total time here would be 3 seconds. But,
if we use `load_async` to run them in parallel:

```rb
class ReportsController
  def create
    @new_authors = Author.recent.load_async
    @new_books = Book.recent.load_async
    @new_reviews = BookReview.recent.load_async
  end
end
```

Then the total time would be around 1 second! Again, we don't need to wait for
one query to complete to start the next one. The Rails logs will show that:

```rb
ASYNC Author Load  (1010.2ms) (db time 1011.4ms)
ASYNC Book Load       (2.2ms) (db time 1013.8ms)
ASYNC BookReview Load (0.2ms) (db time 1014.7ms)
```

The first number column shows us the time the query took to run in the
foreground thread, while the second column shows the total time the query took
on the database.

As with any promises of performance improvements, there are trade-offs here.
When using `load_async`, we're using more resources (database connection
threads) in a single request. This can be a problem if you're using that on a
part of the app that's under heavy load, because one or a few users might
exhaust the connections and other users will have to wait (and possibly
timeout). So, **be careful with `load_async`**!

A good use case for this, though, is when you have an HTTP request and a
database query that can be done in parallel:

```rb
class BooksController
  def show
    @new_books = Book.recent.load_async
    @external_books = HTTP.get("https://external.com/books")
  end
end
```

### Async views

I don't think Rails renders partials in parallel, but you can use Turbo Frames to
load parts of the page in parallel. Just give it a URL and it will load its
content from that route. [Lazy-loaded frames] are particularly useful for parts of
the page that are not critical to the user experience or are heavyweight.

[lazy-loaded frames]: https://turbo.hotwired.dev/reference/frames#lazy-loaded-frame

Add a turbo frame to your view:

```html
<turbo-frame
  id="best_sellers"
  src="books/best_sellers"
  loading="lazy"
></turbo-frame>
```

Write a controller action that renders the frame content:

```rb
class BooksController
  def best_sellers
    @books = Book.best_sellers
  end
end
```

And a view that renders the content:

```erb
<turbo-frame id="best_sellers">
  <h1>Best Sellers</h1>
  <%= render @best_sellers %>
</turbo-frame>
```

And that's it! If you have several frames on a page, they will load in parallel.
Of course, this means more requests to the server, so keep that in mind. Also,
don't overdo it. It is frustrating for the user to see the page loading in and
then "load content again" every time (looking at you, SPAs).

If you have something costly to render or that not every user needs, you can
push it outside of the initial viewport and load it lazily with a turbo frame.

### Async assets

Extending this concept further, we can do the same with assets. For instance,
you can set [the `async` attribute] on your script tags to load them in parallel:

```html
<script blocking="render" async src="async-script.js"></script>
```

[the `async` attribute]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#async

Splitting critical and non-critical CSS can also help. You can [lazy-load fonts]
with `font-display: swap`, which will render the text with a fallback font while
the custom font is loading.

For images, you can lazy-load them with the [`loading="lazy"` attribute]. This
works the same way as Turbo Frames, loading the image only when it's about to
enter the viewport. Rails even has a config option to [lazy-load images by
default].

[lazy-load fonts]: https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display#swap
[`loading="lazy"` attribute]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#loading
[lazy-load images by default]: https://edgeguides.rubyonrails.org/configuring.html#config-action-view-image-loading

All of this will help your page to display faster, instead of seeing a blank
screen for a long time.

### Adding indexes concurrently

Finally, I'd like to mention some async tools that you can use in development or
on more "behind the scenes" scenarios. The first one is if you're using PostgreSQL.

When adding indexes to a Postgres table, it blocks the table for writes. This
can lead to downtime in production if you have a big table. Luckily, we can use
the [`concurrently` option] to create the index without blocking the table:

```rb
class AddIndexToUserRoles < ActiveRecord::Migration
  disable_ddl_transaction!

  def change
    add_index :users, :role, algorithm: :concurrently
  end
end
```

<aside class="warn">
  <p>Note that we need to run this outside of a transaction, so we need to call <code>disable_ddl_transaction!</code> in the migration.</p>
</aside>

The caveat here is that --quoting the docs-- <q>this method requires more total
work than a standard index build and takes significantly longer to complete.
However, since it allows normal operations to continue while the index is built,
this method is useful for adding new indexes in a production environment. Of
course, the extra CPU and I/O load imposed by the index creation might slow
other operations.</q>

[`concurrently` option]: https://thoughtbot.com/blog/how-to-create-postgres-indexes-concurrently-in

### Running tests in parallel

Rails 6 introduced parallel testing. All you need to do is to specify how many
workers you want:

```rb
class ActiveSupport::TestCase
  parallelize(workers: 2)
  # or let it figure out looking at the number of CPUs
  parallelize(workers: :number_of_processors)
end
```

The math is simple (because I'm simplifying things): the more workers you have,
the faster your tests will run.

| Workers | Test Suite Time |
| ------- | --------------- |
| 1       | 40 min          |
| 2       | 20 min          |
| 4       | 10 min          |

<aside class="info">
  <p>Like I said, this is an oversimplification. The time each worker takes to
    finish running its tests will vary depending on the tests themselves.
    There are <a href="https://knapsackpro.com/">services</a> that can help
    optimize the test distribution between workers.
  </p>
  <p>
    Rails itself
    <a href="https://edgeguides.rubyonrails.org/testing.html#threshold-to-parallelize-tests">won't parallelize the tests</a>
    until you hit a certain number of tests, because of the overhead of starting additional workers.
  </p>
</aside>

Unfortunately, **RSpec doesn't support Rails' parallel testing out of the box**.
Several gems implement this behavior for RSpec, though. Some examples are
[`parallel_tests`] and [`flatware`].

[`parallel_tests`]: https://rubygems.org/gems/parallel_tests
[`flatware`]: https://rubygems.org/gems/flatware

## Make sure you have the basics right before going async!

Async can make your app faster, but it also can make the code more complex in
the process! You might feel like you have less control of how things are
executing and errors can become harder to debug.

That is to say that you should do your homework before going async. Don't use
these techniques as band-aids for _real_ performance problems. And by that, I
mean basic things like [adding indexes to database columns], fixing [N+1
queries], using [low-level caching] and [view caching] where they make sense,
and generally following [good Ruby and Rails practices].

Use these principles with wisdom. As with any simplification, they can be wrong
in some cases. **These are not rules**! There are plenty of cases where being
"proactive" is better than being "lazy" (precomputing values, for example). But,
I hope this serves as a starting point for you to start thinking about async and
Rails a bit more.

[adding indexes to database columns]: https://thoughtbot.com/blog/choosing-the-right-database-index-type
[N+1 queries]: https://guides.rubyonrails.org/active_record_querying.html#n-1-queries-problem
[low-level caching]: https://guides.rubyonrails.org/caching_with_rails.html#low-level-caching
[view caching]: https://guides.rubyonrails.org/caching_with_rails.html#russian-doll-caching
[good Ruby and Rails practices]: https://github.com/rubocop/rubocop-performance
[`deliver_later`]: https://edgeapi.rubyonrails.org/classes/ActionMailer/MessageDelivery.html#method-i-deliver_later
[`perform_later`]: https://edgeapi.rubyonrails.org/classes/ActiveJob/Enqueuing/ClassMethods.html#method-i-perform_later
[`purge_later`]: https://edgeapi.rubyonrails.org/classes/ActiveStorage/Blob.html#method-i-purge_later
[can configure]: https://edgeguides.rubyonrails.org/configuring.html#config-active-record-destroy-association-async-batch-size
[`dependent: :destroy_async`]: https://edgeguides.rubyonrails.org/association_basics.html#dependent

## Want to speed up your development?

Learn more about [partnering with thoughtbot](https://thoughtbot.com/services/ruby-on-rails-development) to elevate your processes and speed up development.
