rails-performance
Skill2 starsChanged 8 days agoMITView on GitHub
Use when optimizing performance with caching, ETags, batching, and N+1 prevention
In this repository24 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- rails-activestorageSkill
- rails-apiSkill
- rails-auditSkill
- rails-clean-test-outputSkill
- rails-controllersSkill
- rails-coreSkill
- rails-database-performanceSkill
- rails-i18nSkill
- rails-jobsSkill
- rails-loggingSkill
- rails-mailersSkill
- rails-migrationsSkill
- rails-modelsSkill
- rails-multi-tenancySkill
- rails-philosophySkill
- rails-project-setupSkill
- rails-securitySkill
- rails-stimulusSkill
- rails-styleSkill
- rails-testingSkill
- rails-turboSkill
- rails-upgradeSkill
- rails-viewcomponentsSkill
The file
We split this skill into the rules it gives an agent
12 rules. About 0 are common, 3 are distinctive, and 9 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈3
Distinctive · estimate
This author's own choices. Worth a look.
9
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 41
Use json.cache! in Jbuilder templates:
- Distinctive: line 77
Only cache when regeneration costs meaningfully more than the ~1ms cache round-trip.
- Distinctive: line 111
Add global ETag components in ApplicationController so all ETags change when shared elements change:
- Very specific: line 124
Create a named preloaded scope per model that preloads everything its standard rendering touches.
- Very specific: line 148
Use preload for separate queries, includes when you need to filter on the association.
- Very specific: line 149
Use with_rich_text_*_and_embeds for ActionText (see [[rails-activestorage]] for attachment preload names).
- Very specific: line 211
Only preload what authorization touches every request — not a license to includes the world.
- Very specific: line 217
Use find_each/in_batches for large collections.
- Distinctive: line 308
Only route work where slightly stale data is acceptable.
- Very specific: line 335
Put a hard LIMIT on range reads.
- Very specific: line 337
Add a circuit breaker (e.g.
- Very specific: line 353
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.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
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
end4. 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
end6. 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
end7. 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.publishedKey Points:
- Name the scope
preloadedfor consistency. - Use
preloadfor separate queries,includeswhen you need to filter on the association. - Use
with_rich_text_*_and_embedsfor 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:
| Method | Query strategy | Reach for it when |
preload | One extra query per association | Default. You read the association but don't filter/sort by it (the preloaded scope). |
includes | Rails auto-picks preload or a LEFT JOIN | You filter/sort on the association and want Rails to decide (pair with references when you reference it in where). |
eager_load | Forces one LEFT OUTER JOIN | You filter/sort on the association and want to force the single-query form includes would otherwise leave to chance. |
joins | INNER JOIN, loads nothing | You 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
endRolling 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
endKey Points:
- Route permission checks through one method (
has_role?(name)) that works on the loaded collection, instead ofroles.where(...)calls that bypass the preload. - Only preload what authorization touches every request — not a license to
includesthe 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 1211. 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
end13. 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::SessionGET/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)
endDuplicate 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
endKey Points:
- Put a hard
LIMITon range reads. An autocomplete that runszrevrangebyscoreover thousands of entries and truncates to 15 in Ruby should passlimit: [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, setENV LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2. - autotuner gem — analyzes GC behavior in production and suggests
RUBY_GC_*tuning; callAutotuner.reportfromconfig/puma.rbin 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
endstackprof 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
| Pattern | When to Use |
<% cache record do %> | Cache expensive view fragments |
cached: true | Cache collection partials (single read_multi) |
json.cache! | Cache JSON responses |
| One fragment per row | Replace granular Rails.cache.fetch calls ("fast N+1") |
fresh_when etag: / etag { "v1" } | HTTP caching with ETags |
scope :preloaded | Prevent N+1 queries |
prosopite | Detect N+1s, fail the suite on them |
includes(user: :roles) at session load | Preload per-request permission checks |
find_each | Process large collections |
geared_pagination | Cursor-based pagination |
expires_in 30.seconds, public: true | CDN/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 200 | Keep search/autocomplete failures from becoming outages |
| YJIT / jemalloc / autotuner / thruster | Runtime-level wins (memory trade-offs noted above) |
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

