Updated October 2026: Fixed outdated and incorrect examples (
User.all(limit: 10), dynamic finders, the SQL injection demo,serve_static_files), replaced image spriting with modern image advice, and added enum, index, Brotli and Active Job examples.
I. Remove Unused Code
Removing unused code is an integral part of maintaining source code in a software project. It helps clean up the codebase and reduces complexity, making the code easier to understand and maintain in the future.
Delete dead code instead of commenting it out — version control already keeps the history if you ever need it back.
II. Keep Controllers Thin
Thin controllers are easier to read and test. A controller action should parse the request, call the model (or a service object) and render a response; business rules belong in models or plain Ruby objects, and the view should only receive the data it actually displays.
# DON'T: business logic in the controller
def create
@order = Order.new(order_params)
@order.total = @order.line_items.sum { |item| item.price * item.quantity }
@order.status = "pending"
# ...
end
# SHOULD BE: the model owns the rule
class Order < ApplicationRecord
before_validation :calculate_total
private
def calculate_total
self.total = line_items.sum { |item| item.price * item.quantity }
end
end
III. DRY (Don’t Repeat Yourself)
DON’T
def published?
state == 'published'
end
def draft?
state == 'draft'
end
def spam?
state == 'spam'
end
SHOULD BE
STATES = ['draft', 'published', 'spam']
STATES.each do |state_name|
define_method "#{state_name}?" do
state == state_name
end
end
In the above case, we can use an enum instead. It generates the predicate methods (published?), bang methods that update the value (published!) and scopes (Post.published, Post.not_published) for us:
class Post < ApplicationRecord
# Integer column:
enum :state, { draft: 0, published: 1, spam: 2 }
# Or, keeping an existing string column (the Rails docs note this is likely slower to query):
# enum :state, { draft: "draft", published: "published", spam: "spam" }
end
IV. Eager Loading
Eager loading is a way to solve the classic N + 1 query performance problem caused by inefficient use of child objects.
Let’s look at the following code. It will fetch the zip of 10 users.
DON’T
users = User.limit(10)
users.each do |user|
puts user.address.zip # one extra query per user
end
SHOULD BE
users = User.includes(:address).limit(10)
users.each do |user|
puts user.address.zip
end
To catch N + 1 queries early, you can enable strict_loading on a relation or model; lazily loading an association then raises ActiveRecord::StrictLoadingViolationError.
V. Use Indexes
Database indexing is one of the simplest ways to improve database performance. INSERT and UPDATE operations become slightly slower, but reads — which are far more frequent in web applications — get much faster. Index the columns you filter, join or sort on, and enforce uniqueness at the database level:
class AddIndexToUsersEmail < ActiveRecord::Migration[8.0]
def change
add_index :users, :email, unique: true
end
end
t.references / add_reference already add an index on the foreign key unless you pass index: false.
VI. Prefer find_by Over Dynamic Finders
find_by(email: email) is a normal method. Dynamic finders such as find_by_email(email) are different: the first call goes through method_missing, which matches the method name against the table’s columns, and then Active Record defines a real method so later calls are direct. The performance cost is therefore small, but find_by(email: ...) is still the better choice: it is explicit, easy to grep, works with several attributes at once (find_by(email: email, active: true)), and doesn’t depend on method-name magic.
VII. Caching
There are many typical types of caching, such as HTTP Caching, Page Caching, Counter Caching, Proxy Caching, Fragment Caching,…
HTTP Caching: This is a mechanism that web browsers and web servers use to store (cache) HTTP resources such as HTML, CSS, JavaScript, images, and other media resources. This helps reduce page load times by fetching resources from the cache instead of re-requesting them from the server.
Page Caching: This involves storing entire web pages that have been generated and displayed to users, helping to reduce response times and page load times. Page caching was removed from Rails core in Rails 4.0; it lives on in the
actionpack-page_cachinggem.Counter Caching: Used to store counts or other counting-related information instead of recalculating it each time there is a request. This reduces the load on the database and improves the performance of counting-related functions, such as counting the number of comments on a blog post.
Proxy Caching: Proxy caching occurs when a proxy server caches requested resources from the origin server and serves them directly to subsequent requests from clients, rather than waiting for the origin server’s response. This reduces the load on the origin server and improves performance for end users.
Fragment caching: Is a technique in Ruby on Rails (and many other web frameworks) that allows you to cache a portion of a view or a fragment of a webpage instead of caching the entire page or all data. This enables you to optimize the performance of your web application by caching only the parts of the page that truly need caching, while still allowing other parts of the page to be rendered as needed.
<% cache @post do %> <%= render @post %> <% end %>
Each type of caching has its own applications and benefits in different situations. In future articles, we can explore each type of caching in detail and how they can be deployed and optimized in web systems and applications.
VIII. Optimize Images
In websites, a significant amount of time is consumed loading images. Image spriting (combining many images into one file) used to be the standard fix, but with HTTP/2 multiplexing many requests over a single connection, most of its benefit is gone. Today, focus on:
- SVG for icons and logos, which stay sharp at any size and are usually tiny.
- Modern formats such as WebP or AVIF for photos, which generally compress better than JPEG and PNG; provide a fallback with the
<picture>element where needed. - Lazy loading off-screen images with
loading="lazy", e.g.image_tag "photo.webp", loading: "lazy".
IX. Use a CDN
A CDN (content delivery network) is an interconnected system of computers on the Internet that provides web content rapidly to numerous users by duplicating the content on multiple servers and directing the content to users based on proximity.
When concurrent users come to your site, using a CDN rather than serving assets (like images, JavaScript, stylesheets) from your server will boost performance. In Rails, pointing asset URLs at a CDN is a single setting:
# config/environments/production.rb
config.asset_host = "https://cdn.example.com"
X. Minify and Compress Stylesheets and JavaScript
You can reduce the size of stylesheets and JavaScript significantly by minifying them and serving them compressed. Besides gzip, all current major browsers accept Brotli (Content-Encoding: br), which gives better compression ratios than gzip but is slower to compress — ideal for precompressed, cacheable assets. Compression is normally handled by your web server, reverse proxy or CDN rather than by Rails itself. It will improve performance significantly by reducing request/response time.
XI. Specify Which Columns You Need If Possible
If you only need the email of each user:
User.limit(100).map(&:email)
Can Be…
User.select(:email).limit(100).map(&:email)
Better
User.limit(100).pluck(:email)
If you just need a few values, pluck avoids instantiating Active Record objects altogether.
XII. Use Background Jobs
We should use background jobs for:
- Tasks that need to spend a longer time to complete than a normal request.
- Tasks that need not return an immediate response to the user.
Rails’ Active Job gives you one API for this, and since Rails 8.0 new apps use the database-backed Solid Queue as the default backend (Sidekiq and others are still supported):
class WelcomeEmailJob < ApplicationJob
queue_as :default
def perform(user)
UserMailer.welcome(user).deliver_now
end
end
WelcomeEmailJob.perform_later(user)
# For mailers, deliver_later does the same in one call:
UserMailer.welcome(user).deliver_later
XIII. SQL Injection
Never interpolate user input into a SQL string. The value becomes part of the query itself:
DON’T
# params[:id] = "1) OR (1=1"
User.where("id = #{params[:id]}").first
SELECT "users".* FROM "users" WHERE (id = 1) OR (1=1) ORDER BY "users"."id" ASC LIMIT 1
The condition is always true, so the query returns a user regardless of the id requested — the attacker controls the WHERE clause.
SHOULD BE
Let Active Record quote the value, with a hash condition or a placeholder:
User.find_by(id: params[:id])
User.where("id = ?", params[:id]).first
User.where("name LIKE ?", "%#{User.sanitize_sql_like(params[:q])}%")
XIV. Serving Static Files in Production
Since Rails 5.0, the setting is config.public_file_server.enabled (it replaced serve_static_files / serve_static_assets), and it controls whether the Rails app serves files from public/, including precompiled assets.
Whether to turn it off depends on what sits in front of your app:
- Behind Nginx, Apache or a CDN that already serves
public/, let them do it — they are faster at static files than a Ruby process. - With the Rails 8 default Docker setup, the Dockerfile runs Puma behind Thruster, a small proxy that provides HTTP caching of public assets, X-Sendfile support and gzip compression. In that setup, serving assets from the app container is fine, especially with a CDN in front.
Public comments are closed, but I love hearing from readers. Feel free to contact me with your thoughts.