ruby 74 lines · 3 tabs

N+1 Proof Serialization with preloaded associations

Shared by codesnips Jan 2026
3 tabs
class PostsController < ApplicationController
  def index
    posts = Post.for_feed.page(params[:page]).per(25)

    render json: {
      data: posts.map { |post| PostSerializer.new(post).as_json },
      meta: { page: posts.current_page, total: posts.total_count }
    }
  end

  def show
    post = Post.for_feed.find(params[:id])
    render json: { data: PostSerializer.new(post).as_json }
  end
end
3 files · ruby Explain with highlit

This snippet shows how to build a JSON API endpoint that is provably free of N+1 queries by pairing an explicit preload contract with serializers that only touch already-loaded associations. The core idea is that serialization should never trigger a database query: every association a serializer reads must be loaded up front, so the number of queries stays constant regardless of how many records are rendered.

In PostsController, the index action delegates loading to a scope named for_feed rather than inlining includes. This keeps the preload contract in one place and next to the model it belongs to. The controller stays thin and simply hands the relation to PostSerializer, which is where a naive implementation would silently fire one query per post for author, per post for comments, and per comment for its author.

In Post model, for_feed uses includes(:author, comments: :author) to eager-load the full object graph the serializer needs, then strict_loading! is the safety net. strict_loading makes ActiveRecord raise ActiveRecord::StrictLoadingViolationError the moment any un-preloaded association is accessed, turning a hidden performance bug into a loud test failure. The counter_cache on Comment means comments_count is a real column, so rendering a count never loads the comment rows at all.

In PostSerializer, every method reads from associations that for_feed already loaded. comment_count prefers the cached column via comments_count and only falls back to size on an already-materialized collection, avoiding a COUNT(*) round trip. Nested comments are mapped with CommentSerializer, which itself only reads comment.author, kept warm by the comments: :author preload.

The trade-off is discipline: the serializer and the scope are tightly coupled, and adding a new field that reads a fresh association requires updating for_feed too. That coupling is intentional — strict_loading guarantees the two stay in sync, because forgetting the preload raises instead of quietly degrading. This pattern is worth reaching for on hot list endpoints where render fan-out is large and predictable, and it pairs well with a request-level query-count assertion in tests to lock the behavior in.


Related snips

Share this code

Here's the card — post it anywhere.

N+1 Proof Serialization with preloaded associations — share card
Link copied