I had an API endpoint that returned a Avatar and a lot of nested associations. It worked, but the shape of the data made it easy to trigger N+1s or force a single monster JOIN that Postgres struggled to optimize.

This write‑up is my engineering log: what I started with, why it was slow, and the exact changes I shipped. It includes every example from the session, starting at 1), along with the full SQL log and the original preload code. All snippets are explicitly file‑scoped so they’re copy‑pasteable into a Rails 7/8 codebase.


1) Baseline & symptoms

Endpoint:

  • GET /api/v1/avatars/:id (show)
  • GET /api/v1/avatars (index with Ransack filters + pagination)

Real SQL log I started from (show):

Started GET "/api/v1/avatars/1" for ::1 at 2025-08-11 14:59:59 +0000
Processing by Api::V1::GameController#show as */*
  Parameters: {"id" => "1"}
  Player Load (15.4ms)  SELECT `players`.* FROM `game_table` WHERE `players`.`id` = 1 LIMIT 1 /*action='show',application='TestApp',controller='game'*/
  Avatar Load (0.2ms)  SELECT `avatars`.* FROM `game_table` WHERE `avatars`.`id` = 1 LIMIT 1 /*action='show',application='TestApp',controller='game'*/
  ↳ app/controllers/api/v1/game_controller.rb:143:in 'Api::V1::GameController#set_avatar'
  Armory Load (0.3ms)  SELECT `armory`.* FROM `game_table` WHERE `armory`.`avatar_id` = 1 /*action='show',application='TestApp',controller='game'*/
  ↳ app/controllers/api/v1/game_controller.rb:109:in 'Api::V1::GameController#show'
  Rune Load (0.3ms)  SELECT `runes`.* FROM `game_table` WHERE `runes`.`avatar_id` = 1 /*action='show',application='TestApp',controller='game'*/
  ↳ app/controllers/api/v1/game_controller.rb:109:in 'Api::V1::GameController#show'
  Beacon Load (0.2ms)  SELECT `beacons`.* FROM `game_table` WHERE `beacons`.`avatar_id` = 1 /*action='show',application='TestApp',controller='game'*/
  ↳ app/controllers/api/v1/game_controller.rb:109:in 'Api::V1::GameController#show'
  LoreNode Load (0.2ms)  SELECT `lore_nodes`.* FROM `game_table` WHERE `lore_nodes`.`avatar_id` = 1 /*action='show',application='TestApp',controller='game'*/
  ↳ app/controllers/api/v1/game_controller.rb:109:in 'Api::V1::GameController#show'
  TrainingLog Load (0.2ms)  SELECT `training_logs`.* FROM `game_table` WHERE `training_logs`.`avatar_id` = 1 /*action='show',application='TestApp',controller='game'*/
  ↳ app/controllers/api/v1/game_controller.rb:109:in 'Api::V1::GameController#show'
  LoadoutDoc Load (0.8ms)  SELECT `asset_cache`.* FROM `game_table` WHERE `asset_cache`.`asset_kind` = 'LoadoutDoc' AND `asset_cache`.`owner_id` = 1 AND `asset_cache`.`owner_kind` = 'Avatar' /*action='show',application='TestApp',controller='game'*/
  ↳ app/controllers/api/v1/game_controller.rb:109:in 'Api::V1::GameController#show'
  QuestHook Load (0.3ms)  SELECT `quest_hooks`.* FROM `game_table` WHERE `quest_hooks`.`avatar_id` = 1 /*action='show',application='TestApp',controller='game'*/
  ↳ app/controllers/api/v1/game_controller.rb:109:in 'Api::V1::GameController#show'
  TrophyBanner Load (0.2ms)  SELECT `asset_cache`.* FROM `game_table` WHERE `asset_cache`.`asset_kind` = 'TrophyBanner' AND `asset_cache`.`owner_id` = 1 AND `asset_cache`.`owner_kind` = 'Avatar' /*action='show',application='TestApp',controller='game'*/
  ↳ app/controllers/api/v1/game_controller.rb:109:in 'Api::V1::GameController#show'
  Dialect Load (0.2ms)  SELECT `dialects`.* FROM `game_table` WHERE `dialects`.`avatar_id` = 1 /*action='show',application='TestApp',controller='game'*/
  ↳ app/controllers/api/v1/game_controller.rb:109:in 'Api::V1::GameController#show'
  Permit Load (0.2ms)  SELECT `permits`.* FROM `game_table` WHERE `permits`.`avatar_id` = 1 /*action='show',application='TestApp',controller='game'*/
  ↳ app/controllers/api/v1/game_controller.rb:109:in 'Api::V1::GameController#show'
  MapRule Load (0.2ms)  SELECT `map_rules`.* FROM `game_table` WHERE `map_rules`.`avatar_id` = 1 LIMIT 1 /*action='show',application='TestApp',controller='game'*/
  ↳ app/controllers/api/v1/game_controller.rb:109:in 'Api::V1::GameController#show'
  GuildLink Load (0.3ms)  SELECT `guild_links`.* FROM `game_table` WHERE `guild_links`.`avatar_id` = 1 /*action='show',application='TestApp',controller='game'*/
  ↳ app/controllers/api/v1/game_controller.rb:109:in 'Api::V1::GameController#show'
  Mission Load (0.2ms)  SELECT `missions`.* FROM `game_table` WHERE `missions`.`avatar_id` = 1 /*action='show',application='TestApp',controller='game'*/
  ↳ app/controllers/api/v1/game_controller.rb:109:in 'Api::V1::GameController#show'
  LoadoutDoc Load (0.2ms)  SELECT `asset_cache`.* FROM `game_table` WHERE `asset_cache`.`asset_kind` = 'LoadoutDoc' AND `asset_cache`.`owner_id` = 1 AND `asset_cache`.`owner_kind` = 'Mission' /*action='show',application='TestApp',controller='game'*/
  ↳ app/controllers/api/v1/game_controller.rb:109:in 'Api::V1::GameController#show'
  TrophyBanner Load (0.2ms)  SELECT `asset_cache`.* FROM `game_table` WHERE `asset_cache`.`asset_kind` = 'TrophyBanner' AND `asset_cache`.`owner_id` = 1 AND `asset_cache`.`owner_kind` = 'Mission' /*action='show',application='TestApp',controller='game'*/
  ↳ app/controllers/api/v1/game_controller.rb:109:in 'Api::V1::GameController#show'
  Chronicle Load (0.2ms)  SELECT `chronicles`.* FROM `game_table` WHERE `chronicles`.`avatar_id` = 1 LIMIT 1 /*action='show',application='TestApp',controller='game'*/
  ↳ app/controllers/api/v1/game_controller.rb:109:in 'Api::V1::GameController#show'
  LoadoutDoc Load (0.2ms)  SELECT `asset_cache`.* FROM `game_table` WHERE `asset_cache`.`asset_kind` = 'LoadoutDoc' AND `asset_cache`.`owner_id` = 1 AND `asset_cache`.`owner_kind` = 'Chronicle' /*action='show',application='TestApp',controller='game'*/
  ↳ app/controllers/api/v1/game_controller.rb:109:in 'Api::V1::GameController#show'
  BountyRule Load (0.3ms)  SELECT `bounty_rules`.* FROM `game_table` WHERE `bounty_rules`.`avatar_id` = 1 LIMIT 1 /*action='show',application='TestApp',controller='game'*/
  ↳ app/controllers/api/v1/game_controller.rb:109:in 'Api::V1::GameController#show'
  ClearanceRune Load (0.2ms)  SELECT `clearance_runes`.* FROM `game_table` WHERE `clearance_runes`.`avatar_id` = 1 /*action='show',application='TestApp',controller='game'*/
  ↳ app/controllers/api/v1/game_controller.rb:109:in 'Api::V1::GameController#show'
  AbilityReview Load (0.2ms)  SELECT `ability_reviews`.* FROM `game_table` WHERE `ability_reviews`.`avatar_id` = 1 /*action='show',application='TestApp',controller='game'*/
  ↳ app/controllers/api/v1/game_controller.rb:109:in 'Api::V1::GameController#show'
  Ability Load (0.2ms)  SELECT `abilities`.* FROM `game_table` WHERE `abilities`.`avatar_id` = 1 /*action='show',application='TestApp',controller='game'*/
  ↳ app/controllers/api/v1/game_controller.rb:109:in 'Api::V1::GameController#show'
  SideQuest Load (0.2ms)  SELECT `side_quests`.* FROM `game_table` WHERE `side_quests`.`avatar_id` = 1 /*action='show',application='TestApp',controller='game'*/
  ↳ app/controllers/api/v1/game_controller.rb:109:in 'Api::V1::GameController#show'
  RaidLog Load (0.2ms)  SELECT `raid_logs`.* FROM `game_table` WHERE `raid_logs`.`avatar_id` = 1 /*action='show',application='TestApp',controller='game'*/
  ↳ app/controllers/api/v1/game_controller.rb:109:in 'Api::V1::GameController#show'
  WitnessNote Load (0.2ms)  SELECT `witness_notes`.* FROM `game_table` WHERE `witness_notes`.`raid_log_id` = 1 /*action='show',application='TestApp',controller='game'*/
  ↳ app/controllers/api/v1/game_controller.rb:109:in 'Api::V1::GameController#show'
  FieldNote Load (0.2ms)  SELECT `field_notes`.* FROM `game_table` WHERE `field_notes`.`avatar_id` = 1 /*action='show',application='TestApp',controller='game'*/
  ↳ app/controllers/api/v1/game_controller.rb:109:in 'Api::V1::GameController#show'
