The pause you did not schedule

Free preview 4 min to read

Your p50 is 40 ms. Your p99 is 900 ms. The slow requests aren't slower queries, a different endpoint or one bad customer. They're scattered across everything, and when you line them up against process uptime, they arrive in a rhythm. Something in the process stops the world every few seconds, and it isn't in your code.

That something is a major garbage collection. On one machine here, with Ruby 4.0.1, a process holding 217,855 live objects took 2.08 ms for a full mark and sweep and 0.56 ms for a minor one:

retained = []
200_000.times { |i| retained << "row-#{i}" }
GC.start

t = Time.now
GC.start(full_mark: true, immediate_sweep: true)
puts "major #{((Time.now - t) * 1000).round(2)} ms"

t = Time.now
GC.start(full_mark: false, immediate_sweep: true)
puts "minor #{((Time.now - t) * 1000).round(2)} ms"

That's about four times the cost, for the same call with one keyword changed. A rerun on another machine on 2026-09-28 gave 4.14 ms against 0.89 ms, the same shape. Scale the live set from two hundred thousand objects to the several million a real Rails process carries, and that ratio is the whole story of your tail latency.

What you already have, and what's missing

The junior course "Ruby: garbage collection" gave you the instruments. You counted live objects with ObjectSpace and broke the heap down by type with ObjectSpace.count_objects. You watched pauses land with GC::Profiler, set heap environment variables and measured which ones moved things, and met finalizers and WeakRef. Each of those is a way to observe the collector.

None of them tells you what the collector is deciding. That's the gap this course closes. Observation without a model produces the thing senior engineers do worst: tuning by superstition. Someone sets four environment variables from a blog post, the graph moves for unrelated reasons, and the setting becomes folklore nobody can remove.

The senior course "Ruby: GVL deep dive" ended by pointing here. Every allocation in that course fed a collector whose thirty counters you read without interpreting them. Every thread you watched get preempted on a timer could have paused for a GC run instead. The lock and the collector are the two things that stop a Ruby thread, and you've only modeled one of them.

The one idea underneath everything else

Most objects die young. A request builds a hash of parameters, three Active Record objects and forty strings for a template. Then the response goes out, and nearly all of it is garbage. A handful of things survive: the class objects, the connection pool, the compiled templates, your caches.

A collector that walks every object on every cycle pays full price to rediscover that fact thousands of times a second. Ruby doesn't. It sorts objects by age, sweeps the young ones often and cheaply and visits the old ones rarely. The interpreter itself confirms it:

GC::OPTS
# => ["USE_RGENGC", "RGENGC_ESTIMATE_OLDMALLOC",
#     "GC_ENABLE_LAZY_SWEEP", "GC_COMPACTION_SUPPORTED"]

RGENGC is the generational collector, compiled in and on. Every diagnosis in this course starts from what it's trying to do.

What this course covers

Lesson 2 is the machinery: minor marks against major marks, the three-survival rule that promotes an object to old, incremental marking, and the write barrier and remembered set that keep the whole scheme correct.

Lesson 3 asks what fires a major collection, because that's the pause anyone notices. The answer includes a memory path most people have never heard of, and GC.latest_gc_info will name it for you.

Lesson 4 turns GC.stat from thirty numbers into a diagnosis. You'll reduce it to three ratios and read them as a verdict. That lesson carries the coding challenge.

Lesson 5 draws the line that decides what you do on Monday. A leak is retained references growing without bound. Bloat is resident memory that never comes back. They look identical on a memory graph, and they need opposite responses.

Lesson 6 covers compaction and copy-on-write: GC.compact measured here, why GC.auto_compact is still off by default, and why a preforking Puma shares pages until the collector dirties them.

Lesson 7 covers allocation and tuning, with an honest verdict on both. The default heuristics are good. The interesting question is almost never which environment variable to set.

Start with the age of an object. Everything the collector does well, and every way it surprises you, follows from it.

Lesson 1 of 7 · free

Keep going with lesson 2

Generational collection and the write barrier

How objects are promoted after three survivals, why minors skip old objects, and how the write barrier keeps that safe.

A free account also gives you the quizzes, your progress and notes, and the discussion.

Type to start searching...