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 exceededorR12 - 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.Optimize Puma configuration for Heroku:
- 2.```ruby
- 3.# config/puma.rb
- 4.max_threads_count = ENV.fetch('RAILS_MAX_THREADS', 5)
- 5.min_threads_count = ENV.fetch('RAILS_MIN_THREADS', max_threads_count)
- 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.Tune ActiveRecord connection pool:
- 2.```yaml
- 3.# config/database.yml
- 4.production:
- 5.pool: <%= ENV.fetch('RAILS_MAX_THREADS', 5) + ENV.fetch('WEB_CONCURRENCY', 2) %>
- 6.# Each worker needs: threads + 1 connections minimum
- 7.
` - 8.Profile memory usage:
- 9.```ruby
- 10.# Add to Gemfile
- 11.gem 'memory_profiler'
- 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.Add memory monitoring and worker recycling:
- 2.```ruby
- 3.# config/puma.rb
- 4.before_fork do
- 5.ActiveRecord::Base.connection_pool.disconnect!
- 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.Set Heroku environment variables:
- 2.```bash
- 3.heroku config:set RAILS_MAX_THREADS=5
- 4.heroku config:set WEB_CONCURRENCY=2
- 5.heroku config:set RAILS_SERVE_STATIC_FILES=true
- 6.heroku config:set MALLOC_ARENA_MAX=2 # Reduce glibc memory fragmentation
- 7.
`
Prevention
- Monitor memory usage trends in Heroku dashboard or Librato
- Set up alerts for R14 errors exceeding threshold
- Use
MALLOC_ARENA_MAX=2to 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