Completed 200 OK in 46ms (Views: 0.3ms | ActiveRecord: 22.0ms (27 queries, 0 cached) | GC: 1.4ms)

Original preload block (index) I wanted to replace:

# app/controllers/api/v1/game_controller.rb (index; BEFORE)
@avatars = Avatar.preload(
  :player,
  :beacons,
  :abilities,
  :raid_logs,
  :guild_links,
  :dialects,
  :lore_nodes,
  :ability_reviews,
  :training_logs,
  :field_notes,
  :runes,
  :armory,
  :missions,
  :quest_hooks,
  :clearance_runes,
  :permits,
  :trophy_banners,
  :loadout_docs,
  :map_rule,
  :bounty_rule,
  # Nested associations for models that have their own associations
  { runes: [:loadout_docs] },
  { chronicle: [:loadout_docs] },
  { raid_logs: :witness_notes },
  { training_logs: [:trophy_banners, :loadout_docs] },
  { permits: [:trophy_banners, :loadout_docs, :permit_links] },
  { missions: [:trophy_banners, :loadout_docs] },
  { side_quests: [:trophy_banners, :loadout_docs] },
  { armory: [:loadout_docs] },
  # { clearance_runes: [:trophy_banners, :loadout_docs] },
  # { runes: [:trophy_banners, :loadout_docs] },
  # { field_notes: [:trophy_banners, :loadout_docs] }
)
.ransack(ransack_params)
.result(distinct: true)
.page(params[:page])
.per(params[:limit])

