Main  |  Other posts

Optimizing a Complex Rails Profile Endpoint — my step‑by‑step notes

Aug 15, 2025

I had an API endpoint that returned a Profile 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/profiles/:id (show)
  • GET /api/v1/profiles (index with Ransack filters + pagination)

Real SQL log I started from (show):

Started GET "/api/v1/profiles/1" for ::1 at 2025-08-11 14:59:59 +0000
Processing by Api::V1::ProfilesController#show as */*
  Parameters: {"id" => "1"}
  User Load (15.4ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1 /*action='show',application='WigiworkBack',controller='profiles'*/
  Profile Load (0.2ms)  SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`id` = 1 LIMIT 1 /*action='show',application='WigiworkBack',controller='profiles'*/
  ↳ app/controllers/api/v1/profiles_controller.rb:143:in 'Api::V1::ProfilesController#set_profile'
  AwardItem Load (0.3ms)  SELECT `award_items`.* FROM `award_items` WHERE `award_items`.`profile_id` = 1 /*action='show',application='WigiworkBack',controller='profiles'*/
  ↳ app/controllers/api/v1/profiles_controller.rb:109:in 'Api::V1::ProfilesController#show'
  CertificationItem Load (0.3ms)  SELECT `certification_items`.* FROM `certification_items` WHERE `certification_items`.`profile_id` = 1 /*action='show',application='WigiworkBack',controller='profiles'*/
  ↳ app/controllers/api/v1/profiles_controller.rb:109:in 'Api::V1::ProfilesController#show'
  ContactInfo Load (0.2ms)  SELECT `contact_infos`.* FROM `contact_infos` WHERE `contact_infos`.`profile_id` = 1 /*action='show',application='WigiworkBack',controller='profiles'*/
  ↳ app/controllers/api/v1/profiles_controller.rb:109:in 'Api::V1::ProfilesController#show'
  ContentBlock Load (0.2ms)  SELECT `content_blocks`.* FROM `content_blocks` WHERE `content_blocks`.`profile_id` = 1 /*action='show',application='WigiworkBack',controller='profiles'*/
  ↳ app/controllers/api/v1/profiles_controller.rb:109:in 'Api::V1::ProfilesController#show'
  EducationItem Load (0.2ms)  SELECT `education_items`.* FROM `education_items` WHERE `education_items`.`profile_id` = 1 /*action='show',application='WigiworkBack',controller='profiles'*/
  ↳ app/controllers/api/v1/profiles_controller.rb:109:in 'Api::V1::ProfilesController#show'
  FileDocument Load (0.8ms)  SELECT `attachments`.* FROM `attachments` WHERE `attachments`.`type` = 'FileDocument' AND `attachments`.`attachable_id` = 1 AND `attachments`.`attachable_type` = 'Profile' /*action='show',application='WigiworkBack',controller='profiles'*/
  ↳ app/controllers/api/v1/profiles_controller.rb:109:in 'Api::V1::ProfilesController#show'
  HighlightLink Load (0.3ms)  SELECT `highlight_links`.* FROM `highlight_links` WHERE `highlight_links`.`profile_id` = 1 /*action='show',application='WigiworkBack',controller='profiles'*/
  ↳ app/controllers/api/v1/profiles_controller.rb:109:in 'Api::V1::ProfilesController#show'
  GalleryImage Load (0.2ms)  SELECT `attachments`.* FROM `attachments` WHERE `attachments`.`type` = 'GalleryImage' AND `attachments`.`attachable_id` = 1 AND `attachments`.`attachable_type` = 'Profile' /*action='show',application='WigiworkBack',controller='profiles'*/
  ↳ app/controllers/api/v1/profiles_controller.rb:109:in 'Api::V1::ProfilesController#show'
  Language Load (0.2ms)  SELECT `languages`.* FROM `languages` WHERE `languages`.`profile_id` = 1 /*action='show',application='WigiworkBack',controller='profiles'*/
  ↳ app/controllers/api/v1/profiles_controller.rb:109:in 'Api::V1::ProfilesController#show'
  LicenseItem Load (0.2ms)  SELECT `license_items`.* FROM `license_items` WHERE `license_items`.`profile_id` = 1 /*action='show',application='WigiworkBack',controller='profiles'*/
  ↳ app/controllers/api/v1/profiles_controller.rb:109:in 'Api::V1::ProfilesController#show'
  LocationSetting Load (0.2ms)  SELECT `location_settings`.* FROM `location_settings` WHERE `location_settings`.`profile_id` = 1 LIMIT 1 /*action='show',application='WigiworkBack',controller='profiles'*/
  ↳ app/controllers/api/v1/profiles_controller.rb:109:in 'Api::V1::ProfilesController#show'
  PortfolioLink Load (0.3ms)  SELECT `portfolio_links`.* FROM `portfolio_links` WHERE `portfolio_links`.`profile_id` = 1 /*action='show',application='WigiworkBack',controller='profiles'*/
  ↳ app/controllers/api/v1/profiles_controller.rb:109:in 'Api::V1::ProfilesController#show'
  ProjectItem Load (0.2ms)  SELECT `project_items`.* FROM `project_items` WHERE `project_items`.`profile_id` = 1 /*action='show',application='WigiworkBack',controller='profiles'*/
  ↳ app/controllers/api/v1/profiles_controller.rb:109:in 'Api::V1::ProfilesController#show'
  FileDocument Load (0.2ms)  SELECT `attachments`.* FROM `attachments` WHERE `attachments`.`type` = 'FileDocument' AND `attachments`.`attachable_id` = 1 AND `attachments`.`attachable_type` = 'ProjectItem' /*action='show',application='WigiworkBack',controller='profiles'*/
  ↳ app/controllers/api/v1/profiles_controller.rb:109:in 'Api::V1::ProfilesController#show'
  GalleryImage Load (0.2ms)  SELECT `attachments`.* FROM `attachments` WHERE `attachments`.`type` = 'GalleryImage' AND `attachments`.`attachable_id` = 1 AND `attachments`.`attachable_type` = 'ProjectItem' /*action='show',application='WigiworkBack',controller='profiles'*/
  ↳ app/controllers/api/v1/profiles_controller.rb:109:in 'Api::V1::ProfilesController#show'
  Resume Load (0.2ms)  SELECT `resumes`.* FROM `resumes` WHERE `resumes`.`profile_id` = 1 LIMIT 1 /*action='show',application='WigiworkBack',controller='profiles'*/
  ↳ app/controllers/api/v1/profiles_controller.rb:109:in 'Api::V1::ProfilesController#show'
  FileDocument Load (0.2ms)  SELECT `attachments`.* FROM `attachments` WHERE `attachments`.`type` = 'FileDocument' AND `attachments`.`attachable_id` = 1 AND `attachments`.`attachable_type` = 'Resume' /*action='show',application='WigiworkBack',controller='profiles'*/
  ↳ app/controllers/api/v1/profiles_controller.rb:109:in 'Api::V1::ProfilesController#show'
  SalaryExpectation Load (0.3ms)  SELECT `salary_expectations`.* FROM `salary_expectations` WHERE `salary_expectations`.`profile_id` = 1 LIMIT 1 /*action='show',application='WigiworkBack',controller='profiles'*/
  ↳ app/controllers/api/v1/profiles_controller.rb:109:in 'Api::V1::ProfilesController#show'
  SecurityClearanceItem Load (0.2ms)  SELECT `security_clearance_items`.* FROM `security_clearance_items` WHERE `security_clearance_items`.`profile_id` = 1 /*action='show',application='WigiworkBack',controller='profiles'*/
  ↳ app/controllers/api/v1/profiles_controller.rb:109:in 'Api::V1::ProfilesController#show'
  SkillReview Load (0.2ms)  SELECT `skill_reviews`.* FROM `skill_reviews` WHERE `skill_reviews`.`profile_id` = 1 /*action='show',application='WigiworkBack',controller='profiles'*/
  ↳ app/controllers/api/v1/profiles_controller.rb:109:in 'Api::V1::ProfilesController#show'
  Skill Load (0.2ms)  SELECT `skills`.* FROM `skills` WHERE `skills`.`profile_id` = 1 /*action='show',application='WigiworkBack',controller='profiles'*/
  ↳ app/controllers/api/v1/profiles_controller.rb:109:in 'Api::V1::ProfilesController#show'
  VolunteerWorkItem Load (0.2ms)  SELECT `volunteer_work_items`.* FROM `volunteer_work_items` WHERE `volunteer_work_items`.`profile_id` = 1 /*action='show',application='WigiworkBack',controller='profiles'*/
  ↳ app/controllers/api/v1/profiles_controller.rb:109:in 'Api::V1::ProfilesController#show'
  WorkExperience Load (0.2ms)  SELECT `work_experiences`.* FROM `work_experiences` WHERE `work_experiences`.`profile_id` = 1 /*action='show',application='WigiworkBack',controller='profiles'*/
  ↳ app/controllers/api/v1/profiles_controller.rb:109:in 'Api::V1::ProfilesController#show'
  WorkReference Load (0.2ms)  SELECT `work_references`.* FROM `work_references` WHERE `work_references`.`work_experience_id` = 1 /*action='show',application='WigiworkBack',controller='profiles'*/
  ↳ app/controllers/api/v1/profiles_controller.rb:109:in 'Api::V1::ProfilesController#show'
  WorkingKnowledgeItem Load (0.2ms)  SELECT `working_knowledge_items`.* FROM `working_knowledge_items` WHERE `working_knowledge_items`.`profile_id` = 1 /*action='show',application='WigiworkBack',controller='profiles'*/
  ↳ app/controllers/api/v1/profiles_controller.rb:109:in 'Api::V1::ProfilesController#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/profiles_controller.rb (index; BEFORE)
@profiles = Profile.preload(
  :user,
  :contact_infos,
  :skills,
  :work_experiences,
  :portfolio_links,
  :languages,
  :content_blocks,
  :skill_reviews,
  :education_items,
  :working_knowledge_items,
  :certification_items,
  :award_items,
  :project_items,
  :highlight_links,
  :security_clearance_items,
  :license_items,
  :images,
  :files,
  :location_setting,
  :salary_expectation,
  # Nested associations for models that have their own associations
  { certification_items: [:files] },
  { resume: [:files] },
  { work_experiences: :work_references },
  { education_items: [:images, :files] },
  { license_items: [:images, :files, :license_links] },
  { project_items: [:images, :files] },
  { volunteer_work_items: [:images, :files] },
  { award_items: [:files] },
  # { security_clearance_items: [:images, :files] },
  # { certification_items: [:images, :files] },
  # { working_knowledge_items: [:images, :files] }
)
.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[
    user contact_infos skills work_experiences.work_references portfolio_links
    languages content_blocks skill_reviews education_items.images education_items.files
    working_knowledge_items certification_items.files award_items.files
    project_items.images project_items.files highlight_links security_clearance_items
    license_items.images license_items.files license_items.license_links
    images files resume.files location_setting salary_expectation volunteer_work_items.images
    volunteer_work_items.files
  ].freeze

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

  # JSON:API-style sparse fieldsets, e.g. fields[profiles]=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/profiles/1compact default payload.
  • GET /api/v1/profiles/1?include=project_items.images,education_items.files → 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/profiles_controller.rb

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

  # Map requested include tokens to a preload tree
  PRELOAD_MAP = {
    'user' => :user,
    'contact_infos' => :contact_infos,
    'skills' => :skills,
    'work_experiences' => { work_experiences: :work_references },
    'portfolio_links' => :portfolio_links,
    'languages' => :languages,
    'content_blocks' => :content_blocks,
    'skill_reviews' => :skill_reviews,
    'education_items.images' => { education_items: :images },
    'education_items.files'  => { education_items: :files },
    'working_knowledge_items' => :working_knowledge_items,
    'certification_items.files' => { certification_items: :files },
    'award_items.files' => { award_items: :files },
    'project_items.images' => { project_items: :images },
    'project_items.files'  => { project_items: :files },
    'highlight_links' => :highlight_links,
    'security_clearance_items' => :security_clearance_items,
    'license_items.images' => { license_items: :images },
    'license_items.files'  => { license_items: :files },
    'license_items.license_links' => { license_items: :license_links },
    'images' => :images,
    'files'  => :files,
    'resume.files' => { resume: :files },
    'location_setting' => :location_setting,
    'salary_expectation' => :salary_expectation,
    'volunteer_work_items.images' => { volunteer_work_items: :images },
    'volunteer_work_items.files'  => { volunteer_work_items: :files }
  }.freeze

  # GET /api/v1/profiles
  def index
    includes = parsed_includes
    fields   = parsed_fields
    scope    = Profile.all

    # JOINs strictly for Ransack filters/sorts.
    if params.dig(:q)&.keys&.any? { |k| k.start_with?('user_') }
      scope = scope.joins(:user)
    end
    if params.dig(:q)&.keys&.any? { |k| k.start_with?('location_setting_') }
      scope = scope.joins(:location_setting)
    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: ProfileBlueprint.render(records,
      view: view_for(includes),
      fields: fields['profiles'])
  end

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

    scope = Profile.preload(preload_tree(includes))
    @profile = scope.find(params[:id])

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

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

    render json: ProfileBlueprint.render(@profile,
      view: view_for(includes),
      fields: fields['profiles'])
  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?('project_items') || i.start_with?('education_items') }
      :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/profile_blueprint.rb

