Ruby
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Methods and Blocks
-
Section 4: Object-Oriented Ruby
-
Section 5: Metaprogramming
-
Section 6: Working with Data and Files
-
Section 7: Ruby Frameworks Overview
-
Section 8: Ecosystem and Testing
-
Section 9: Practical Projects
-
Section 10: Interview Practice
-
Section 11: Data Structures and Algorithms in Ruby
-
Section 12: More Practice Exercises
-
Section 13: Enumerable and Functional Style
-
Section 14: More OOP Practice
-
Section 15: More Testing
-
Section 16: Enumerable and Comparable Modules In Depth
-
Section 17: Ruby Standard Library: Core Utilities
-
Section 18: Ruby Standard Library: Data and Security
-
Section 19: Ruby Standard Library: CLI and Text
-
Section 20: Ruby Networking
-
Section 21: Ruby on Rails Deep Dive
-
Section 22: Ruby Metaprogramming Deep Dive
-
Section 23: Ruby Design Patterns
-
Section 24: Ruby Concurrency
-
Section 25: Ruby Testing Deep Dive
-
Section 26: Ruby Gems and Packaging
-
Section 27: Ruby Performance
-
Section 28: More Practice Exercises
-
Section 29: More Interview Practice
-
Section 30: Rails API Development
-
Section 31: Rails Authentication and Authorization
-
Section 32: Rails Testing Deep Dive
-
Section 33: Rails Performance
-
Section 34: Rails Deployment
-
Section 35: Sinatra and Lightweight Ruby Web Apps
-
Section 36: More Ruby Language Deep Dive
-
Section 37: Ruby 3.x Modern Features
-
Section 38: More Data Structures in Ruby
-
Section 39: More Practical Projects
-
Section 40: Ruby Ecosystem Tools
-
Section 41: More Practice and Review
-
Section 42: Final Practice and Mastery
-
Section 43: Ruby Interview Deep Dive
-
Section 44: Ruby Background Processing Deep Dive
-
Section 45: Ruby GraphQL
-
Section 46: Ruby Object Model Deep Dive
-
Section 47: Ruby Hanami Framework Overview
-
Section 48: Ruby gRPC and Protocol Buffers
-
Section 49: Ruby Data Processing
-
Section 50: Ruby Search Integration
-
Section 51: Ruby File Upload and Media
-
Section 52: Ruby Email and Notifications
-
Section 53: Ruby Admin Panels
-
Section 54: Ruby Feature Flags and Experimentation
-
Section 55: Ruby Monitoring and Observability
-
Section 56: Ruby Docker and Deployment Deep Dive
-
Section 57: Ruby Security Deep Dive
-
Section 58: More Advanced Metaprogramming
-
Section 59: More Final Projects
114: Garbage Collection Tuning
A few years ago, I was helping a teammate debug a production issue on a Rails API that handled massive JSON payloads for a fintech client. Everything looked great in staging, but in production, we saw these random, jagged spikes in response times. Some requests took 50ms, others suddenly jumped to 1.2 seconds for no apparent reason. We spent two days obsessing over database indexes and Redis latency, only to realize the culprit was the Ruby Garbage Collector (GC). The app was creating so many short-lived objects during the JSON parsing phase that it was triggering "stop-the-world" GC cycles right in the middle of critical requests. We weren't hitting a memory limit; we were just spending too much time cleaning up.
By default, Ruby's GC is designed to be a generalist. It tries to balance memory usage and performance for the average app. But when you're dealing with high-throughput systems or memory-intensive processing, the defaults can actually become a bottleneck. You'll find yourself in a cycle where Ruby allocates a bit of memory, decides it's time to clean up, pauses your entire application to do so, and then repeats the process a millisecond later. It's a death by a thousand cuts.
Balancing the Heap and the Pause
To fix this, you have to understand the trade-off: you can either use more RAM to reduce the frequency of GC pauses, or you can keep your memory footprint tight and accept that your CPU will spend more time pausing your code. Most of the time in modern cloud environments, RAM is cheaper than CPU cycles and latency. If you have 4GB of RAM available but Ruby is aggressively cleaning up every time it hits 200MB, you're wasting resources.
Ruby uses a generational garbage collector (RGenGC). It separates objects into "young" and "old" generations. Most objects die young, so Ruby cleans the young generation frequently and the old generation rarely. The problem arises when you have "bloated" allocations that force the GC to promote objects to the old generation too quickly or trigger a full mark-and-sweep of the entire heap. This is where tuning the environment variables comes in.
Tuning the GC Environment Variables
You don't change these settings in your Ruby code; you set them as environment variables before the process starts. If you're using Docker or Heroku, these go in your config. The most impactful ones are usually the malloc limits.
# Increase the threshold for when the GC is triggered by memory allocation
export RUBY_GC_MALLOC_LIMIT=40000000
export RUBY_GC_MALLOC_LIMIT_MAX=160000000
export RUBY_GC_OLDMALLOC_LIMIT=16000000
export RUBY_GC_OLDMALLOC_LIMIT_MAX=64000000
The RUBY_GC_MALLOC_LIMIT tells Ruby: "Don't worry about running a GC cycle until you've allocated this much memory since the last run." By raising this limit, you're telling Ruby to be less anxious. You'll see fewer pauses, but your process's RSS (Resident Set Size) will climb higher. I usually suggest starting by doubling the defaults and monitoring your memory usage. If you have head-room in your RAM, keep pushing it up until the latency spikes disappear.
Observing the GC with GC.stat
Never tune your GC based on a "hunch." It's a dangerous game that can lead to OOM (Out of Memory) kills in production. Instead, use GC.stat. This method returns a hash of the current GC state, which is a goldmine for debugging. I specifically look at minor_gc_count and major_gc_count.
# Check how many times the GC has run
stats = GC.stat
puts "Minor GCs: #{stats[:minor_gc_count]}"
puts "Major GCs: #{stats[:major_gc_count]}"
puts "Heap live slots: #{stats[:heap_live_slots]}"
If you see the major_gc_count climbing rapidly during a specific task, you know you're hitting a wall. A major GC is the expensive one—it's the "stop-the-world" event we're trying to avoid. If your minor GC count is astronomical, you're likely creating too many temporary objects (strings, hashes) in a tight loop. In that case, tuning variables might help, but you should also look at your code—maybe use String#freeze or map! to reduce allocation pressure in the first place.
📋 Practical Task
Exercise: Reducing GC Cycles for a Large-Scale Object Generator
You have been given a script that simulates a heavy data processing task. Currently, the script triggers a high number of GC cycles because it creates millions of small, short-lived strings. Your goal is to reduce the number of Major GC cycles by tuning the environment variables.
The Setup: Create a file named gc_test.rb with the following code:
# gc_test.rb
def process_data
1_000_000.times do |i|
# Simulating heavy allocation
"user_data_point_#{i}_temp_string_#{rand(1000)}"
end
end
puts "Starting processing..."
GC.start # Start clean
before_stats = GC.stat
process_data
after_stats = GC.stat
puts "--- GC Stats ---"
puts "Minor GC count: #{after_stats[:minor_gc_count] - before_stats[:minor_gc_count]}"
puts "Major GC count: #{after_stats[:major_gc_count] - before_stats[:major_gc_count]}"
Your Task:
- Run the script normally:
ruby gc_test.rb. Note the number of Minor and Major GC cycles. - Run the script again, but this time prepend the execution with an increased
RUBY_GC_MALLOC_LIMIT. For example:RUBY_GC_MALLOC_LIMIT=100000000 ruby gc_test.rb. - Experiment with
RUBY_GC_OLDMALLOC_LIMITas well. - Find a combination of environment variables that reduces the Major GC count to zero (or the lowest possible number) while keeping the script's execution time stable.
- Document which variables you changed and how they affected the Major GC count compared to the baseline.
There are no comments for now.