Skip to main content

Rails OOM error — diagnosis and fix

TL;DR

How to diagnose and fix Rails applications killed by the Linux OOM killer due to Ruby memory bloat.

Key facts

Topic
Production error triage
Stack
Ruby / Linux

TL;DR

When the Linux OOM killer terminates a Rails process, your application goes down without a graceful shutdown. Unlike a Ruby NoMemoryError (which is rare in practice), the OOM killer fires at the kernel level — the process is killed with SIGKILL, no rescue block runs, and in-flight requests are dropped. This is one of the most common Rails production failures, especially on memory-constrained VPS instances.

Confirm the OOM kill

Check kernel logs for the kill event:

dmesg | grep -i "killed process"
journalctl -k | grep -i "out of memory"

You will see output like:

Out of memory: Killed process 12345 (ruby) total-vm:2048000kB, anon-rss:1536000kB

Check which Puma workers consumed the most memory at the time:

ps aux --sort=-%rss | grep puma | head -10
smem -t -k -P puma

Why Rails processes bloat

Ruby's memory allocator (glibc malloc) is notoriously fragmentation-prone. Several patterns cause unbounded growth:

  • Large ActiveRecord result setsModel.all or where without find_each loads the entire result set into Ruby objects. A table with 100k rows can consume hundreds of MB.
  • Memory fragmentation — glibc creates multiple memory arenas for multithreaded applications (Puma threads), and freed memory is rarely returned to the OS.
  • String and Array churn — building large strings (CSV exports, XML generation) in a single buffer instead of streaming.
  • Gem-level leaks — certain gems retain references in global state or class-level variables that grow per request.

Immediate fixes

Switch to jemalloc

jemalloc dramatically reduces Ruby memory fragmentation. Install and configure it:

sudo apt install -y libjemalloc2

Set the environment variable for your Puma service:

# In your systemd service or .env
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2

Most Rails teams see a 20-40% reduction in memory usage after switching to jemalloc.

Limit glibc arenas

If you cannot switch to jemalloc, reduce glibc arena count:

MALLOC_ARENA_MAX=2

This limits the number of memory pools glibc creates, reducing fragmentation at the cost of slightly more lock contention.

Batch large queries

# Instead of loading everything at once
User.where(active: true).each { |u| process(u) }

# Use find_each to load in batches of 1000
User.where(active: true).find_each(batch_size: 1000) { |u| process(u) }

Configure Puma worker killer

The puma_worker_killer gem automatically restarts workers that exceed a memory threshold:

# config/puma.rb
before_fork do
  require 'puma_worker_killer'
  PumaWorkerKiller.config do |config|
    config.ram           = 1024 # MB total for all workers
    config.frequency     = 30   # check every 30 seconds
    config.percent_usage = 0.90 # restart at 90% of ram limit
    config.rolling_restart_frequency = 6 * 3600 # rolling restart every 6 hours
  end
  PumaWorkerKiller.start
end

Monitor memory trends

Track RSS over time to distinguish leaks (steady upward trend) from bloat (high but stable after warmup):

watch -n 5 'ps -o pid,rss,command -p $(pgrep -d, -f puma)'

Check system-wide memory pressure:

free -m
cat /proc/meminfo | grep -E "MemTotal|MemAvailable|SwapTotal|SwapFree"

Where Reflex helps

Reflex tracks per-worker RSS memory trends for your Puma processes. When memory usage crosses a configurable threshold, Reflex can trigger a phased restart of Puma workers — recycling them one at a time to preserve availability — before the OOM killer intervenes. It correlates memory growth with recent deployments and traffic patterns, giving your team actionable diagnostics. See How it works.