DEV Community

Cover image for How I manage to optimize my Active Admin in 4 simple tricks
Pimp My Ruby
Pimp My Ruby

Posted on

How I manage to optimize my Active Admin in 4 simple tricks

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

  Data Set Presentation
  Introduction to ActiveAdmin
  Filters
       1️⃣ Always Use Custom Filters
       2️⃣ Preload Your Own Collection and Cache It
  Index
       3️⃣ Preload Data in Your Controller
       4️⃣ Preload Data in Your View
  Results
       Server-Side Rendering:
       Rack-mini-profiler:
  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)
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
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

Which results in this:

Image description

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

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:

ActiveAdmin.register Mission do
  filter :title

  [...]
end
Enter fullscreen mode Exit fullscreen mode

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:

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

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:

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
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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

Image description

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:

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
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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.

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
Enter fullscreen mode Exit fullscreen mode

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

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

  • After:

Image description

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.

Top comments (4)

Collapse
 
jacquesslaff profile image
JacquesSlaff • Edited

Review the database queries being generated by your ActiveAdmin pages. Ensure that indexes are appropriately set on columns involved in search and filtering operations.
Consider using the includes method to eager-load associations and prevent N+1 query issues.rice purity test

Collapse
 
pimp_my_ruby profile image
Pimp My Ruby

You've got this!

Collapse
 
thomas180399 profile image
Thomas Frank

I had no idea this ActiveAdmin could be optimized in such a straightforward manner until I read this post. This tool just became even more powerful in my Buckshot Roulette eyes.

Collapse
 
emmaalva profile image
EmmaAlva • Edited

I completely understand after reading your detailed explanation. I am delighted to gain knowledge from your valuable tiny fishing expertise. Your generosity in sharing is greatly appreciated.