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. 1.Configure GC environment variables for Puma:
  2. 2.```bash
  3. 3.# In Procfile, systemd service, or Dockerfile
  4. 4.# Reduce heap growth from 1.8 to 1.3 for more conservative growth
  5. 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. 1.Verify GC settings in running process:
  2. 2.```bash
  3. 3.# Check current GC configuration
  4. 4.ruby -e 'puts GC.respond_to?(:compact) ? "Ruby 2.7+ with GC compaction" : "Older Ruby"'
  5. 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. 1.Add GC monitoring middleware:
  2. 2.```ruby
  3. 3.# lib/middleware/gc_monitor.rb
  4. 4.class GcMonitor
  5. 5.def initialize(app)
  6. 6.@app = app
  7. 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. 1.Tune for Puma worker memory limits:
  2. 2.```ruby
  3. 3.# config/puma.rb
  4. 4.# Restart workers when they exceed 500MB
  5. 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. 1.Use GC.compact for Ruby 2.7+ to reduce fragmentation:
  2. 2.```ruby
  3. 3.# In a periodic background job
  4. 4.class GcCompactJob < ApplicationJob
  5. 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.3 as a production baseline
  • Use GC.verify_compaction_references in 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