Ruby GC Tuning and Memory Profiling

· 6 min read · Updated March 20, 2026 · intermediate
garbage-collection memory performance profiling ruby

Ruby’s garbage collector keeps your application running without you manually freeing memory. But when it runs at the wrong time or too frequently, it causes latency spikes. This guide shows you how to measure GC behavior, tune the collector, and profile memory usage in your Ruby applications.

How Ruby’s GC Works

Ruby uses a generational mark-and-sweep garbage collector, often called RGenGC. Objects start in the “new” generation. After surviving three minor GC cycles, they move to the “old” generation.

Minor GC only walks new objects — it’s fast. Major GC walks the entire heap, including old objects — it’s expensive. The goal of tuning is to keep objects in the new generation when possible and reduce how often major GC runs.

Inspecting GC with GC.stat

GC.stat returns a hash with current GC metrics. This is your primary tool for understanding what’s happening.

stats = GC.stat
puts stats[:major_gc_count]   # Total major GCs since process start
puts stats[:minor_gc_count]    # Total minor GCs since process start
puts stats[:heap_live_slots]   # Objects currently in use
puts stats[:heap_free_slots]  # Empty slots in the heap
puts stats[:old_objects]       # Objects in the old generation

A high heap_free_slots value — above 250,000 — signals memory bloat. This typically happens with bursty allocation patterns where the heap grows permanently to accommodate peak demand but never shrinks back.

Measuring GC Time

Ruby 3.1 and Later

Ruby 3.1 introduced GC.total_time, which returns total GC time in nanoseconds as an Integer. It’s enabled by default.

start_time = GC.total_time

# ... your application code ...

end_time = GC.total_time
gc_time_ms = (end_time - start_time) / 1_000_000.0
puts "GC time: #{gc_time_ms.round(2)} ms"

Ruby 3.0 and Earlier

Ruby versions before 3.1 require the GC::Profiler module, which is disabled by default. You must enable it before collecting data.

GC::Profiler.enable

# ... your application code ...

gc_time_seconds = GC::Profiler.total_time
puts "GC time: #{gc_time_seconds} seconds"

Note the difference: GC.total_time returns nanoseconds as an Integer, while GC::Profiler.total_time returns seconds as a Float. Normalize these if you write version-agnostic code.

Profiling GC Between Operations

To isolate GC cost for a specific operation, capture counts and time before and after:

def profile_gc
  major_before = GC.stat[:major_gc_count]
  minor_before = GC.stat[:minor_gc_count]
  time_before  = GC.total_time

  result = yield

  major_after = GC.stat[:major_gc_count]
  minor_after = GC.stat[:minor_gc_count]
  time_after  = GC.total_time

  {
    major_gcs: major_after - major_before,
    minor_gcs: minor_after - minor_before,
    gc_time_ms: (time_after - time_before) / 1_000_000.0,
    result: result
  }
end

report = profile_gc { some_operation }
puts "Major GCs: #{report[:major_gcs]}, Minor GCs: #{report[:minor_gcs]}, Time: #{report[:gc_time_ms].round(2)} ms"

This pattern helps you identify which parts of your code trigger expensive GC cycles.

Manual GC Triggers

You can trigger garbage collection manually with GC.start. By default it runs a full major GC:

GC.start  # Runs major GC (all generations)

# Ruby 3.x: Run minor GC only
GC.start(full_mark: false)

Use GC.start(full_mark: false) when you specifically want to clean up short-lived objects without the cost of a full heap sweep.

Avoid calling GC.start in production hot paths. It’s useful for benchmarking to get consistent results, but in production let the auto-GC handle it.

Environment Variables for GC Tuning

All GC environment variables are read at process startup. Setting them at runtime via ENV[] has no effect.

Heap Growth Settings

VariableDefaultDescription
RUBY_GC_HEAP_INIT_SLOTS10000Initial heap slots at boot
RUBY_GC_HEAP_FREE_SLOTS4096Minimum free slots after GC
RUBY_GC_HEAP_GROWTH_FACTOR1.8Heap expansion multiplier
RUBY_GC_HEAP_GROWTH_MAX_SLOTS0Cap on slots added per growth (0 = unlimited)

