Ruby memory management best practices for large applications
The Challenge #
Memory leaks killing your app’s performance? Watching your Rails server’s memory usage creep up until it crashes?
Our Approach #
Let’s master Ruby memory management and build apps that stay lean and fast
Have you ever deployed a Rails app that starts using 100MB of memory, only to find it consuming 2GB after a few days? Memory creep is one of the most insidious performance problems in Ruby applications. It starts small, grows slowly, and then suddenly your servers are crashing with out-of-memory errors.
The good news? Ruby memory issues follow predictable patterns, and there are proven techniques to prevent and fix them. Let’s dive into how Ruby manages memory and what you can do to keep your applications running efficiently.
Understanding Ruby’s memory model #
Before we can optimize memory usage, we need to understand how Ruby handles memory allocation and garbage collection.
How Ruby allocates memory #
Ruby uses several types of memory allocation that behave differently:
Ruby memory allocation types #
# Object allocation - creates new Ruby objects
user = User.new # Allocates memory for User object
users = User.all.to_a # Allocates memory for array and each user
# String allocation - strings are objects in Ruby
name = "John Doe" # Allocates memory for string
interpolated = "Hello #{name}" # Allocates new string
# Symbol allocation - symbols are never garbage collected
status = :active # Allocated once and kept forever
dynamic_symbol = params[:key].to_sym # DANGEROUS - can cause memory leaks
# Array and Hash allocation
data = [1, 2, 3, 4, 5] # Allocates array and references to integers
config = { host: 'localhost', port: 3000 } # Allocates hash
# Block and Proc allocation
callback = proc { |x| x * 2 } # Allocates memory for proc object
Ruby’s garbage collection basics #
Ruby uses a mark-and-sweep garbage collector with generational improvements:
Understanding GC behavior #
# Monitor garbage collection
GC.start # Force garbage collection
puts GC.stat # View GC statistics
# Check current memory usage
def memory_usage
`ps -o rss= -p #{Process.pid}`.to_i / 1024.0 # Memory in MB
end
puts "Memory usage: #{memory_usage}MB"
# Create temporary objects
1000.times { User.new }
puts "After object creation: #{memory_usage}MB"
GC.start
puts "After GC: #{memory_usage}MB"
# Key GC statistics to monitor:
gc_stats = GC.stat
puts "Total allocations: #{gc_stats[:total_allocated_objects]}"
puts "GC runs: #{gc_stats[:count]}"
puts "Heap pages: #{gc_stats[:heap_allocated_pages]}"
puts "Free slots: #{gc_stats[:heap_free_slots]}"
Memory generations and object lifecycle #
Ruby uses generational GC - newer objects are collected more frequently:
Object generations in Ruby #
# Short-lived objects (collected frequently)
def process_request
temp_data = JSON.parse(request.body) # Dies after method returns
result = transform_data(temp_data) # Dies after method returns
result.to_json # Dies after response sent
end
# Long-lived objects (collected less frequently)
class UserCache
@@cache = {} # Lives for application lifetime
def self.get(id)
@@cache[id] ||= User.find(id) # May live for hours/days
end
end
# Immortal objects (never collected)
CONSTANTS = { # Lives forever
api_version: '1.0',
max_retries: 3
}
# Check object generation
ObjectSpace.each_object(String) do |str|
puts "String: #{str[0..20]}... Generation: #{GC.generation(str)}"
end
💡 Tip: Use
GC.generation(object)
to see which generation an object belongs to. Generation 0 objects are newest and collected most frequently.
Common memory leak patterns #
Let’s identify and fix the most common memory leak patterns in Ruby applications.
Pattern 1: Symbol leaks from dynamic content #
Symbols are never garbage collected, making them dangerous when created from user input:
Symbol leak prevention #
# BAD: Creates unlimited symbols from user input
class PostsController < ApplicationController
def index
sort_by = params[:sort_by].to_sym # DANGER! Memory leak
@posts = Post.order(sort_by)
end
end
# GOOD: Use allowlist approach
class PostsController < ApplicationController
ALLOWED_SORT_FIELDS = %i[created_at updated_at title author].freeze
def index
sort_by = params[:sort_by]&.to_sym
if ALLOWED_SORT_FIELDS.include?(sort_by)
@posts = Post.order(sort_by)
else
@posts = Post.order(:created_at) # Safe default
end
end
end
# EVEN BETTER: Use string-based sorting
class PostsController < ApplicationController
ALLOWED_SORT_FIELDS = %w[created_at updated_at title author].freeze
def index
sort_by = params[:sort_by]
if ALLOWED_SORT_FIELDS.include?(sort_by)
@posts = Post.order(sort_by) # ActiveRecord handles strings fine
else
@posts = Post.order('created_at')
end
end
end
# Monitor symbol count
puts "Symbol count: #{Symbol.all_symbols.count}"
# Check for symbol leaks
symbols_before = Symbol.all_symbols.count
# ... run suspicious code ...
symbols_after = Symbol.all_symbols.count
puts "Symbols created: #{symbols_after - symbols_before}"
Pattern 2: Cached object accumulation #
Caches that grow unbounded will eventually consume all available memory:
Safe caching patterns #
# BAD: Unbounded cache growth
class UserCache
def self.cache
@cache ||= {}
end
def self.get(id)
cache[id] ||= User.find(id) # Cache grows forever
end
end
# GOOD: LRU cache with size limit
require 'lru_redux'
class UserCache
MAX_CACHE_SIZE = 1000
def self.cache
@cache ||= LruRedux::Cache.new(MAX_CACHE_SIZE)
end
def self.get(id)
cache.getset(id) { User.find(id) }
end
def self.clear
@cache = nil
end
end
# BETTER: Use Rails cache with TTL
class UserCache
def self.get(id)
Rails.cache.fetch("user_#{id}", expires_in: 1.hour) do
User.find(id)
end
end
end
# EVEN BETTER: Memory-aware caching
class MemoryAwareCache
MAX_MEMORY_MB = 50
def self.cache
@cache ||= {}
end
def self.get(key, &block)
# Check memory usage before caching
if current_memory_mb > MAX_MEMORY_MB
cache.clear
GC.start
end
cache[key] ||= block.call
end
def self.current_memory_mb
`ps -o rss= -p #{Process.pid}`.to_i / 1024.0
end
end
Pattern 3: Event listener and callback leaks #
Object references in callbacks can prevent garbage collection:
Callback memory leak prevention #
# BAD: Callback holds reference to large object
class DataProcessor
def initialize(large_dataset)
@large_dataset = large_dataset # Big object
setup_callbacks
end
private
def setup_callbacks
EventBus.subscribe('data_updated') do |event|
# This callback holds a reference to @large_dataset
process_update(event) # Memory leak!
end
end
def process_update(event)
# Process using @large_dataset
end
end
# GOOD: Explicit cleanup and weak references
class DataProcessor
def initialize(large_dataset)
@large_dataset = large_dataset
@subscription_id = setup_callbacks
end
def cleanup
EventBus.unsubscribe(@subscription_id)
@large_dataset = nil
end
private
def setup_callbacks
# Store only what you need, not the whole object
dataset_id = @large_dataset.id
EventBus.subscribe('data_updated') do |event|
# Load data fresh instead of holding reference
dataset = Dataset.find(dataset_id)
process_update(event, dataset)
end
end
end
# BETTER: Use weak references where available
require 'weakref'
class DataProcessor
def initialize(large_dataset)
@large_dataset_ref = WeakRef.new(large_dataset)
setup_callbacks
end
private
def setup_callbacks
weak_ref = @large_dataset_ref
EventBus.subscribe('data_updated') do |event|
begin
dataset = weak_ref.__getobj__
process_update(event, dataset)
rescue WeakRef::RefError
# Object was garbage collected, which is fine
Rails.logger.info "Dataset was garbage collected"
end
end
end
end
Pattern 4: String and array concatenation leaks #
Inefficient string and array operations can create memory pressure:
Efficient string and array operations #
# BAD: Creates many intermediate strings
def build_html(items)
html = ""
items.each do |item|
html += "<div>#{item.name}</div>" # Creates new string each time
end
html
end
# GOOD: Use StringIO or Array#join
def build_html(items)
items.map { |item| "<div>#{item.name}</div>" }.join
end
# BETTER: Use string interpolation efficiently
def build_html(items)
items.map { |item|
"<div>#{item.name}</div>"
}.join
end
# BEST: Use proper templating
def build_html(items)
render partial: 'item', collection: items
end
# BAD: Array concatenation in loop
def collect_data(sources)
result = []
sources.each do |source|
result += source.fetch_data # Creates new array each time
end
result
end
# GOOD: Use Array#concat or flatten
def collect_data(sources)
sources.flat_map(&:fetch_data)
end
# Or use Array#concat for in-place modification
def collect_data(sources)
result = []
sources.each do |source|
result.concat(source.fetch_data) # Modifies array in place
end
result
end
Profiling memory usage #
You can’t optimize what you don’t measure. Let’s set up comprehensive memory profiling.
Using memory_profiler gem #
The memory_profiler gem gives detailed insights into object allocation:
Memory profiling with memory_profiler #
# Gemfile
gem 'memory_profiler'
# Basic memory profiling
require 'memory_profiler'
report = MemoryProfiler.report do
# Code you want to profile
1000.times { User.new(name: "User #{rand(1000)}") }
end
report.pretty_print
# Save report to file for analysis
report.pretty_print(to_file: 'memory_report.txt')
# Profile specific methods
class UserService
def self.profile_batch_creation(count)
MemoryProfiler.report do
create_users_batch(count)
end
end
def self.create_users_batch(count)
users = []
count.times do |i|
users << User.create(
name: "User #{i}",
email: "user#{i}@example.com"
)
end
users
end
end
# Usage
report = UserService.profile_batch_creation(100)
puts "Total allocated: #{report.total_allocated_memsize} bytes"
puts "Total retained: #{report.total_retained_memsize} bytes"
# Analyze allocations by location
report.allocated_memory_by_file.each do |file, size|
puts "#{file}: #{size} bytes"
end
# Find the biggest memory users
report.allocated_memory_by_class.first(10).each do |klass, size|
puts "#{klass}: #{size} bytes"
end
Real-time memory monitoring #
Set up continuous memory monitoring in your Rails application:
Real-time memory monitoring #
# app/services/memory_monitor.rb
class MemoryMonitor
MEMORY_THRESHOLD_MB = 500
CHECK_INTERVAL = 30.seconds
def self.start
Thread.new do
loop do
check_memory_usage
sleep CHECK_INTERVAL
end
end
end
def self.check_memory_usage
current_memory = memory_usage_mb
Rails.logger.info "Memory usage: #{current_memory}MB"
if current_memory > MEMORY_THRESHOLD_MB
Rails.logger.warn "HIGH MEMORY USAGE: #{current_memory}MB"
log_memory_details
force_gc_if_needed(current_memory)
end
end
def self.memory_usage_mb
`ps -o rss= -p #{Process.pid}`.to_i / 1024.0
end
def self.log_memory_details
gc_stats = GC.stat
Rails.logger.info "GC Stats: #{gc_stats}"
Rails.logger.info "Object count: #{ObjectSpace.count_objects}"
# Log top object classes
object_counts = Hash.new(0)
ObjectSpace.each_object do |obj|
object_counts[obj.class] += 1
end
Rails.logger.info "Top object classes:"
object_counts.sort_by(&:last).last(10).reverse.each do |klass, count|
Rails.logger.info " #{klass}: #{count}"
end
end
def self.force_gc_if_needed(current_memory)
if current_memory > MEMORY_THRESHOLD_MB * 1.5
Rails.logger.info "Forcing garbage collection"
GC.start
new_memory = memory_usage_mb
Rails.logger.info "Memory after GC: #{new_memory}MB (freed #{current_memory - new_memory}MB)"
end
end
end
# config/initializers/memory_monitor.rb (for production)
if Rails.env.production?
Rails.application.config.after_initialize do
MemoryMonitor.start
end
end
# Middleware for per-request memory tracking
class MemoryTrackingMiddleware
def initialize(app)
@app = app
end
def call(env)
memory_before = memory_usage_mb
status, headers, response = @app.call(env)
memory_after = memory_usage_mb
memory_diff = memory_after - memory_before
if memory_diff > 10 # Log requests that use >10MB
Rails.logger.warn "High memory request: #{env['REQUEST_METHOD']} #{env['PATH_INFO']} used #{memory_diff}MB"
end
[status, headers, response]
end
private
def memory_usage_mb
`ps -o rss= -p #{Process.pid}`.to_i / 1024.0
end
end
# Add to application.rb
config.middleware.use MemoryTrackingMiddleware
Memory benchmarking #
Compare memory usage of different approaches:
Memory benchmarking techniques #
require 'benchmark/memory'
# Compare different approaches
Benchmark.memory do |x|
x.report("String concatenation") do
result = ""
1000.times { |i| result += "item #{i}" }
end
x.report("Array join") do
items = []
1000.times { |i| items << "item #{i}" }
items.join
end
x.report("String interpolation") do
(0...1000).map { |i| "item #{i}" }.join
end
x.compare!
end
# Benchmark different data loading strategies
Benchmark.memory do |x|
x.report("Load all at once") do
User.includes(:posts, :profile).limit(100).to_a
end
x.report("Load in batches") do
User.includes(:posts, :profile).limit(100).find_in_batches(batch_size: 20) do |batch|
batch.each { |user| user.posts.count }
end
end
x.report("Lazy loading") do
User.limit(100).find_each do |user|
user.posts.count
user.profile&.bio
end
end
x.compare!
end
# Custom memory benchmarking helper
module MemoryBenchmark
def self.compare(label, &block)
puts "Benchmarking: #{label}"
memory_before = memory_usage_mb
gc_before = GC.stat
result = block.call
GC.start # Force GC to see true memory usage
memory_after = memory_usage_mb
gc_after = GC.stat
puts " Memory: #{memory_before}MB -> #{memory_after}MB (#{(memory_after - memory_before).round(2)}MB diff)"
puts " Objects allocated: #{gc_after[:total_allocated_objects] - gc_before[:total_allocated_objects]}"
puts " GC runs: #{gc_after[:count] - gc_before[:count]}"
result
end
def self.memory_usage_mb
`ps -o rss= -p #{Process.pid}`.to_i / 1024.0
end
end
# Usage
result1 = MemoryBenchmark.compare("Inefficient approach") do
# Some memory-intensive code
end
result2 = MemoryBenchmark.compare("Optimized approach") do
# More efficient code
end
Garbage collection optimization #
Understanding and tuning Ruby’s garbage collector can significantly improve performance.
Tuning GC parameters #
Ruby’s GC behavior can be tuned via environment variables:
GC tuning for production #
# Environment variables for GC tuning
# Increase heap size to reduce GC frequency
export RUBY_GC_HEAP_INIT_SLOTS=1000000
export RUBY_GC_HEAP_FREE_SLOTS=200000
# Control heap growth
export RUBY_GC_HEAP_GROWTH_FACTOR=1.1
export RUBY_GC_HEAP_GROWTH_MAX_SLOTS=100000
# Control when GC runs
export RUBY_GC_MALLOC_LIMIT=16000000
export RUBY_GC_MALLOC_LIMIT_MAX=32000000
# Control old generation GC
export RUBY_GC_OLDMALLOC_LIMIT=16000000
export RUBY_GC_OLDMALLOC_LIMIT_MAX=128000000
# For memory-constrained environments (smaller heap)
export RUBY_GC_HEAP_INIT_SLOTS=100000
export RUBY_GC_HEAP_FREE_SLOTS=10000
export RUBY_GC_HEAP_GROWTH_FACTOR=1.05
Custom GC strategies #
Implement application-specific GC strategies:
Custom GC management #
# Smart GC triggering based on request patterns
class SmartGarbageCollector
def self.after_request(controller)
# Force GC after memory-intensive operations
if memory_intensive_controller?(controller)
GC.start
end
# Force GC periodically
if should_run_periodic_gc?
GC.start
@last_periodic_gc = Time.current
end
end
def self.memory_intensive_controller?(controller)
MEMORY_INTENSIVE_CONTROLLERS = %w[
ReportsController
ExportsController
BulkOperationsController
].freeze
MEMORY_INTENSIVE_CONTROLLERS.include?(controller.class.name)
end
def self.should_run_periodic_gc?
@last_periodic_gc ||= Time.current
Time.current - @last_periodic_gc > 5.minutes
end
# Monitor GC effectiveness
def self.monitor_gc_effectiveness
before_memory = memory_usage_mb
before_objects = ObjectSpace.count_objects[:T_OBJECT]
GC.start
after_memory = memory_usage_mb
after_objects = ObjectSpace.count_objects[:T_OBJECT]
freed_memory = before_memory - after_memory
freed_objects = before_objects - after_objects
Rails.logger.info "GC freed #{freed_memory}MB and #{freed_objects} objects"
# If GC isn't freeing much, we might have a memory leak
if freed_memory < 5 && freed_objects < 1000
Rails.logger.warn "GC effectiveness is low - possible memory leak"
end
end
def self.memory_usage_mb
`ps -o rss= -p #{Process.pid}`.to_i / 1024.0
end
end
# ApplicationController integration
class ApplicationController < ActionController::Base
after_action :smart_gc
private
def smart_gc
SmartGarbageCollector.after_request(self)
end
end
# Background job GC management
class ApplicationJob < ActiveJob::Base
around_perform do |job, block|
memory_before = memory_usage_mb
block.call
memory_after = memory_usage_mb
memory_used = memory_after - memory_before
# Force GC after memory-intensive jobs
if memory_used > 50
Rails.logger.info "Job #{job.class.name} used #{memory_used}MB, running GC"
GC.start
end
end
private
def memory_usage_mb
`ps -o rss= -p #{Process.pid}`.to_i / 1024.0
end
end
GC performance monitoring #
Track GC performance over time:
GC performance tracking #
# app/services/gc_monitor.rb
class GcMonitor
def self.start_monitoring
@gc_stats_before = GC.stat.dup
@start_time = Time.current
end
def self.log_gc_metrics
return unless @gc_stats_before
gc_stats_after = GC.stat
duration = Time.current - @start_time
metrics = {
duration_seconds: duration.round(2),
gc_runs: gc_stats_after[:count] - @gc_stats_before[:count],
major_gc_runs: gc_stats_after[:major_gc_count] - @gc_stats_before[:major_gc_count],
minor_gc_runs: gc_stats_after[:minor_gc_count] - @gc_stats_before[:minor_gc_count],
objects_allocated: gc_stats_after[:total_allocated_objects] - @gc_stats_before[:total_allocated_objects],
heap_pages: gc_stats_after[:heap_allocated_pages],
heap_slots_used: gc_stats_after[:heap_live_slots],
heap_slots_free: gc_stats_after[:heap_free_slots]
}
Rails.logger.info "GC Metrics: #{metrics}"
# Send to monitoring service
if defined?(StatsD)
metrics.each do |key, value|
StatsD.increment("gc.#{key}", value)
end
end
@gc_stats_before = nil
end
# Middleware for automatic GC monitoring
class GcTrackingMiddleware
def initialize(app)
@app = app
end
def call(env)
GcMonitor.start_monitoring
status, headers, response = @app.call(env)
GcMonitor.log_gc_metrics
[status, headers, response]
end
end
end
# Daily GC report
class GcReportJob < ApplicationJob
queue_as :low_priority
def perform
gc_stats = GC.stat
memory_mb = `ps -o rss= -p #{Process.pid}`.to_i / 1024.0
report = {
timestamp: Time.current,
memory_usage_mb: memory_mb,
total_gc_runs: gc_stats[:count],
major_gc_runs: gc_stats[:major_gc_count],
heap_pages: gc_stats[:heap_allocated_pages],
total_allocated_objects: gc_stats[:total_allocated_objects],
heap_final_slots: gc_stats[:heap_final_slots]
}
Rails.logger.info "Daily GC Report: #{report}"
# Store historical data
GcReport.create!(report)
end
end
Memory-efficient coding practices #
Write code that’s naturally memory-friendly from the start.
Efficient data processing patterns #
Memory-efficient data processing #
# BAD: Loads everything into memory
def process_all_users
User.all.each do |user| # Loads ALL users into memory
update_user_stats(user)
end
end
# GOOD: Process in batches
def process_all_users
User.find_in_batches(batch_size: 1000) do |batch|
batch.each do |user|
update_user_stats(user)
end
end
end
# BETTER: Use find_each for automatic batching
def process_all_users
User.find_each(batch_size: 1000) do |user|
update_user_stats(user)
end
end
# BEST: Use pluck for simple operations
def get_user_emails
User.pluck(:email) # Only loads email column, not full objects
end
# Efficient aggregation without loading objects
def calculate_user_stats
{
total_users: User.count,
active_users: User.where(active: true).count,
avg_age: User.average(:age),
recent_signups: User.where('created_at > ?', 1.week.ago).count
}
end
# Stream processing for large datasets
def export_users_csv
CSV.open('users.csv', 'w') do |csv|
csv << ['Name', 'Email', 'Created At']
User.find_each do |user|
csv << [user.name, user.email, user.created_at]
# Each user object is eligible for GC after this iteration
end
end
end
# Lazy enumeration for memory efficiency
def process_large_file(filename)
File.foreach(filename).lazy
.map(&:strip)
.reject(&:empty?)
.each_slice(100) do |batch|
process_batch(batch)
end
end
Smart caching strategies #
Memory-conscious caching #
# Cache only what you need, when you need it
class UserStatsCache
CACHE_TTL = 1.hour
MAX_CACHE_SIZE = 1000
def self.get_stats(user_id)
key = "user_stats:#{user_id}"
Rails.cache.fetch(key, expires_in: CACHE_TTL) do
calculate_user_stats(user_id)
end
end
# Only cache expensive calculations
def self.calculate_user_stats(user_id)
user = User.find(user_id)
{
post_count: user.posts.count,
comment_count: user.comments.count,
reputation_score: calculate_reputation(user),
activity_score: calculate_activity(user)
}
# User object can be GC'd after this method
end
# Conditional caching based on memory pressure
def self.cache_if_memory_allows(key, &block)
if current_memory_mb < 400 # Only cache if we have memory available
Rails.cache.fetch(key, expires_in: 30.minutes, &block)
else
block.call # Skip caching when memory is tight
end
end
def self.current_memory_mb
`ps -o rss= -p #{Process.pid}`.to_i / 1024.0
end
end
# Time-based cache eviction
class TimedCache
def initialize(ttl: 1.hour)
@cache = {}
@ttl = ttl
end
def get(key, &block)
entry = @cache[key]
if entry && (Time.current - entry[:timestamp]) < @ttl
entry[:value]
else
value = block.call
@cache[key] = { value: value, timestamp: Time.current }
# Periodic cleanup
cleanup_expired if rand(100) == 0
value
end
end
private
def cleanup_expired
cutoff = Time.current - @ttl
@cache.reject! { |_, entry| entry[:timestamp] < cutoff }
end
end
# Size-limited cache
class SizeLimitedCache
def initialize(max_size: 1000)
@cache = {}
@access_order = []
@max_size = max_size
end
def get(key, &block)
if @cache.key?(key)
# Update access order
@access_order.delete(key)
@access_order << key
@cache[key]
else
value = block.call
set(key, value)
value
end
end
private
def set(key, value)
@cache[key] = value
@access_order << key
if @cache.size > @max_size
# Remove least recently used
oldest_key = @access_order.shift
@cache.delete(oldest_key)
end
end
end
Avoiding common memory pitfalls #
Memory pitfall prevention #
# 1. Avoid creating unnecessary objects in loops
# BAD
def format_users(users)
users.map do |user|
{
id: user.id,
name: "#{user.first_name} #{user.last_name}".titleize, # Creates multiple strings
email: user.email.downcase # Creates new string
}
end
end
# GOOD
def format_users(users)
users.map do |user|
full_name = user.full_name # Assume this is efficient
{
id: user.id,
name: full_name,
email: user.email.downcase
}
end
end
# 2. Use constants for repeated values
# BAD
def process_items(items)
items.select { |item| item.status == 'active' } # Creates string each time
end
# GOOD
ACTIVE_STATUS = 'active'.freeze
def process_items(items)
items.select { |item| item.status == ACTIVE_STATUS }
end
# 3. Reuse objects when possible
# BAD
def generate_reports(data)
data.map do |row|
formatter = ReportFormatter.new # Creates new object each time
formatter.format(row)
end
end
# GOOD
def generate_reports(data)
formatter = ReportFormatter.new # Reuse single object
data.map do |row|
formatter.format(row)
end
end
# 4. Clear large data structures when done
def process_large_dataset
data = load_large_dataset # Big memory allocation
result = transform_data(data)
data = nil # Help GC by removing reference
GC.start # Force GC to clean up immediately
result
end
# 5. Use streaming for file operations
# BAD
def process_csv_file(filename)
content = File.read(filename) # Loads entire file into memory
CSV.parse(content) do |row|
process_row(row)
end
end
# GOOD
def process_csv_file(filename)
CSV.foreach(filename) do |row| # Reads line by line
process_row(row)
end
end
# 6. Avoid string mutations in hot paths
# BAD
def build_query(conditions)
query = "SELECT * FROM users WHERE "
conditions.each_with_index do |condition, index|
query << " AND " if index > 0
query << condition
end
query
end
# GOOD
def build_query(conditions)
"SELECT * FROM users WHERE #{conditions.join(' AND ')}"
end
⚠️ Warning: Don’t micro-optimize too early! Focus on the biggest memory users first. Profile your application to find the real bottlenecks before applying these techniques.
Production monitoring and alerting #
Set up comprehensive monitoring to catch memory issues before they affect users.
Memory alerting system #
Production memory monitoring #
# config/initializers/memory_monitoring.rb (production only)
if Rails.env.production?
class ProductionMemoryMonitor
ALERT_THRESHOLD_MB = 800
CRITICAL_THRESHOLD_MB = 1200
CHECK_INTERVAL = 30.seconds
def self.start
Thread.new do
loop do
check_memory_and_alert
sleep CHECK_INTERVAL
end
rescue => e
Rails.logger.error "Memory monitor error: #{e.message}"
sleep 60 # Wait before restarting
retry
end
end
def self.check_memory_and_alert
current_memory = memory_usage_mb
if current_memory > CRITICAL_THRESHOLD_MB
send_critical_alert(current_memory)
emergency_memory_cleanup
elsif current_memory > ALERT_THRESHOLD_MB
send_warning_alert(current_memory)
end
end
def self.send_critical_alert(memory_mb)
Rails.logger.error "CRITICAL MEMORY USAGE: #{memory_mb}MB"
# Send to monitoring service
if defined?(StatsD)
StatsD.increment('memory.critical_alert')
StatsD.gauge('memory.usage_mb', memory_mb)
end
# Send to Slack/email
AlertService.send_critical_alert(
title: "Critical Memory Usage",
message: "Server memory usage: #{memory_mb}MB (threshold: #{CRITICAL_THRESHOLD_MB}MB)",
details: gather_memory_details
)
end
def self.emergency_memory_cleanup
Rails.logger.info "Running emergency memory cleanup"
# Clear caches
Rails.cache.clear
# Force garbage collection
3.times { GC.start }
# Clear any application-specific caches
UserCache.clear if defined?(UserCache)
memory_after = memory_usage_mb
Rails.logger.info "Memory after cleanup: #{memory_after}MB"
end
def self.gather_memory_details
gc_stats = GC.stat
{
memory_mb: memory_usage_mb,
gc_count: gc_stats[:count],
heap_pages: gc_stats[:heap_allocated_pages],
heap_slots: gc_stats[:heap_live_slots],
top_objects: top_object_classes(10)
}
end
def self.top_object_classes(limit = 10)
object_counts = Hash.new(0)
ObjectSpace.each_object { |obj| object_counts[obj.class] += 1 }
object_counts.sort_by(&:last).last(limit).reverse.to_h
end
def self.memory_usage_mb
`ps -o rss= -p #{Process.pid}`.to_i / 1024.0
end
end
# Start monitoring after Rails initialization
Rails.application.config.after_initialize do
ProductionMemoryMonitor.start
end
end
# Sidekiq memory monitoring
if defined?(Sidekiq)
Sidekiq.configure_server do |config|
config.server_middleware do |chain|
chain.add MemoryTrackingMiddleware
end
end
class SidekiqMemoryMiddleware
def call(worker, job, queue)
memory_before = memory_usage_mb
yield
memory_after = memory_usage_mb
memory_used = memory_after - memory_before
if memory_used > 100 # Log jobs using >100MB
Rails.logger.warn "High memory job: #{worker.class.name} used #{memory_used}MB"
end
end
private
def memory_usage_mb
`ps -o rss= -p #{Process.pid}`.to_i / 1024.0
end
end
end
Ready to master Ruby memory management? #
Memory management in Ruby doesn’t have to be mysterious. By understanding how Ruby allocates and collects memory, identifying common leak patterns, and implementing smart monitoring, you can build applications that stay lean and fast even as they scale.
The key is to start with good practices from the beginning: avoid creating unnecessary objects, use efficient data processing patterns, and monitor your memory usage in production. When issues do arise, you’ll have the tools and knowledge to diagnose and fix them quickly.
Start optimizing your Ruby memory usage:
- Add basic memory monitoring to identify current usage patterns
- Profile your most memory-intensive operations with memory_profiler
- Implement efficient data processing patterns in your hottest code paths
- Set up production alerting to catch issues early
Need help optimizing your Ruby application’s memory usage?
At JetThoughts, we’ve helped teams solve complex memory issues in Ruby applications of all sizes. From small memory leaks to major architectural optimizations, we know how to make Ruby apps run efficiently at scale.
Our memory optimization services include:
- Comprehensive memory profiling and leak detection
- Custom GC tuning and optimization strategies
- Code review focused on memory efficiency
- Production monitoring and alerting setup
- Team training on Ruby memory best practices
Ready to build memory-efficient Ruby applications? Contact us for a memory optimization consultation and let’s discuss how we can help your application run leaner and faster.