How I manage to optimize my Active Admin in 4 simple tricks
Tags: ruby, rails, tutorial, beginners
Published: 2024-01-07 14:13:14

ActiveAdmin is a commonly used tool for creating admin interfaces in Ruby on Rails applications. It's incredibly useful for quickly setting up an admin interface focused solely on the data you want to display. However, we often encounter pages that make numerous SQL requests and take a long time to load.

Today, we're going to look at three things I always do to optimize my ActiveAdmin views.

## Table of Contents
&nbsp; [Data Set Presentation](#data-set-presentation)
&nbsp; [Introduction to ActiveAdmin](#introduction-to-activeadmin)
&nbsp; [Filters](#filters)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; [1️⃣ Always Use Custom Filters](#always-use-custom-filters)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; [2️⃣ Preload Your Own Collection and Cache It](#preload-your-own-collection-and-cache-it)
&nbsp; [Index](#index)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; [3️⃣ Preload Data in Your Controller](#preload-data-in-your-controller)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; [4️⃣ Preload Data in Your View](#preload-data-in-your-view)
&nbsp; [Results](#results)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; [Server-Side Rendering:](#serverside-rendering)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; [Rack-mini-profiler:](#rackminiprofiler)
&nbsp; [Conclusion](#conclusion)

## Data Set Presentation

To illustrate our examples, let's imagine that you need to integrate an admin interface for a temp agency.

You will have 4 tables:

- A table to store clients who pay to find temporary workers (Owner)
- A table to store all your available temporary workers (User)
- A table to store all the missions (Mission)
- A table to store all the mission shifts (MissionShift)

```ruby
create_table "users" do |t|
  t.string "email"
  t.string "first_name"
  t.string "last_name"
end

create_table "owner" do |t|
  t.string "name"
end

create_table "missions" do |t|
  t.string "description"
  t.string "title"
  t.bigint "client_id", null: false
end

create_table "mission_shifts" do |t|
  t.bigint "mission_id", null: false
  t.date "day"
  t.datetime "begin_time"
  t.datetime "end_time"
  t.bigint "user_id"
end
```

The relationships are :

- A User have many MissionShifts.
- A MissionShift belongs to a Mission.
- A Mission belongs to an Owner.

That being explained, let’s dig into our use case !

---

## Introduction to ActiveAdmin

Our goal is to use ActiveAdmin to create our admin interface as quickly as possible. You want a page that displays:

- The names of the missions
- The client to whom the mission belongs
- How many MissionShifts are staffed with a User

By reading a bit of the documentation, you discover ActiveAdmin's wonderful DSL.

You quickly come up with this code:

```ruby
ActiveAdmin.register Mission do
  index do
    selectable_column
    column :title
    column :owner
    column('Mission Shifts') do |mission|
      mission_shifts = mission.mission_shifts
      "#{mission_shifts.count(:user_id)}/#{mission_shifts.size}"
    end
    actions
  end
end
```

Which results in this:


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


You're ready to conquer the world with this admin page!

Yet, you should know that this page contains 3 sources of N+1 queries. To prove this, we'll use the “rack-mini-profiler” gem. This gem allows you to trace the server's path while rendering the page.

Here's a screenshot of what rack-mini-profiler shows us:


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



We have 148 SQL calls in the view 😲. That's huge!

Looking at the server side, here's the information we get when loading the page:

`Completed 200 OK in 741ms (Views: 455.5ms | ActiveRecord: 266.5ms | Allocations: 1035126)`

The page takes a lot of time to load and allocates a huge amount of memory!

The purpose of this article is to show you how to significantly reduce these SQL calls and memory allocation, thereby optimizing the time it takes for the page to render and reducing memory leaks!

---

## Filters

### 1️⃣ Always Use Custom Filters

The first tip I'd like to share today is well-known. It doesn't solve our N+1 issue, but it greatly reduces the memory allocated by the page:

Never leave the default filters on!

In fact, ActiveAdmin tries to be helpful and generates filters for all attributes of your table.

1. If you have many attributes, it's hard to navigate.
2. If you have relationships with other models, ActiveAdmin will preload the entire ActiveRecord collection. In our case, we have the `Owner` and `MissionShift` models included by default.

Always specify at least one filter:

```ruby
ActiveAdmin.register Mission do
  filter :title

  [...]
end
```

Here's the server-side rendering when I load the page:

- Before the modification: `Completed 200 OK in 620ms (Views: 422.9ms | ActiveRecord: 186.0ms | Allocations: 928758)`
- After the modification: `Completed 200 OK in 581ms (Views: 403.4ms | ActiveRecord: 168.0ms | Allocations: 703878)`

There's a huge difference in memory allocation!

### 2️⃣ Preload Your Own Collection and Cache It

If you still want a filter for a Model, write your own query to load the data:

```ruby
ActiveAdmin.register Mission do
  filter :owner, as: :select, collection: lambda {
    Owner.pluck(:name, :id)
  }
  [...]
end
```

Here's the server-side rendering when I load the page:

`Completed 200 OK in 666ms (Views: 337.9ms | ActiveRecord: 316.7ms | Allocations: 761030)`

It's still better than with the default filters.

If you want to go even further in optimization, you can also set up a caching logic like this:

```ruby
ActiveAdmin.register Mission do
  filter :owner, as: :select, collection: lambda {
    Rails.cache.fetch('owners_name_id', expires_in: 1.hour) do
      Owner.pluck(:name, :id)
    end
  }
  [...]
end
```

This gives us:

`Completed 200 OK in 816ms (Views: 531.6ms | ActiveRecord: 248.3ms | Allocations: 711373)`

In terms of allocation, we've come a long way!

---

## Index

On our index page, we have two problems:

1. Displaying the `Owner` column
2. Displaying the number of MissionShifts that have a User per Mission

To summarize how ActiveAdmin works, for each line, it makes 3 SQL queries:

- One to find the Owner
- One to find the MissionShifts
- One to count the MissionShifts that have a User

As you can see, all our N+1s are actually concentrated here.

The only way to solve our issues is by preloading as much data as possible. For this, we have two ways:

1. Preload the data in the controller
2. Load the data once and memoize it in the view

### 3️⃣ Preload Data in Your Controller

ActiveAdmin allows us to create our own controller methods. So far, we have only played with the `#index` method, so if we want to preload data, this is the place!

Let's modify our file to preload the `Owner` data directly from the controller:

```ruby
ActiveAdmin.register Mission do
   [...]
  index do
    [...]
    column('Owner') do |mission|
      owners.fetch(mission.owner_id)
    end
    [...]
  end

  controller do
    # before loading anything, we preload Owners

    def index
      @owners = Owner.pluck(:id, :name).to_h
      # call for the initial index method
      super
    end
  end
end
```

If we look at the rack-mini-profiler, we get this:


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


We have reduced our database calls by ~30. Why this number? Well, as we have the same Owner multiple times on different Missions, ActiveAdmin kept the call in cache and pulled out the information rather than making a new request.

This technique works well unless you have a large volume of data in the `Owner` table. If you want to optimize a bit more, you can also use the cache. And if you want to optimize even further, you can reuse the cache we set up together in the filters to use the same values twice on the page.

### 4️⃣ Preload Data in Your View

Preloading data in the controller is good, but we might load too much. The Mission table has a larger volume than Owner, so we can't afford to preload the entire table. Unfortunately, we don't know which Missions will be loaded on the page at the controller level.

But there is one place where we do know which Missions are displayed: in the view!

In other words:

```ruby
ActiveAdmin.register Mission do
  index do
    column('Sample Column') do
      missions.count # => 50
    end
  end
  controller do
    def index
      pp missions.count # => NameError - undefined local variable or method `missions' for #<Admin::MissionsController:0x000000000a4128>:
      super
    end
  end
end
```

Let's use this to our advantage!

Our goal is to preload the number of MissionShifts with a User as well as the total number of MissionShifts per Mission.

The first step is to find a query to preload this data for a set of Missions:

```ruby
class Mission < ApplicationRecord
[...]
  # will output missions with "id", "total_shifts" and "shifts_with_user"
  def self.mission_shifts_with_users
    self.left_joins(:mission_shifts)
        .select(:id,
                'COUNT(mission_shifts.id) AS total_shifts',
                'COUNT(DISTINCT CASE WHEN mission_shifts.user_id IS NOT NULL THEN mission_shifts.id END) AS shifts_with_user')
        .group('missions.id')
  end
end
```

We want to use this query in our view so that we only have to look up our mission in it and extract the information we need.

```ruby
ActiveAdmin.register Mission do
[...]
  index do
    [...]
    column('Mission Shifts') do |mission|
      @missions_with_mission_shifts ||= missions.mission_shifts_with_users
      mission_shifts = @missions_with_mission_shifts.find { |m| m.id == mission.id }

      "#{mission_shifts.shifts_with_user}/#{mission_shifts.total_shifts}"
    end
  end
end
```

By using memoization, we only make the query once, and it is usable for all our Missions.

If we take a tour of rack-mini-profiler, we get:


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


We have eliminated almost 100 database calls by preloading the data for Owners and MissionShifts.

## Results

Here are the results with all the tips applied:

### Server-Side Rendering:

- Before: `Completed 200 OK in 741ms (Views: 455.5ms | ActiveRecord: 266.5ms | Allocations: 1035126)`
- After: `Completed 200 OK in 200ms (Views: 187.3ms | ActiveRecord: 5.9ms | Allocations: 517789)`

Memory allocation was halved.

The response time is around 200ms (which is acceptable).

### Rack-mini-profiler:

- Before:

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

- After:

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


The numbers speak for themselves, only 6 SQL queries compared to 148 before. It's a huge gap for the performance of your applications.

The important indicator to look at is the `% in sql`, which we reduced from 15.6% to 3.5%, a huge difference!

## Conclusion

In conclusion, this article demonstrated how to transform an initially heavy and inefficient ActiveAdmin interface into a significantly more effective and faster version. Thanks to four simple yet powerful tricks, we drastically reduced SQL queries, improved memory management, and optimized loading time. These improvements are not just technical gains; they translate into a smoother and more professional user experience. For Ruby on Rails developers using ActiveAdmin, these methods offer a concrete way to enhance the performance of their applications while maintaining the ease and speed of development that this gem offers.