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=2in 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.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