Performance First Rails – Lessons From Production
Performance First Rails – Lessons From Production
This post is a work in progress. I’ll keep expanding and refining it over time.
Most Rails performance problems aren’t about Ruby. They’re about data: how it’s accessed, serialized, cached, and moved through your system.
These are practices I’ve picked up from running Rails in production. Some are obvious, others less so.
🗃️ Data First, Always
Your database is the bottleneck long before Ruby is.
- Avoid N+1 queries. Yes, still.
- Use
exists?
instead ofcount > 0
. - Use
pluck
to avoid loading columns you don’t need. - Never call
.all
. Use pagination library or chunking with.in_batches
instead. - For massive tables, consider partitioning especially for time-based data.
Indexing
- Composite indexes should reflect your actual query filters and sort order.
- Use partial indexes to address query skew and workload imbalances. If a single tenant generates the majority of reads on a table, create a targeted index for just their slice of data. This avoids full-table indexes and keeps read performance fast for high-traffic accounts.
- Too many indexes will slow down inserts and updates—index only what you read often.
- Monitor slow queries and experiment with different indexes.
🟥 Redis
Redis gets abused and forgotten.
For caching Redis, reduce how often data is written to disk. Optimize save
and appendonly
settings.
If you overload a single Redis instance, you’ll experience weird latency spikes.
- Split workloads:
- One Redis for jobs (Sidekiq)
- One for caching (Rails.cache)
- One for ephemeral features (rate limiting, feature flags, etc.)
🦵 Sidekiq Queues & Concurrency
- More queues = more polling, more complexity, more starvation risk.
- Sidekiq latency problems are often queue starvation or Redis slowness, not job logic.
- Tune concurrency to match job intensity. CPU-bound work needs fewer threads.
💾 Cache Aggressively
The less work your database has to do, the faster everything else gets.
- Use HTTP caching where applicable.
- Cache HTML fragments for common UI blocks.
- Cache expensive queries and API responses.
- Use
jsonapi-serializer
for fast, structured JSON. It’s composable and cache-aware. - Make sure you have TTL entries on your cache items.
🌊 Pool Sizes & Throughput
Puma, Sidekiq, Postgres, Redis—they all have pool settings. If they’re misaligned, you’ll see weird timeouts and throughput drops.
- Configure Postgresql and Redis’ connection pool size to
RAILS_MAX_THREADS
+ 1. - Higher thread and worker counts can overwhelm your database. Experiment.
- Monitor wait times in your connection pools. They’re early indicators of saturation.
🚀 Ruby & Gems
Ruby performance matters, but it’s not the first lever.
- Ruby 3.5 is faster. Use it.
- Enable YJIT. It’s real now.
- Use
jemalloc
. - Configure MALLOC_CONF to reduce memory fragmentation with jemalloc.
Before tuning code, audit your gems:
- Avoid gems that wrap everything in callbacks or monkey patches.
- Avoid gems with inefficient data access patterns—they don’t scale.
🛰️ Still to Come
- Memory pressure and tuning for large multi-tenant workloads.
- Strategies for load testing with
vegeta
. - Notes on replicas, analytics traffic, and write isolation.
- Horizontal scaling basics: how and why to split workloads across differently configured hosts.