rubyguides

Ruby GC Tuning and Memory Profiling

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.

TL;DR

Start by measuring GC behavior before you change any settings. GC.stat tells you what the collector has been doing, GC.total_time or GC::Profiler tell you how much time it is taking, and environment variables let you shift the balance between memory use and pause time. The right setting depends on workload, so the goal is to understand the shape of your allocations first.

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.

That is the sort of signal you should watch for before changing any defaults. A large heap does not automatically mean there is a bug, but it does tell you the process is holding on to more room than it needs. Once you know that, you can decide whether the fix is fewer allocations, shorter-lived objects, or a different GC threshold.

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, and the timing values it reports are in seconds as a Float rather than nanoseconds. The API is slightly more verbose because you call GC::Profiler.enable once and then query GC::Profiler.total_time after your code runs:

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.

Picking the right timer matters because the numbers need to be compared in the same unit. If you are comparing a few requests or a short benchmark, even a small unit mismatch can make the result hard to trust. Converting to one format up front keeps the rest of the analysis simple.

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.

Once you have a before-and-after snapshot, the next step is to look for allocations that are easy to reduce. Sometimes that means reusing buffers or avoiding unnecessary intermediate arrays. Sometimes it means changing a data structure so short-lived objects do not fill the heap in the first place. The report does not solve the issue for you, but it does point to the place where the cost starts.

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.

Manual collection is best treated as a measurement aid, not as an everyday fix. If you find yourself calling GC.start often in application code, that is usually a sign the allocation pattern needs attention instead. The collector should help the program recover, not become part of the normal request path.

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.

These environment variables are useful because they let you test a theory without changing the application code itself. That makes them a good fit for production experiments and performance investigations. If a setting helps, you can keep it; if it hurts, you can back it out without touching the codebase.

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. The output includes both the total number of allocations and the retained memory, so you can distinguish between transient allocations that get cleaned up quickly and long-lived objects that stick around. This distinction matters in practice because fixing a transient allocation problem requires a different strategy than fixing a memory leak.

stackprof

stackprof with mode: :gc profiles GC pause times at the C level, giving you a call-stack view of where garbage collection is spending its time. This helps when GC.stat tells you that GC is happening but you need to know exactly which allocation sites are triggering it:

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.

The key idea across all of these tools is the same: measure the shape of the problem before you change the shape of the runtime. GC tuning is about tradeoffs, not miracles. If a workload allocates a lot, the collector will work harder. The art is deciding whether to reduce allocations, change the thresholds, or accept a small pause in exchange for a simpler design.

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