The growth factor controls how much the heap expands when it needs more space. A higher value means bigger jumps but less frequent expansions. Set RUBY_GC_HEAP_GROWTH_MAX_SLOTS to limit the size of any single growth to prevent sudden large allocations.

Old Object Threshold

RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR (default 2.0) controls when major GC triggers based on old object count. When old objects exceed this multiple of the old object count after the last major GC, a new major GC starts.

Malloc Limits

Ruby triggers minor GC when malloc calls exceed a threshold. These settings control that threshold:

VariableDefaultDescription
RUBY_GC_MALLOC_LIMIT16 MBMinimum malloc bytes before minor GC
RUBY_GC_MALLOC_LIMIT_MAX32 MBMaximum malloc limit
RUBY_GC_MALLOC_LIMIT_GROWTH_FACTOR1.4Growth rate for malloc limit

For old objects, separate limits apply:

VariableDefaultDescription
RUBY_GC_OLDMALLOC_LIMIT16 MBMin malloc for old objects before major GC
RUBY_GC_OLDMALLOC_LIMIT_MAX128 MBMax oldmalloc limit
RUBY_GC_OLDMALLOC_LIMIT_GROWTH_FACTOR1.2Growth rate

Lower malloc limits trigger GC more frequently but with less work per cycle. Higher limits batch allocations but do more work when GC finally runs.

Memory Profiling Gems

memory_profiler

The memory_profiler gem reports on object allocations and memory usage:

require 'memory_profiler'

report = MemoryProfiler.report do
  # code you want to analyze
  your_code_here
end

report.pretty_print

It shows allocation counts by class, file, and line — useful for finding what’s creating the most objects.

stackprof

stackprof with mode: :gc profiles GC pause times at the C level:

require 'stackprof'

StackProf.run(mode: :gc, out: 'tmp/gc_profile.dump') do
  # code to profile
end

Use this when you suspect GC is causing latency issues but GC.stat doesn’t give enough detail.

tunemygc

tunemygc is a commercial SaaS service that automatically analyzes your application and suggests optimal GC environment variable values. You point it at your running app and it generates recommended settings based on real traffic patterns.

Common Gotchas

GC.start does not run minor GC by default. It runs a full major GC. Use GC.start(full_mark: false) if you specifically need minor collection only.

GC.total_time vs GC::Profiler.total_time. The first returns nanoseconds as an Integer (Ruby 3.1+), the second returns seconds as a Float (Ruby < 3.1). Always normalize when comparing across Ruby versions.

GC::Profiler is disabled by default in Ruby versions before 3.1. Call GC::Profiler.enable before using it.

heap_sorted_length is not the same as heap_allocated_pages. The former is the total number of heap slots (allocated and free combined); the latter is the number of heap pages. Don’t confuse them when analyzing memory usage.

High heap_free_slots indicates bloat. Values above 250,000 usually mean your application has a bursty allocation pattern that’s permanently expanded the heap.

Environment variables must be set before the process starts. Changing them at runtime via ENV[] has no effect on the running process.

Major GC is triggered by old object accumulation, not just malloc limits. Even with high malloc limits, old objects filling up will force major GC cycles.

Ruby Version Compatibility

VersionFeatures
Ruby 2.1+Modern generational GC with all environment variables
Ruby 3.0+GC.start(full_mark: false) for minor-only collection
Ruby 3.1+GC.total_time in nanoseconds, enabled by default
Ruby 3.2+Incremental GC improvements

Where to Start

If you’re seeing latency issues in production, measure first. Add GC profiling to your request pipeline and look for requests that trigger multiple major GCs. Then identify what’s allocating those objects and consider whether you can reduce allocations or keep objects shorter-lived.

If you’re using a PaaS or containerized deployment, set GC environment variables in your startup script or Dockerfile. Test changes in staging with representative traffic before pushing to production.

See Also