This fetches everything by default. Even when the UI only needed a subset, the DB and the JSON renderer were doing unnecessary work.


2) I made the payload opt‑in via include= and fields=

I added a small concern to parse and whitelist expansions and sparse fieldsets.

File: app/controllers/concerns/include_params.rb

module IncludeParams
  # Whitelist to avoid arbitrary preload trees
  ALLOWED_INCLUDES = %w[
    player beacons abilities raid_logs.witness_notes guild_links
    dialects lore_nodes ability_reviews training_logs.trophy_banners training_logs.loadout_docs
    field_notes runes.loadout_docs armory.loadout_docs
    missions.trophy_banners missions.loadout_docs quest_hooks clearance_runes
    permits.trophy_banners permits.loadout_docs permits.permit_links
    trophy_banners loadout_docs chronicle.loadout_docs map_rule bounty_rule side_quests.trophy_banners
    side_quests.loadout_docs
  ].freeze

  def parsed_includes
    raw = params[:include].to_s.split(',').map(&:strip)
    raw & ALLOWED_INCLUDES
  end

  # JSON:API-style sparse fieldsets, e.g. fields[avatars]=id,username,role
  def parsed_fields
    fields = params.fetch(:fields, {}).to_h.transform_values { |v| v.split(',').map(&:strip) }
    fields.transform_keys!(&:to_s)
  end
