Polymorphic N+1 Cascades
N+1 queries that appear when iterating over a collection of records with a polymorphic belongs_to, where each loop iteration triggers an additional query to resolve the associated record of a different type.
Before / After
class Notification < ApplicationRecord
belongs_to :notifiable, polymorphic: true
end
# Controller
@notifications = Notification.recent.limit(50)
# View
<% @notifications.each do |n| %>
<%= n.notifiable.display_name %>
<% end %>
# Logs: 1 query for notifications + 50 queries for notifiable
# (Post Load, Comment Load, User Load, ...) @notifications = Notification.recent
.includes(:notifiable)
.limit(50)
# Logs: 1 query + 1 query per distinct notifiable type
# (e.g. Post Load IN (1,2,3), Comment Load IN (5,8), ...) Why this hurts
Each row in the parent collection issues a separate SELECT keyed on the polymorphic type and id columns. Fifty notifications produce fifty extra round-trips to the database, each requiring connection checkout, query plan selection, and a synchronous wait for the response. In a typical web request with a 100 ms database latency budget, a polymorphic cascade consumes most of that budget on network round-trips rather than actual query work.
Under concurrent load the problem compounds. Each request holds its database connection for the duration of the cascade, which saturates the connection pool and forces other requests to wait on pool checkout. The same cascade that takes 200 ms in development with an empty pool takes 2 seconds under production traffic because pool contention stacks on top of raw query time. Rack::Timeout fires, Puma marks the worker unhealthy, and the load balancer routes traffic to shrinking capacity.
The Bullet gem flags N+1 cascades only when the association is actually dereferenced on every iteration. Serializers that conditionally load associations (for admin users, for paid plans, for feature-flagged routes) hide the problem during development and expose it only when the specific code path is exercised in production. PostgreSQL’s prepared statement cache provides no benefit because each query has different bind parameters for the type column, defeating plan reuse. Query planner statistics become skewed because the database sees many single-row lookups instead of the batched IN query the ORM could have generated.
See also: Fix Rails N+1 Queries Effectively Using Bullet Gem.
Get Expert Help
Inheriting a legacy Rails codebase with this problem? Request a Technical Debt Audit.