class ProfileBlueprint < Blueprinter::Base
  identifier :id

  view :compact do
    fields :username, :role, :location, :experience_years
    association :user, blueprint: UserBlueprint, view: :tiny
  end

  view :standard do
    include_view :compact
    association :skills, blueprint: SkillBlueprint
    association :languages, blueprint: LanguageBlueprint
  end

  view :extended do
    include_view :standard
    association :work_experiences, blueprint: WorkExperienceBlueprint, view: :with_refs
    association :education_items, blueprint: EducationItemBlueprint, view: :with_assets
    association :project_items, blueprint: ProjectItemBlueprint, view: :with_assets do |profile, options|
      max = options[:locals]&.fetch(:max_children, 25)
      profile.project_items.limit(max)
    end
    association :license_items, blueprint: LicenseItemBlueprint, view: :with_assets
    association :resume, blueprint: ResumeBlueprint, view: :with_files
  end
end

File: app/blueprints/project_item_blueprint.rb

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

  association :images, blueprint: AttachmentBlueprint
  association :files,  blueprint: AttachmentBlueprint

  # Per-record cache
  cache ->(obj, _opts) { "bp:project_item:#{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 = Profile.all

joins_needed = []
joins_needed << :user if params.dig(:q)&.keys&.any? { |k| k.start_with?('user_') }
joins_needed << :location_setting if params.dig(:q)&.keys&.any? { |k| k.start_with?('location_setting_') }
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):

