/ Tags: RUBY 3 / Categories: RUBY

ObjectSpace and Memory Profiling — Finding Leaks Before Production Does

A Puma worker starts at 180MB and sits at 1.4GB by the end of the day. Restarting fixes it, so someone adds a memory-based worker killer and the problem becomes invisible rather than solved. Ruby processes that grow without bound are almost never leaking in the C sense — they’re holding references to objects that should have been collectable, and ObjectSpace will tell you which ones if you ask it the right way.

Bloat Is Not a Leak


Two different problems get called “memory leak” and they need different fixes.

Memory bloat is a process that allocates a lot at peak and never returns it to the OS. Ruby’s heap grows to accommodate the high-water mark and stays there, because the allocator rarely gives pages back. A job that loads 200k records once will permanently expand the worker even though the objects are long gone.

A genuine retention leak is objects that stay reachable when they shouldn’t. A cache with no bound, a class-level array that gets appended to per request, an observer registry nobody unsubscribes from. These grow monotonically.

The distinction matters because the diagnosis differs: bloat shows a sawtooth pattern that plateaus, retention shows a straight line that never plateaus. Check GC.stat before you go hunting.

Example:

GC.stat.slice(
  :heap_live_slots,          # objects currently alive
  :heap_free_slots,          # allocated but unused
  :total_allocated_objects,  # cumulative, since boot
  :total_freed_objects,
  :major_gc_count,
  :minor_gc_count
)

If heap_live_slots climbs steadily across full GCs, you have retention. If it’s flat and RSS is high, you have bloat.

Counting What’s Alive


ObjectSpace.count_objects is cheap and gives a shape.

Example:

require "objspace"

GC.start(full_mark: true, immediate_sweep: true)
ObjectSpace.count_objects
# => { :TOTAL=>1_204_000, :FREE=>320_100, :T_OBJECT=>84_200,
#      :T_STRING=>412_800, :T_ARRAY=>96_400, :T_HASH=>38_900, ... }

For anything actionable you want counts by class, not by VM type:

Example:

def objects_by_class(limit: 20)
  GC.start(full_mark: true, immediate_sweep: true)

  counts = Hash.new(0)
  ObjectSpace.each_object { |obj| counts[obj.class] += 1 }
  counts.sort_by { |_, c| -c }.first(limit).to_h
rescue NoMethodError
  # some objects raise on #class (BasicObject subclasses)
  counts
end

ObjectSpace.each_object walks the entire heap and is slow — seconds on a large process. It’s a development and staging tool, not something to call in a request.

The Diff That Finds the Culprit


Absolute counts tell you little. The growth between two points tells you everything.

Example:

require "objspace"

def snapshot
  GC.start(full_mark: true, immediate_sweep: true)
  counts = Hash.new(0)
  ObjectSpace.each_object { |o| counts[o.class.to_s] += 1 rescue nil }
  counts
end

before = snapshot
500.times { simulate_request }
after = snapshot

after.map { |klass, count| [klass, count - before.fetch(klass, 0)] }
     .reject { |_, delta| delta < 100 }
     .sort_by { |_, delta| -delta }
     .first(15)
     .each { |klass, delta| puts "#{delta.to_s.rjust(8)}  #{klass}" }

Output:

   50124  String
   25060  MyApp::AuditEntry
   25060  Hash
    2004  Proc

25,060 AuditEntry objects retained after 500 iterations is 50 per request that never get freed. That’s your leak, and the class name usually points straight at the code.

Run the workload twice before taking the “before” snapshot — the first pass warms caches, loads classes, and allocates constants that legitimately stay. Without that warmup you’ll chase one-time allocations.

memory_profiler for Allocation Sites


Counts tell you what. memory_profiler tells you where.

Setup:

gem install memory_profiler

Example:

require "memory_profiler"

report = MemoryProfiler.report do
  InvoiceBuilder.new(account).build
end

report.pretty_print(to_file: "tmp/memory_report.txt", scale_bytes: true)

Output:

Total allocated: 42.11 MB (412094 objects)
Total retained:   3.24 MB (18402 objects)

retained memory by gem
-----------------------------------
   2.10 MB  activerecord-7.1.3
   0.88 MB  myapp/app

retained objects by location
-----------------------------------
   18000  app/services/invoice_builder.rb:47
     402  app/models/line_item.rb:22

Retained is the column that matters for leaks — objects still reachable when the block finished. Allocated matters for GC pressure and speed. A method can allocate 40MB and retain nothing, which is a performance problem but not a leak.

invoice_builder.rb:47 with 18,000 retained objects is a specific line you can go read.

Tracing Allocations With Source Locations


ObjectSpace.trace_object_allocations records where each object was created, which lets you interrogate individual objects.

Example:

require "objspace"

ObjectSpace.trace_object_allocations_start

run_workload

GC.start(full_mark: true, immediate_sweep: true)

ObjectSpace.each_object(String).first(1000).group_by do |str|
  file = ObjectSpace.allocation_sourcefile(str)
  line = ObjectSpace.allocation_sourceline(str)
  file ? "#{file}:#{line}" : "unknown"