end

How I call it:

  • GET /api/v1/avatars/1compact default payload.
  • GET /api/v1/avatars/1?include=missions.trophy_banners,training_logs.loadout_docs → only those heavy bits are expanded.

3) Controller changes: preload for rendering, JOIN for filters

I mapped include tokens to a preload tree, and I only JOIN on associations that Ransack actually filters or sorts on.

File: app/controllers/api/v1/game_controller.rb

class Api::V1::GameController < ApplicationController
  include IncludeParams

  # Map requested include tokens to a preload tree
  PRELOAD_MAP = {
    'player' => :player,
    'beacons' => :beacons,
    'abilities' => :abilities,
    'raid_logs' => { raid_logs: :witness_notes },
    'guild_links' => :guild_links,
    'dialects' => :dialects,
    'lore_nodes' => :lore_nodes,
    'ability_reviews' => :ability_reviews,
    'training_logs.trophy_banners' => { training_logs: :trophy_banners },
    'training_logs.loadout_docs'  => { training_logs: :loadout_docs },
    'field_notes' => :field_notes,
    'runes.loadout_docs' => { runes: :loadout_docs },
    'armory.loadout_docs' => { armory: :loadout_docs },
    'missions.trophy_banners' => { missions: :trophy_banners },
    'missions.loadout_docs'  => { missions: :loadout_docs },
    'quest_hooks' => :quest_hooks,
    'clearance_runes' => :clearance_runes,
    'permits.trophy_banners' => { permits: :trophy_banners },
    'permits.loadout_docs'  => { permits: :loadout_docs },
    'permits.permit_links' => { permits: :permit_links },
    'trophy_banners' => :trophy_banners,
    'loadout_docs'  => :loadout_docs,
    'chronicle.loadout_docs' => { chronicle: :loadout_docs },
    'map_rule' => :map_rule,
    'bounty_rule' => :bounty_rule,
    'side_quests.trophy_banners' => { side_quests: :trophy_banners },
    'side_quests.loadout_docs'  => { side_quests: :loadout_docs }
  }.freeze

  # GET /api/v1/avatars
  def index
    includes = parsed_includes
    fields   = parsed_fields
    scope    = Avatar.all

    # JOINs strictly for Ransack filters/sorts.
    if params.dig(:q)&.keys&.any? { |k| k.start_with?('player_') }
      scope = scope.joins(:player)
    end
    if params.dig(:q)&.keys&.any? { |k| k.start_with?('map_rule_') }
      scope = scope.joins(:map_rule)
    end

    scope = scope.preload(preload_tree(includes)) if includes.any?

    records = scope.ransack(ransack_params).result(distinct: true)
                   .page(params[:page]).per(params[:limit])

    render json: AvatarBlueprint.render(records,
      view: view_for(includes),
      fields: fields['avatars'])
  end

  # GET /api/v1/avatars/:id
  def show
    includes = parsed_includes
    fields   = parsed_fields

    scope = Avatar.preload(preload_tree(includes))
    @avatar = scope.find(params[:id])

    # Strong HTTP caching: ETag + Last-Modified across key associations
    last_mod = [
      @avatar.updated_at,
      @avatar.abilities.maximum(:updated_at),
      @avatar.raid_logs.maximum(:updated_at)
    ].compact.max

    fresh_when etag: [@avatar.cache_key_with_version, includes.sort],
               last_modified: last_mod,
               public: true

    render json: AvatarBlueprint.render(@avatar,
      view: view_for(includes),
      fields: fields['avatars'])
  end

  private

  def preload_tree(includes)
    includes.map { |key| PRELOAD_MAP.fetch(key) }
  end

  def view_for(includes)
    if includes.any? { |i| i.start_with?('missions') || i.start_with?('training_logs') }
      :extended
    elsif includes.any?
      :standard
    else
      :compact
    end
  end

  def ransack_params
    params.fetch(:q, {})
  end
