7 Ruby Techniques for Profiling and Reducing Memory Footprint
Memory issues sneak up on Ruby projects. One day your app runs fine, the next it’s choking on 2GB RSS. I’ve spent nights wrestling memory leaks in production. These techniques saved our Rails monolith from becoming unmanageable.
Allocation tracing reveals creation hotspots
Start with ObjectSpace
before reaching for gems. The built-in tools show object birth locations. I use this when memory balloons unexpectedly.
# Track object origins in critical code blocks
ObjectSpace.trace_object_allocations do
process_order_reports # Suspect method
end
# Generate heap dump for later analysis
File.open("heap_dump.json", "w") do |f|
ObjectSpace.dump_all(output: f)
end
Dump analysis shows retained objects. Look for unexpectedly numerous String or Array instances. I once found 400,000 duplicate currency symbol strings this way. The heapy
gem helps parse these dumps.
String optimization cuts duplication
Immutable strings waste memory when duplicated. Freeze constants and frequent values. Symbols help but don’t overuse them - they’re not garbage collected.
# Before: Duplicate strings everywhere
VALID_STATUSES = ["pending", "approved", "rejected"]
# After: Frozen array with frozen elements
VALID_STATUSES = ["pending".freeze, "approved".freeze, "rejected".freeze].freeze
# For dynamic strings, reuse buffers
buffer = String.new(capacity: 1024)
json_records.each do |record|
buffer << record.to_json
buffer << "\n"
end
output.write(buffer)
buffer.clear
I’ve seen 40% memory reduction in CSV generators using buffers. Remember: str.freeze
prevents future duplication but doesn’t deduplicate existing strings.
Lazy loading delays resource consumption
Initialize heavy resources only when needed. Combine with weak references for cache-like behavior.
class GeoData
def countries
@countries ||= load_countries
end
private
def load_countries
# 50MB dataset
JSON.parse(File.read("countries.json"))
end
end
# Weak reference example
require 'weakref'
cache = WeakRef.new({})
def fetch_user(id)
cache[id] ||= User.find(id)
rescue WeakRef::RefError
retry # Reference was GC'd, try again
end
Use weak references cautiously. I prefer them for transient caches rather than core data. They’re not available in all Ruby implementations.
GC tuning adjusts Ruby’s memory behavior
Modern Rubies have generational GC. Tune it based on your application’s object lifetime patterns.
# production.rb
Rails.application.configure do
# Enable compaction to reduce fragmentation
GC.auto_compact = true
# Adjust based on your object lifetime
if ENV["RAILS_ENV"] == "production"
# Longer living objects? Increase old gen growth
GC::Profiler.enable
GC.interval_ratio = 20
end
end
Profile first with GC.stat
before changing settings. I’ve caused major performance regressions by misconfiguring GC. The gc_tuner
gem helps find optimal settings.
Efficient collections reduce overhead
Standard collections can be memory-hungry. Specialized types save space for large datasets.
# Identity comparison avoids string duplication
unique_lines = Set.new.compare_by_identity
large_file.each_line { |line| unique_lines.add(line) }
# Structs beat full classes for data containers
CustomerData = Struct.new(:id, :name, :email, keyword_init: true)
# Sparse arrays save memory
require 'sparse_array'
matrix = SparseArray.new(100_000_000)
matrix[42] = "value" # Only stores index 42
For a 10-million element CSV processor, switching to Structs saved 300MB. The array_pack
gem offers memory-efficient numeric arrays.
Dependency pruning removes weight
Gems silently bloat memory. Audit what you actually use.
# Gemfile
gem "aws-sdk-s3", require: false # Only load when needed
# Initializer
require "aws-sdk-s3" if ENV["S3_ENABLED"]
# Application code
def image_processor
@image_processor ||=
if ENV["S3_ENABLED"]
AwsS3Processor.new
else
LocalFileProcessor.new
end
end
Run bundle clean --force
regularly. I replaced nokogiri
with ox
in XML-heavy services, cutting memory by 25%. The derailed_benchmarks
gem identifies heavy dependencies.
Native memory management handles external resources
When Ruby’s GC isn’t enough, manage memory manually.
# Free native memory promptly
class ImageProcessor
def initialize
@pointer = FFI::MemoryPointer.new(:uchar, 1024**3) # 1GB buffer
end
def process
# Use C extension with buffer
end
def release
@pointer.free
end
end
# Using finalizers as safety net
ObjectSpace.define_finalizer(self, proc { @pointer.free })
I use this for image/video processing. Always pair manual management with robust error handling. Forgotten native allocations cause the worst leaks.
Profiling memory requires methodical work. Start with memory_profiler
gem snapshots. Compare memory before/after suspect operations. The get_process_mem
gem tracks RSS growth during tests. I’ve caught leaks by asserting memory boundaries in specs.
Memory optimization balances tradeoffs. Frozen strings help but reduce flexibility. Native code is fast but risks crashes. Measure twice before optimizing. Your future self will thank you during that 3AM outage.