Introduction

Puma worker timeouts and OOM kills on Heroku are production-critical issues that cause 503 errors and degraded user experience. Heroku enforces strict resource limits: R12 (request timeout after 30 seconds) and R14 (memory quota exceeded), both of which can terminate Puma workers mid-request.

Symptoms

  • Heroku logs show R14 - Memory quota exceeded or R12 - Request timeout
  • Puma worker gets SIGKILL with Error R12 (Exit timeout)
  • Application returns 503 errors intermittently
  • Heroku dashboard shows memory usage exceeding dyno limit (512MB for hobby, 1GB for standard)
  • Workers restart frequently, causing connection drops

Example Heroku log: `` 2026-04-09T10:15:32.000000+00:00 heroku[web.1]: Process running mem=548M(107.0%) 2026-04-09T10:15:32.000000+00:00 heroku[web.1]: Error R14 (Memory quota exceeded) 2026-04-09T10:15:45.000000+00:00 heroku[web.1]: Stopping all processes with SIGTERM 2026-04-09T10:15:45.000000+00:00 heroku[web.1]: Process exited with status 137

Common Causes

  • Too many Puma workers for available memory
  • Memory leak in application code or a gem
  • Large request payloads held in memory
  • ActiveRecord connection pool too large for worker count
  • Long-running requests exceeding 30-second timeout

Step-by-Step Fix

  1. 1.Optimize Puma configuration for Heroku:
  2. 2.```ruby
  3. 3.# config/puma.rb
  4. 4.max_threads_count = ENV.fetch('RAILS_MAX_THREADS', 5)
  5. 5.min_threads_count = ENV.fetch('RAILS_MIN_THREADS', max_threads_count)
  6. 6.threads min_threads_count, max_threads_count

# Calculate workers based on memory: (dyno_memory - 200MB overhead) / worker_memory worker_count = ENV.fetch('WEB_CONCURRENCY', 2) workers worker_count

preload_app!

# Graceful timeout before Heroku's 30s hard limit worker_timeout 25 worker_boot_timeout 30 ```

  1. 1.Tune ActiveRecord connection pool:
  2. 2.```yaml
  3. 3.# config/database.yml
  4. 4.production:
  5. 5.pool: <%= ENV.fetch('RAILS_MAX_THREADS', 5) + ENV.fetch('WEB_CONCURRENCY', 2) %>
  6. 6.# Each worker needs: threads + 1 connections minimum
  7. 7.`
  8. 8.Profile memory usage:
  9. 9.```ruby
  10. 10.# Add to Gemfile
  11. 11.gem 'memory_profiler'
  12. 12.gem 'derailed_benchmarks', group: :development

# Run memory profiling bundle exec derailed exec mem bundle exec derailed exec perf:objects

# Or profile in production require 'memory_profiler' report = MemoryProfiler.report do User.all.each { |u| u.process_something } end report.pretty_print(to_file: 'memory_report.txt') ```

  1. 1.Add memory monitoring and worker recycling:
  2. 2.```ruby
  3. 3.# config/puma.rb
  4. 4.before_fork do
  5. 5.ActiveRecord::Base.connection_pool.disconnect!
  6. 6.end

on_worker_boot do ActiveSupport.on_load(:active_record) do ActiveRecord::Base.establish_connection end end

# Recycle workers after N requests to prevent memory growth worker_count = Integer(ENV.fetch('WEB_CONCURRENCY', 2)) workers worker_count max_threads_count = Integer(ENV.fetch('RAILS_MAX_THREADS', 5)) threads min_threads_count, max_threads_count

# Add to workers to recycle after 1000 requests plugin :tmp_restart ```

  1. 1.Set Heroku environment variables:
  2. 2.```bash
  3. 3.heroku config:set RAILS_MAX_THREADS=5
  4. 4.heroku config:set WEB_CONCURRENCY=2
  5. 5.heroku config:set RAILS_SERVE_STATIC_FILES=true
  6. 6.heroku config:set MALLOC_ARENA_MAX=2 # Reduce glibc memory fragmentation
  7. 7.`

Prevention

  • Monitor memory usage trends in Heroku dashboard or Librato
  • Set up alerts for R14 errors exceeding threshold
  • Use MALLOC_ARENA_MAX=2 to reduce glibc memory fragmentation on Linux
  • Regularly profile memory with derailed_benchmarks
  • Keep Puma workers under 3 for standard-1x dynos (1GB limit)
  • Use phased restarts (heroku restart --rolling) to avoid downtime