end

4) Serializer views + per‑record caches (Blueprinter)

The default is compact; heavier trees are behind :standard and :extended. Heavy leaf nodes cache by cache_key_with_version.

File: app/blueprints/avatar_blueprint.rb

class AvatarBlueprint < Blueprinter::Base
  identifier :id

  view :compact do
    fields :username, :role, :location, :experience_years
    association :player, blueprint: PlayerBlueprint, view: :tiny
  end

  view :standard do
    include_view :compact
    association :abilities, blueprint: AbilityBlueprint
    association :dialects, blueprint: DialectBlueprint
  end

  view :extended do
    include_view :standard
    association :raid_logs, blueprint: RaidLogBlueprint, view: :with_refs
    association :training_logs, blueprint: TrainingLogBlueprint, view: :with_assets
    association :missions, blueprint: MissionBlueprint, view: :with_assets do |avatar, options|
      max = options[:locals]&.fetch(:max_children, 25)
      avatar.missions.limit(max)
    end
    association :permits, blueprint: PermitBlueprint, view: :with_assets
    association :chronicle, blueprint: ChronicleBlueprint, view: :with_loadout_docs
  end
end

File: app/blueprints/mission_blueprint.rb

class MissionBlueprint < Blueprinter::Base
  identifier :id
  fields :title, :summary, :started_on, :finished_on

  association :trophy_banners, blueprint: AttachmentBlueprint
  association :loadout_docs,  blueprint: AttachmentBlueprint

  # Per-record cache
  cache ->(obj, _opts) { "bp:mission:#{obj.cache_key_with_version}" }
end

5) Avoid “monster SQL”: split JOINs for filters from PRELOADs for rendering

This is the pattern I keep handy when the index endpoint starts accreting conditions:

scope = Avatar.all

joins_needed = []
joins_needed << :player if params.dig(:q)&.keys&.any? { |k| k.start_with?('player_') }
joins_needed << :map_rule if params.dig(:q)&.keys&.any? { |k| k.start_with?('map_rule_') }
scope = scope.joins(*joins_needed) if joins_needed.any?

scope = scope.preload(preload_tree(parsed_includes))
records = scope.ransack(ransack_params).result(distinct: true)

6) Async preloading (optional)

Rails 7/8 lets me parallelize independent SELECTs:

File: config/application.rb

config.active_record.async_query_executor = :global_thread_pool
config.active_record.global_executor_concurrency = 4 # tune per env

Usage (example):

@avatar = Avatar.preload(preload_tree(parsed_includes)).load_async.find(params[:id])

7) Cap / paginate heavy nested collections

I pass a locals cap to Blueprinter and enforce it in the association (see AvatarBlueprint above).

# app/controllers/api/v1/game_controller.rb (show)
render json: AvatarBlueprint.render(@avatar, view: :extended, locals: { max_children: 25 })

8) Side‑loading for fastest TTFB

Sometimes I just want IDs first, details later.

Main payload sideload IDs:

{
  "id": 1,
  "mission_ids": [3,5,8,13]
}

Then fetch details in bulk:

GET /api/v1/missions?ids=3,5,8,13

Or expose a focused associations endpoint:

File: app/controllers/api/v1/game_associations_controller.rb

