Ruby & Rails

Where Rails memory actually goes

Your app is not leaking. Ruby freed the memory and the allocator kept it. Arenas, fragmentation, and the two environment variables that fix most of it.

22 July 2026 4 min read Mohammad Aaquib Jawed

A Rails process starts at 180MB, climbs to 400MB over a few hours, and settles there. No object count grows without bound. No obvious leak in any heap dump. And yet the resident memory never comes back down.

This is the single most misdiagnosed problem in Ruby deployment, because it is not a leak. It is fragmentation, and the culprit sits below Ruby entirely.

Freed is not returned

When Ruby's garbage collector frees an object, that memory goes back to the allocator — usually glibc's malloc on Linux. It does not necessarily go back to the operating system.

glibc holds onto freed memory deliberately, on the reasonable assumption that a program which allocated once will allocate again, and that asking the kernel for pages is expensive. Memory is only returned when a free region sits at the top of the heap and is large enough to be worth releasing. Free space in the middle stays exactly where it is.

So RSS — the number your monitoring shows, the number your container limit enforces — reflects the high-water mark of what the allocator has ever needed, not what your application currently holds.

Arenas: why threads make it dramatically worse

To stop threads fighting over a single lock, glibc gives each thread its own arena, up to a limit that scales with core count. On an eight-core box that can mean dozens of independent pools.

Now combine that with the previous section. Each arena independently retains freed memory. Each fragments on its own schedule. A block freed in arena three cannot satisfy an allocation in arena seven. You end up with a large amount of free space that is collectively substantial and individually useless.

You are not out of memory. You are out of *contiguous* memory, in the specific arena that happens to be asking.

This is precisely why the problem is so much worse on multi-threaded Puma than on a single-threaded process, and why it appeared the moment your team raised the thread count.

The two-line fix

Set MALLOC_ARENA_MAX=2.

This caps glibc at two arenas regardless of core count. Less fragmentation, less retained memory, dramatically lower RSS on threaded workloads. The trade is some lock contention between threads inside the allocator, which can cost a little throughput — in practice, for typical Rails workloads, far less than the memory it saves is worth.

The alternative is replacing the allocator entirely with jemalloc, which handles fragmentation far better by design and is the option the Rails guides reach for first.

Worth knowing where jemalloc stands, because the situation has moved: the project was archived in mid-2025 and spent a stretch effectively unmaintained, which made a lot of teams reasonably nervous about adopting it. Meta announced renewed investment in early 2026. If you are choosing today, that history is worth a moment's thought — it is excellent software with a wobble in its recent past, not a safe unexamined default.

For what it is worth, this site's container preloads jemalloc *and* sets MALLOC_ARENA_MAX=2, because the second costs nothing if the first is doing its job.

Returning memory on purpose

Two more tools worth knowing.

malloc_trim asks glibc to give unused memory at the top of the heap back to the kernel. It is not magic — it cannot relocate live objects to consolidate free space — but after a big one-off allocation spike, a trim can reclaim a real amount. Some teams call it after long-running jobs for exactly this reason.

Process.warmup, available since Ruby 3.3, is the more interesting one. Call it once your application has finished booting and it performs a full collection, compacts the heap, and prepares memory for the fork that follows. In a preloading Puma setup this means workers fork from a tidy, compacted parent, which improves how much memory genuinely stays shared through copy-on-write. Rails calls it for you in recent versions; it is worth confirming that it actually runs in your setup.

How to tell fragmentation from a real leak

The distinction matters because the fixes are unrelated.

A real leak grows without bound. Object counts for some class climb steadily and never fall. GC.stat shows live object slots rising across collections. Given enough time, the process dies. The cause is nearly always something being retained — a class-level cache, a global registry, a memoised value on a long-lived object.

Fragmentation plateaus. RSS climbs and then flattens, usually a few multiples above where it started. Ruby's own object counts are stable. GC.stat[:heap_live_slots] is flat while RSS is not. That gap — Ruby thinks it is holding little, the OS thinks it is holding a lot — is the signature.

Check that gap before you spend a week hunting a leak that does not exist. If Ruby's live object count is flat and RSS is not, the problem is beneath Ruby, and it is two environment variables away from being much smaller.

All writing Reply by email