How to Build Lightning-Fast Rails Applications: 12 Active Record Optimization Techniques That Actually Work
Boost ActiveRecord performance with proven techniques: eager loading, query optimization, connection pooling, indexing strategies, and caching. Learn to build fast database applications.
Building fast database applications with Active Record feels like walking a tightrope. On one side, you have this beautifully simple way to work with your data. On the other, you can quickly find your application slowing down as it grows. I’ve been there, watching pages that loaded in milliseconds start to take several seconds. Over time, I’ve learned that keeping an application responsive isn’t about secret tricks; it’s about consistently applying a handful of reliable methods.
Let’s start with a problem so common it has its own name: the N+1 query. Imagine you’re showing a list of products, and under each product, you want to display its category name. A straightforward approach might loop through the products and ask for each category one by one. For 100 products, that’s 1 query to get the products, plus 100 more for the categories. That’s 101 queries where 2 would do.
The solution is called eager loading. You tell Active Record upfront what related data you need. Using includes, you can fetch the products and their categories in one go. This simple change often delivers the most dramatic performance improvement.
But sometimes, you don’t always need all the relationships. Loading everything can be wasteful. This is where conditional eager loading shines. You can build the list of associations to load dynamically based on what the page actually requires. For instance, you might only load inventory data for an admin view, not for a public page. This keeps your queries lean and fast.
def index
# Start with the basics
includes = [:category]
# Add more only if needed
includes << :inventory if admin_view?
includes << { reviews: :user } if show_reviews?
@products = Product.includes(includes).where(active: true)
end
Another useful tactic is to create custom scopes that handle complex joins for you. This keeps your controller clean and makes the logic reusable. Instead of scattering complex includes and where clauses everywhere, you define it once on the model.
When your dataset grows large, working with all of it at once can crash your server. I learned this the hard way trying to update millions of records. The find_each method is a lifesaver. It grabs records in manageable batches—say, 1000 at a time—processes them, and then moves on. This keeps memory usage flat, no matter how many records you have.
For updating many records, avoid the loop. Using update_all performs a single SQL UPDATE statement. It bypasses Active Record callbacks and validations, which is why you must use it carefully, but it is incredibly fast for bulk operations.
# This is slow and memory-intensive
Product.all.each { |p| p.update(status: 'archived') }
# This is fast and safe on memory
Product.in_batches do |relation|
relation.update_all(status: 'archived')
end
# Or for a simple set of IDs, one query is best
Product.where(id: selected_ids).update_all(status: 'archived')
A small but frequent optimization involves checking if something exists. Our instinct might be to use count > 0. The database counts all matching rows, which is expensive. The SQL EXISTS command stops as soon as it finds one match, making it much faster. Active Record’s .exists? method uses this.
Your database can only handle so many conversations at once. If every user request opens a new connection and forgets to close it, your database will get overwhelmed, and users will start waiting in line. This is where connection pooling comes in.
Active Record maintains a pool of connections. When your app needs to talk to the database, it checks out a connection from this pool, uses it, and then checks it back in. The pool size in your database.yml file controls how many connections are kept open. Setting this correctly for your workload is crucial.
You can also manage connections manually for background jobs or other long-running tasks to ensure they don’t starve the pool. Using with_connection guarantees the connection is returned properly.
Sometimes, a query goes wrong. It gets stuck in a complex join or gets stuck scanning a huge table. Without a safety net, it can consume your database’s CPU and block other queries. Setting a statement timeout is like giving a query a maximum allowed running time. If it exceeds that, the database cancels it.
def run_safe_report
# Set a 30-second timeout for this block
ActiveRecord::Base.connection.execute("SET statement_timeout = 30000")
begin
generate_complex_report()
ensure
# Always revert to the default timeout
ActiveRecord::Base.connection.execute("RESET statement_timeout")
end
end
Think of your database table like a big book. An index is like the book’s index at the back—it helps you find information without reading every page. A single-column index is helpful, but often your queries look for combinations of columns. That’s where composite indexes come in.
If you often search for orders by a user and a status, then sorted by date, an index on [user_id, status, created_at] can speed that up dramatically. The order of columns in the index matters. It should match the order in your common where and order clauses.
Not all rows are equally important. If you frequently query only for unread notifications, a partial index that only includes rows where read = false is smaller and faster than indexing the entire table.
You can’t improve what you can’t see. Using EXPLAIN ANALYZE before your SQL queries shows you the database’s step-by-step plan for fetching the data. It tells you if it’s using an index, doing a slow full-table scan, or performing a costly sort in memory. Make this a regular part of your development process.
Some calculations are just heavy. Calculating the total sales, average order value, and customer count for a dashboard might require joining several large tables. Running this every time someone loads the dashboard is impractical.
A materialized view is like taking a snapshot of the result of that complex query and saving it as a real table. You can query this snapshot as fast as querying any regular table. The data becomes static, of course, so you need to refresh it periodically—say, every hour or every night.
# Creating a materialized view for daily summary stats
ActiveRecord::Base.connection.execute <<-SQL
CREATE MATERIALIZED VIEW daily_sales_summaries AS
SELECT
DATE(created_at) as sale_date,
product_id,
SUM(quantity) as total_units,
SUM(quantity * unit_price) as total_revenue
FROM order_items
GROUP BY DATE(created_at), product_id
SQL
# You can then query it like a normal model
class DailySalesSummary < ActiveRecord::Base
self.table_name = 'daily_sales_summaries'
def self.refresh
connection.execute('REFRESH MATERIALIZED VIEW daily_sales_summaries')
end
end
Within a single web request, you might run the same query multiple times in different places. Active Record’s query cache stores the result of each SELECT query it runs during that request. If the identical query is run again, it serves the cached result instead of going back to the database. This happens automatically.
For caching across different requests, you move to the application level. Rails.cache can store the results of expensive queries for minutes or hours. The key is generating a reliable cache key that changes when the underlying data changes.
class Homepage
def featured_products
cache_key = "homepage/featured/v2"
Rails.cache.fetch(cache_key, expires_in: 1.hour) do
# This block only runs if the cache is empty or expired
Product.featured.includes(:category).limit(10).to_a
end
end
end
When a single table holds years of data—like logs, events, or old orders—queries can slow down even with good indexes. Partitioning splits one logical table into many smaller physical tables based on a key, like created_at. A query for last week’s data only searches the partition for last week, ignoring the years of older data.
Managing partitions adds complexity, but for the right use case, it’s transformative. You can automate creating new monthly partitions and dropping old ones.
Each of these methods addresses a specific type of friction between your application and the database. They are not mutually exclusive; a well-optimized application will use several of them together. Start by identifying your bottlenecks. Use your database’s logging and explanation tools to see where time is being spent. Often, fixing the first one or two major issues—like an N+1 query or a missing index—is enough to restore performance. The goal is to build a system that remains simple to work with while handling growth gracefully.