class Api::V1::GameAssociationsController < ApplicationController
  include IncludeParams

  def show
    avatar = Avatar.find(params[:id])
    includes = parsed_includes
    raise ActionController::BadRequest, "include= required" if includes.blank?

    # Preload requested bits only
    Avatar.where(id: avatar.id).preload(preload_tree(includes)).load

    render json: {
      id: avatar.id,
      include: includes,
      data: AvatarBlueprint.render(avatar, view: :extended, fields: params.dig(:fields, 'avatars'))
    }
  end

  private

  def preload_tree(includes)
    includes.map { |key| Api::V1::GameController::PRELOAD_MAP.fetch(key) }
  end
end

Usage:

GET /api/v1/avatars/1/associations?include=missions,training_logs

9) Model‑level tweaks that prevent surprise queries

File: app/models/avatar.rb

class Avatar < ApplicationRecord
  has_many :abilities, inverse_of: :avatar, dependent: :destroy
  has_many :missions, inverse_of: :avatar, dependent: :destroy
  # If you use counters a lot:
  # has_many :raid_logs, inverse_of: :avatar, dependent: :destroy, counter_cache: true
end

I also use touch: false on high‑churn relations so I don’t constantly invalidate parent caches.

To keep Ransack from auto‑joining unexpected stuff, I whitelist:

# app/models/avatar.rb
def self.ransackable_associations(_ = nil)
  %w[player map_rule]
end

def self.ransackable_attributes(_ = nil)
  %w[username role location experience_years]
end

10) Guardrails: I enforce a “query budget” in tests

File: spec/requests/api/v1/avatars_spec.rb

it 'stays under 12 queries for compact show' do
  expect {
    get "/api/v1/avatars/#{avatar.id}"
  }.to make_database_queries(count: <= 12) # adapt matcher/threshold
end

I also log payload_size (bytes) and render time so regressions show up in metrics, not in player reports.


11) Requests I actually run during development

Index (lean default):

GET /api/v1/avatars?page=1&limit=20

Index + filter on player + expand a bit:

GET /api/v1/avatars?include=abilities,dialects&q[player_email_cont]=max@

Show minimal (fastest):

GET /api/v1/avatars/1

Show with heavy expansions:

GET /api/v1/avatars/1?include=missions.trophy_banners,training_logs.loadout_docs,permits.loadout_docs

Fetch only associations later:

GET /api/v1/avatars/1/associations?include=missions,training_logs

12) Appendix — the full “before” preload list (for posterity)

Avatar.preload(
  :player,
  :beacons,
  :abilities,
  :raid_logs,
  :guild_links,
  :dialects,
  :lore_nodes,
  :ability_reviews,
  :training_logs,
  :field_notes,
  :runes,
  :armory,
  :missions,
  :quest_hooks,
  :clearance_runes,
  :permits,
  :trophy_banners,
  :loadout_docs,
  :map_rule,
  :bounty_rule,
  { runes: [:loadout_docs] },
  { chronicle: [:loadout_docs] },
  { raid_logs: :witness_notes },
  { training_logs: [:trophy_banners, :loadout_docs] },
  { permits: [:trophy_banners, :loadout_docs, :permit_links] },
  { missions: [:trophy_banners, :loadout_docs] },
  { side_quests: [:trophy_banners, :loadout_docs] },
  { armory: [:loadout_docs] }
)

What changed (in one screen)

  • Default response is compact; clients expand with include=/fields=.
  • I JOIN only for filters/sorts; I preload what I render.
  • Heavier serializer views are cached per record.
  • Strong ETag/Last‑Modified avoids re‑rendering unchanged resources.
  • Optional async preloading helps when many independent associations exist.
  • I cap/paginate heavy children or fetch them via dedicated endpoints.
  • Guardrails (query budget, Ransack whitelists, payload logging) keep it fast.

If you drop this post into a Jekyll site, name it _posts/2025-08-15-rails-avatar-endpoint-optimization.md.