agentsclimarketplace

Rails performance

Skill mickzijdel/rails-toolkit/skills/rails-performance

Use when optimizing performance with caching, ETags, batching, and N+1 preventionFrom its SKILL.md

Install
npx -y skills add mickzijdel/rails-toolkit --skill rails-performance

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

15.0 KB, ~3.8k tokens by cl100k_base, as published. Nobody here has run it

Rails Performance Patterns

1. Fragment Caching in Views

Cache expensive partials with <% cache record do %>. The key derives from cache_key_with_version, so the fragment invalidates when the record's updated_at changes. Nest cache blocks for granular invalidation.

<% cache card do %>
  <div class="card-perma__actions">
    <%= render "cards/container/gild", card: card %>
    <%= render "cards/container/image", card: card %>
  </div>
  <%= card_article_tag card, class: "card" do %>
    <%= render "cards/display/perma/board", card: card %>
    <%= render "cards/display/perma/tags", card: card %>
  <% end %>
<% end %>

2. Collection Caching

cached: true on collection renders caches each item individually and fetches all of them in one read_multi call. Combine with a preloaded scope (Pattern 7) to avoid N+1 during rendering.

<%= render partial: "cards/comments/comment",
           collection: card.comments.preloaded.chronologically,
           cached: true %>

3. JSON Caching

Use json.cache! in Jbuilder templates:

json.cache! @event do
  json.id @event.id
  json.action @event.action
  json.eventable do
    json.partial! @event.eventable
  end
end

4. Don't Over-Cache: The "Fast N+1" Anti-Pattern

Granular Rails.cache.fetch calls scattered through models and helpers turn a list page into dozens or hundreds of cache round-trips — an N+1 made of cache reads instead of queries. Each read is a network hop to Memcached/Redis; a hot DB query (primary-key lookup from buffer pool) is often faster than the cache round-trip it was "optimized" into.

