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
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
Which results in this:
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:
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.
- If you have many attributes, it's hard to navigate.
- If you have relationships with other models, ActiveAdmin will preload the entire ActiveRecord collection. In our case, we have the
Owner
andMissionShift
models included by default.
Always specify at least one filter:
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:
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:
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:
- Displaying the
Owner
column - 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:
- Preload the data in the controller
- 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
If we look at the rack-mini-profiler, we get this:
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
```
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.
Top comments (7)
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
You've got this!
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.
Lucas Bietti is an associate professor in the Department of Psychology at NTNU. His research focuses on understanding cognition in social interactions, utilizing a blend of ethnographic and experimental methods. doramasflix.to
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.
In this daily puzzle game, players must identify which animal is hidden in an image by using clues and feedback. Each guess narrows down the possibilities, making adoptle both entertaining and educational. The game’s daily format ensures that players stay engaged and curious about the world of animals.
Thanks for sharing those tips. Keep posting! hardscape masonry