end.transform_values(&:count)
   .sort_by { |_, c| -c }
   .first(10)

ObjectSpace.trace_object_allocations_stop

Tracing carries real overhead — roughly doubling allocation cost — so scope it to the block you’re investigating with trace_object_allocations { }.

For a full heap dump you can analyse offline:

Example:

require "objspace"

ObjectSpace.trace_object_allocations_start
run_workload
GC.start(full_mark: true)

File.open("tmp/heap.json", "w") { |f| ObjectSpace.dump_all(output: f) }

The dump is newline-delimited JSON, one object per line, with type, size, references, and allocation site. Tools like heapy read it directly, and three dumps taken at intervals let you find objects present in all three — the definition of retained.

The Usual Suspects


In Rails applications, retention leaks cluster around a small number of patterns.

1. Class-level mutable state
class ReportRegistry
  @@reports = []           # grows forever
  def self.register(r) = @@reports << r
end

Anything at class level survives the request. Constants holding mutable collections are the same problem with a capital letter.

2. Unbounded memoization
def self.rate_for(currency)
  @rates ||= {}
  @rates[currency] ||= fetch_rate(currency)
end

Fine with five currencies. A leak when the key is a user ID.

3. Blocks capturing large scopes
def schedule(records)
  big_local = records.to_a          # 200k objects
  @callbacks << -> { puts "done" }  # captures the whole binding, including big_local
end

A Proc retains its entire enclosing binding, not just the variables it mentions. Storing procs long-term is a common and non-obvious retainer.

4. Global observers and subscriptions

ActiveSupport::Notifications.subscribe inside a method that runs per request adds a subscriber every time, and each holds a block, and each block holds a binding.

5. Thread-local storage on a thread pool

Thread.current[:something] on a Puma thread persists across requests, because the thread does. Use ActiveSupport::CurrentAttributes, which Rails resets between requests, or clear it explicitly.

Pro-Tip: Before profiling anything, check whether you have a fragmentation problem rather than a retention problem — the fix is completely different and much cheaper. Set MALLOC_ARENA_MAX=2 in the environment and restart. glibc creates up to eight arenas per thread by default, and a multi-threaded Puma worker can hold hundreds of megabytes of unusable fragmented space across them. This one environment variable routinely cuts RSS by 20–40% on threaded Ruby servers and costs a small amount of allocator contention. If your memory graph drops after setting it, you never had a leak — you had glibc doing what glibc does. Test it before spending a day with heap dumps.

Watching It in Production


You don’t need a profiler running to detect the trend. Log the numbers.

Example:

# config/initializers/memory_logging.rb
if Rails.env.production?
  Thread.new do
    loop do
      sleep 60
      rss_kb = File.read("/proc/self/status")[/VmRSS:\s+(\d+)/, 1].to_i
      Rails.logger.info(
        "memory rss_mb=#{rss_kb / 1024} " \
        "live_slots=#{GC.stat(:heap_live_slots)} " \
        "major_gc=#{GC.stat(:major_gc_count)}"
      )
    end
  end
end

Graph live_slots across major GCs. A flat line with a high RSS is bloat — tune the allocator and the peak workload. A line that climbs and never comes down is retention — go find it with the diff technique above.

Conclusion


Start by classifying the problem: GC.stat(:heap_live_slots) across full GCs distinguishes retention from bloat, and setting MALLOC_ARENA_MAX=2 rules out allocator fragmentation for the cost of a restart. For genuine retention, the diff-two-snapshots technique names the leaking class in a few minutes, and memory_profiler’s retained column names the line. Most Ruby leaks turn out to be one of five patterns — class-level state, unbounded memoization, captured bindings in stored procs, accumulating subscribers, or thread-locals on a pooled thread — and knowing that list often gets you there faster than the tools do.

FAQs


Q1: Does GC.start guarantee everything collectable is freed?
GC.start(full_mark: true, immediate_sweep: true) runs a full collection, which is as close as you get. Objects can still be retained by C extensions or the VM in ways a mark-sweep won’t reveal, but for application-level leaks it’s sufficient.

Q2: Why does RSS stay high after objects are freed?
Ruby’s heap pages are returned to the OS only under specific conditions, and glibc’s allocator holds freed memory in its arenas for reuse. Freed objects reduce heap_live_slots without reducing RSS — that’s normal and is why you must measure both.

Q3: Is ObjectSpace.each_object safe to call in production?
It walks the entire heap and can pause the process for seconds. Never in a request path. On a canary instance under low traffic, with a plan, it’s acceptable.

Q4: What’s the difference between memory_profiler and derailed_benchmarks?
memory_profiler measures a block you specify. derailed_benchmarks wraps it with Rails-aware harnesses — booting the app, hitting an endpoint repeatedly, measuring per-gem memory at require time. Use derailed to find which endpoint, memory_profiler to find which line.

Q5: Can jemalloc replace the MALLOC_ARENA_MAX tweak?
Yes, and it’s usually better — jemalloc fragments far less than glibc on threaded Ruby workloads and typically outperforms the arena limit. It requires building or preloading it, so the environment variable is the zero-effort first test.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More