Real example (Speedshop's AO3 audit): one bookmarks index page produced 179 cache operations — roughly 8 separate cache keys per rendered row (blurb, byline, kudos count, comment count, CSS classes...). The fix is one fragment per row.

# ❌ Bad: helper-level micro-caches, hit once per row
def bookmark_count(work)
  Rails.cache.fetch([work, "bookmark_count"]) { work.bookmarks.count }
end
<%# ✅ Good: one fragment per row covers all of it %>
<% cache work do %>
  <%= render "works/blurb", work: work %>
<% end %>

Key Points:

  • Cache at the highest level that invalidates correctly — usually the HTML fragment, not the individual query.
  • Only cache when regeneration costs meaningfully more than the ~1ms cache round-trip; don't cache single-row lookups or tiny always-hot tables.
  • Collection caching (Pattern 2) is the end state: all row fragments in a single read_multi.

5. ETags for HTTP Caching

fresh_when computes an ETag from the cache keys of the given objects and handles 304 Not Modified automatically. Use stale? when rendering is conditional.

def index
  @pins = Current.user.pins.ordered
  fresh_when etag: [ @pins, @pins.collect(&:card) ]   # arrays combine objects
end

# optional resource: fall back to a literal so "absent" is cacheable too
def show_watch
  fresh_when etag: @card.watch_for(Current.user) || "none"
end

def show
  if stale?(etag: [ @board, @page.records, @user_filtering, Current.account ])
    respond_to do |format|
      format.html
      format.json
    end
  end
end

6. Global ETags

Add global ETag components in ApplicationController so all ETags change when shared elements change:

class ApplicationController < ActionController::Base
  etag { "v1" }                    # bump to invalidate all client caches
  stale_when_importmap_changes     # invalidates when JS changes
end

7. Preloading Scopes (N+1 Prevention)

Create a named preloaded scope per model that preloads everything its standard rendering touches.

class Card < ApplicationRecord
  scope :preloaded, -> {
    with_users
      .preload(:column, :tags, :steps, :closure, :goldness,
               :activity_spike, :image_attachment,
               board: [ :entropy, :columns ],
               not_now: [ :user ])
      .with_rich_text_description_and_embeds
  }

  scope :with_users, -> {
    preload(:creator, assignments: :user)
  }
end

# In controller
@cards = Current.user.accessible_cards.preloaded.published

Key Points:

  • Name the scope preloaded for consistency.
  • Use preload for separate queries, includes when you need to filter on the association.
  • Use with_rich_text_*_and_embeds for ActionText (see [[rails-activestorage]] for attachment preload names).

Choosing a loading strategy

The preloaded scope above uses preload, the right default. Reach for the others only when filtering or sorting by an association:

MethodQuery strategyReach for it when
preloadOne extra query per associationDefault. You read the association but don't filter/sort by it (the preloaded scope).
includesRails auto-picks preload or a LEFT JOINYou filter/sort on the association and want Rails to decide (pair with references when you reference it in where).
eager_loadForces one LEFT OUTER JOINYou filter/sort on the association and want to force the single-query form includes would otherwise leave to chance.
joinsINNER JOIN, loads nothingYou filter by the association but never read its columns.

joins does not load the association — touching its attributes afterward re-triggers the N+1. Switch to preload/includes the moment you need the data, not just the filter.


8. N+1 Detection with Prosopite

Preloading scopes prevent the N+1s you know about; prosopite stops new ones from shipping. It watches the query log for repeated query fingerprints with near-zero false positives (unlike Bullet, which both misses N+1s and flags non-issues) — accurate enough to raise on.

# Gemfile
group :development, :test do
  gem "prosopite"
end

# test/test_helper.rb
Prosopite.rails_logger = true
Prosopite.raise = true

module ActiveSupport
  class TestCase
    setup    { Prosopite.scan }
    teardown { Prosopite.finish }
  end
end

Rolling out on an existing app with known N+1s: install and log in development first; enable scanning per test class starting with the clean ones; keep a short ignore list (Prosopite.allow_stack_paths = [...]) for the final holdouts; end state is raise = true suite-wide.


9. Preload the Session User's Associations

Permission checks (current_user.roles, current_user.admin?, feature flags) run all over views and controllers — each unloaded association check is another query, on every request. Eager-load what authorization touches at the moment the session user is materialized.

# Rails 8 Authentication concern
def find_session_by_cookie
  Session.includes(user: :roles).find_by(id: cookies.signed[:session_id])
end

# Devise equivalent
def self.serialize_from_session(key, salt)
  record = includes(:roles).where(primary_key => key).first
  record if record && record.authenticatable_salt == salt
end

Key Points:

  • Route permission checks through one method (has_role?(name)) that works on the loaded collection, instead of roles.where(...) calls that bypass the preload.
  • Only preload what authorization touches every request — not a license to includes the world.

10. Batching Operations

Use find_each/in_batches for large collections; add sleep_between_batches-style throttling for long-running cleanup.

def self.reindex_all
  Card.find_each(batch_size: 1000) do |card|
    card.update_search_record
  end
end

# config/recurring.yml — Solid Queue cleanup with batching
clear_solid_queue_finished_jobs:
  command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
  schedule: every hour at minute 12

11. Keyset Pagination (Geared Pagination)

Offset pagination (LIMIT/OFFSET) degrades linearly with page number. Use cursor-based pagination via the geared_pagination gem; "geared" page sizes load 15 first, more as users scroll.

def show
  @page = set_page_and_extract_portion_from(
    @board.cards.preloaded.published.sorted_by(@filter.sort),
    per_page: [ 15, 30, 50, 100 ]
  )
  fresh_when etag: [ @board, @page.records ]
end
<%= render @page.records %>
<% unless @page.last? %>
  <%= link_to "Load more", board_path(@board, page: @page.next_param), data: { turbo_stream: true } %>
<% end %>

12. Public Cache Headers

Let CDNs/browsers cache public pages — short TTLs for dynamic content, combined with ETags for validation:

class Public::BaseController < ApplicationController
  allow_unauthenticated_access
  before_action :set_public_cache_expiration

  private
    def set_public_cache_expiration
      expires_in 30.seconds, public: true
    end
end

13. Read Replicas for GETs and Read-Only Jobs

The SQL primary is the hardest thing to scale; move read-only work to replicas — both web GETs and dedicated read-only job queues.

Web requests (automatic role switching):

# config/environments/production.rb
config.active_record.database_selector = { delay: 2.seconds }
config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver
config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session

GET/HEAD requests go to the replica; for 2 seconds after any write, that browser session reads from the primary ("read your own writes"). Before enabling, audit GET actions for side-effect writes (tracking columns, counters) — those will raise on a replica.

Background jobs (read-only queue variants):

# app/jobs/application_job.rb
around_perform do |job, block|
  if job.queue_name.to_s.end_with?("_read_only")
    ActiveRecord::Base.connected_to(role: :reading) { block.call }
  else
    block.call
  end
rescue ActiveRecord::ReadOnlyError
  # Job turned out to write — retry it on the writer queue
  job.class.set(queue: job.queue_name.to_s.delete_suffix("_read_only"))
     .perform_later(*job.arguments)
end

Duplicate heavy read-mostly queues (reports, exports, stat rollups) as *_read_only variants and move jobs over incrementally; the rescue is the safety net for jobs that unexpectedly write.

Key Points:

  • Only route work where slightly stale data is acceptable.
  • Watch active (non-idle) connections on the primary — healthy target is ≤ ~50% of the database's CPU count (see [[rails-database-performance]]).

14. Graceful Degradation for Non-Critical Datastores

Search and autocomplete backends (Elasticsearch, Redis) fail hard by default: 30-second client timeouts and unrescued errors mean a degraded search cluster ties up every Puma thread and takes the whole app down — an outage caused by a feature the page didn't need. Bound every call, rescue failures, render the page without the feature.

# 1. Short client timeouts — never the 30s default
config = { transport_options: { request: { timeout: 2, open_timeout: 1 } } }

# 2. Server-side timeout inside the query itself
def search(query)
  client.search(body: { query: query, timeout: "2s" })
end

# 3. Rescue and degrade to a 200, not a 500
def results
  Work.search(params[:q])
rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Searchkick::Error
  SearchResults.unavailable   # page renders with a "search is unavailable" notice
end

Key Points:

  • Put a hard LIMIT on range reads. An autocomplete that runs zrevrangebyscore over thousands of entries and truncates to 15 in Ruby should pass limit: [0, 50] to Redis instead.
  • Redis is single-threaded: one unbounded query blocks every other client of that instance (sessions, caching).
  • Add a circuit breaker (e.g. circuitbox, or Faraday retry/breaker middleware) so a down dependency fails in microseconds instead of holding threads for the timeout.
  • Cache popular lookups in a small process-local LRU (ActiveSupport::Cache::MemoryStore.new(size: 10.megabytes)) — popular autocomplete prefixes are highly repetitive.

15. Runtime: YJIT, jemalloc, Autotuner, Thruster

  • YJIT — a memory-for-speed trade, not a free win: roughly +10–15% throughput for +20–30% memory per process. Rails 7.2+ enables it automatically on Ruby 3.3+; check for an explicit disable left behind from an older Ruby and remove it. Skip only when memory-constrained.
  • jemalloc — reduces memory fragmentation, often 20–30% less memory. In Docker: install libjemalloc2, set ENV LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2.
  • autotuner gem — analyzes GC behavior in production and suggests RUBY_GC_* tuning; call Autotuner.report from config/puma.rb in production.
  • thruster — lightweight HTTP/2 proxy in front of Puma (CMD ["./bin/thrust", "./bin/rails", "server"]): HTTP/2, SSL termination, gzip, X-Sendfile. See [[rails-project-setup]].

16. Profiling Hotspots with Stackprof

When a request or job is slow and the N+1 tools (§8) come up clean, the cost is in Ruby — profile where before optimising. stackprof is the sampling profiler; pair it with Speedscope for a flamegraph.

# Gemfile
group :development do
  gem "stackprof"
end
# Wrap a hot block — a slow action, a report builder, a job's perform
StackProf.run(mode: :wall, out: "tmp/stackprof.dump", raw: true) do
  ExpensiveReport.new(account).generate
end
stackprof tmp/stackprof.dump --text     # top frames by time
stackprof tmp/stackprof.dump --method 'ExpensiveReport#generate'   # drill into one
# or open the dump at https://www.speedscope.app/ (Sandwich view ranks frames by total time)

For continuous in-page timing during development, rack-mini-profiler shows per-request SQL and render cost inline. To profile the test suite rather than the app, use the same tool via [[rails-testing]] §14. Either way: measure, fix the top frame, re-measure — never optimise a frame you didn't see at the top.


Quick Reference

PatternWhen to Use
<% cache record do %>Cache expensive view fragments
cached: trueCache collection partials (single read_multi)
json.cache!Cache JSON responses
One fragment per rowReplace granular Rails.cache.fetch calls ("fast N+1")
fresh_when etag: / etag { "v1" }HTTP caching with ETags
scope :preloadedPrevent N+1 queries
prosopiteDetect N+1s, fail the suite on them
includes(user: :roles) at session loadPreload per-request permission checks
find_eachProcess large collections
geared_paginationCursor-based pagination
expires_in 30.seconds, public: trueCDN/browser caching for public pages
connected_to(role: :reading)Route GETs / read-only jobs to replicas
StackProf.run(mode: :wall) { … }Profile a slow request/job; read with stackprof --text or Speedscope
Timeouts + rescue → degraded 200Keep search/autocomplete failures from becoming outages
YJIT / jemalloc / autotuner / thrusterRuntime-level wins (memory trade-offs noted above)

What ships with it

Read from the repository

Just SKILL.md. No reference files, no scripts.

Keep looking

Skills are one crate of 326,861. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.