@profile = Profile.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 ProfileBlueprint above).

# app/controllers/api/v1/profiles_controller.rb (show)
render json: ProfileBlueprint.render(@profile, 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,
  "project_item_ids": [3,5,8,13]
}

Then fetch details in bulk:

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

Or expose a focused associations endpoint:

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

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

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

    # Preload requested bits only
    Profile.where(id: profile.id).preload(preload_tree(includes)).load

    render json: {
      id: profile.id,
      include: includes,
      data: ProfileBlueprint.render(profile, view: :extended, fields: params.dig(:fields, 'profiles'))
    }
  end

  private

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

Usage:

GET /api/v1/profiles/1/associations?include=project_items,education_items

9) Model‑level tweaks that prevent surprise queries

File: app/models/profile.rb

class Profile < ApplicationRecord
  has_many :skills, inverse_of: :profile, dependent: :destroy
  has_many :project_items, inverse_of: :profile, dependent: :destroy
  # If you use counters a lot:
  # has_many :work_experiences, inverse_of: :profile, 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/profile.rb
def self.ransackable_associations(_ = nil)
  %w[user location_setting]
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/profiles_spec.rb

it 'stays under 12 queries for compact show' do
  expect {
    get "/api/v1/profiles/#{profile.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 user reports.


11) Requests I actually run during development

Index (lean default):

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

Index + filter on user + expand a bit:

GET /api/v1/profiles?include=skills,languages&q[user_email_cont]=max@

Show minimal (fastest):

GET /api/v1/profiles/1

Show with heavy expansions:

GET /api/v1/profiles/1?include=project_items.images,education_items.files,license_items.files

Fetch only associations later:

GET /api/v1/profiles/1/associations?include=project_items,education_items

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

Profile.preload(
  :user,
  :contact_infos,
  :skills,
  :work_experiences,
  :portfolio_links,
  :languages,
  :content_blocks,
  :skill_reviews,
  :education_items,
  :working_knowledge_items,
  :certification_items,
  :award_items,
  :project_items,
  :highlight_links,
  :security_clearance_items,
  :license_items,
  :images,
  :files,
  :location_setting,
  :salary_expectation,
  { certification_items: [:files] },
  { resume: [:files] },
  { work_experiences: :work_references },
  { education_items: [:images, :files] },
  { license_items: [:images, :files, :license_links] },
  { project_items: [:images, :files] },
  { volunteer_work_items: [:images, :files] },
  { award_items: [:files] }
)

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-profile-endpoint-optimization.md.