Introduction
Ruby's garbage collector uses RUBY_GC_HEAP_GROWTH_FACTOR to determine how aggressively the heap grows when more memory is needed. The default value of 1.8 means the heap grows by 80% each time it fills. A value too high wastes memory; a value too low triggers frequent GC cycles causing latency spikes. In production Rails apps with Puma, improper GC tuning directly impacts response times and memory footprint.
Symptoms
- RSS memory grows rapidly beyond expected baseline
- Frequent GC pauses visible in application latency metrics
- Puma workers consuming 500MB+ each in production
- Response time P99 spikes correlate with GC runs
- Application killed by OOM killer despite moderate traffic
Check GC statistics:
``ruby
# In Rails console
GC.stat
# => {
# :count=>42,
# :heap_allocated_pages=>12453,
# :heap_sorted_length=>12453,
# :heap_allocatable_pages=>0,
# :heap_available_slots=>5063760,
# :heap_live_slots=>3891234,
# :heap_free_slots=>1172526,
# :heap_finalizing_slots=>0,
# :heap_tomb_pages=>0,
# :total_allocated_objects=>15234567,
# :total_freed_objects=>11343333,
# :malloc_allocated_memory=>234567890,
# :malloc_allocated_peak=>345678901
# }
Common Causes
- Default growth factor (1.8) too aggressive for long-running Puma processes
- Heap never stabilizes, continuously growing with each GC cycle
- No memory limit enforcement at the process level
- Large object allocations (file uploads, JSON parsing) triggering heap growth
- GC not running frequently enough due to high growth factor
Step-by-Step Fix
- 1.Configure GC environment variables for Puma:
- 2.```bash
- 3.# In Procfile, systemd service, or Dockerfile
- 4.# Reduce heap growth from 1.8 to 1.3 for more conservative growth
- 5.export RUBY_GC_HEAP_GROWTH_FACTOR=1.3
# Set minimum heap size to avoid early growth export RUBY_GC_HEAP_INIT_SLOTS=1000000
# Trigger GC when 40% of slots are free (more aggressive) export RUBY_GC_HEAP_FREE_SLOTS=400000
# Run GC after allocating this many objects export RUBY_GC_MALLOC_MALLOC_LIMIT=16777216 export RUBY_GC_MALLOC_MALLOC_LIMIT_MAX=33554432 ```
- 1.Verify GC settings in running process:
- 2.```bash
- 3.# Check current GC configuration
- 4.ruby -e 'puts GC.respond_to?(:compact) ? "Ruby 2.7+ with GC compaction" : "Older Ruby"'
- 5.ruby -e 'pp GC.stat.take(10)'
# Monitor GC in real-time ruby -e ' loop do stat = GC.stat puts "GC count: #{stat[:count]}, Live: #{stat[:heap_live_slots]}, Free: #{stat[:heap_free_slots]}" sleep 5 end ' ```
- 1.Add GC monitoring middleware:
- 2.```ruby
- 3.# lib/middleware/gc_monitor.rb
- 4.class GcMonitor
- 5.def initialize(app)
- 6.@app = app
- 7.end
def call(env) before = GC.stat status, headers, body = @app.call(env) after = GC.stat
# Log if GC ran during request if after[:count] > before[:count] Rails.logger.info( "GC triggered during #{env['REQUEST_PATH']}: " \ "count=#{after[:count]}, live=#{after[:heap_live_slots]}" ) end
[status, headers, body] end end
# config/application.rb config.middleware.use GcMonitor ```
- 1.Tune for Puma worker memory limits:
- 2.```ruby
- 3.# config/puma.rb
- 4.# Restart workers when they exceed 500MB
- 5.max_memory = 500 * 1024 * 1024 # 500MB
before_fork do # Reduce memory before fork (copy-on-write friendly) GC.enable if ENV["RUBY_GC_BEFORE_FORK"] == "true" GC.start(full_mark: true, immediate_sweep: true) end
on_worker_boot do # Set worker-specific GC params if ENV["RUBY_GC_HEAP_GROWTH_FACTOR"].nil? ENV["RUBY_GC_HEAP_GROWTH_FACTOR"] = "1.3" end end ```
- 1.Use GC.compact for Ruby 2.7+ to reduce fragmentation:
- 2.```ruby
- 3.# In a periodic background job
- 4.class GcCompactJob < ApplicationJob
- 5.queue_as :low_priority
def perform return unless GC.respond_to?(:compact)
before = GC.stat(:total_allocated_objects) GC.compact after = GC.stat(:total_allocated_objects)
Rails.logger.info( "GC compact completed. Allocated objects: #{before} -> #{after}" ) end end
# Run every hour via cron or Sidekiq-Cron ```
Prevention
- Monitor RSS memory per Puma worker with application metrics
- Set
RUBY_GC_HEAP_GROWTH_FACTOR=1.3as a production baseline - Use
GC.verify_compaction_referencesin staging to test GC behavior - Run load tests with different GC configurations before deploying
- Consider using jemalloc for better memory allocation performance
- Set memory-based worker recycling in Puma to prevent runaway growth