Diagnosing Sidekiq Memory Bloat
Growing worker memory is the most common Sidekiq operational complaint, and most of the time it is not a leak. This guide extends Sidekiq Performance Tuning, part of Backend Frameworks & Worker Scaling, to diagnosing which of the three causes you actually have.
Problem Statement
Your Sidekiq processes start at 220MB RSS and reach 1.4GB within eight hours, at which point the container hits its memory limit and is OOM-killed โ taking every in-flight job with it. A nightly restart hides the symptom, but the restart occasionally lands mid-job and produces duplicate side effects. You need to know whether this is heap fragmentation, retained objects, or simply a workload that genuinely needs that much memory, because the fix differs completely for each.
Prerequisites
- Sidekiq 6.5+ with the
concurrencysetting visible, and Ruby 3.0+. - Container memory limits and the ability to read RSS over time per process.
- A staging environment where you can replay production job mix.
derailed_benchmarksormemory_profileravailable in that environment.
Step 1 โ Distinguish the Three Causes
| Cause | Signature | Fix |
|---|---|---|
| Heap fragmentation | RSS grows then plateaus; Ruby heap stays flat | Switch to jemalloc |
| Retained objects (leak) | Both RSS and live object count grow without bound | Find and release the retained references |
| Legitimate high-water mark | RSS jumps with a specific job class, then holds | Reduce per-job allocation or accept it |
Step 2 โ Rule Out Fragmentation First
Ruby's default allocator fragments badly under the allocate-and-free pattern that a job queue produces. Switching to jemalloc frequently removes 30โ50% of apparent bloat with no code change at all, so it is the cheapest thing to try.
# Dockerfile โ install jemalloc and preload it
RUN apt-get update && apt-get install -y libjemalloc2 && rm -rf /var/lib/apt/lists/*
ENV LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2
ENV MALLOC_CONF="dirty_decay_ms:1000,muzzy_decay_ms:1000"
# Confirm it is actually loaded โ this is the step people skip
ruby -e 'puts `cat /proc/#{Process.pid}/maps`.include?("jemalloc")'
If RSS drops substantially and then plateaus, the problem was fragmentation and you are done. If the curve still climbs without bound, continue.
Step 3 โ Separate Retained from Transient Allocation
A leak retains objects across jobs. Measure the live heap between jobs rather than during one.
# config/initializers/sidekiq_memory.rb
require "objspace"
class MemoryProbeMiddleware
def call(worker, job, queue)
GC.start(full_mark: true, immediate_sweep: true)
before = ObjectSpace.count_objects[:TOTAL] - ObjectSpace.count_objects[:FREE]
rss_before = rss_kb
yield
GC.start(full_mark: true, immediate_sweep: true)
after = ObjectSpace.count_objects[:TOTAL] - ObjectSpace.count_objects[:FREE]
# Retained AFTER a full GC is the number that matters. Transient allocation
# during the job is irrelevant โ it is collected.
StatsD.gauge("sidekiq.retained_objects", after - before,
tags: ["job:#{worker.class.name}"])
StatsD.gauge("sidekiq.rss_delta_kb", rss_kb - rss_before,
tags: ["job:#{worker.class.name}"])
end
private
def rss_kb
File.read("/proc/self/status")[/VmRSS:\s+(\d+)/, 1].to_i
end
end
Sidekiq.configure_server do |config|
config.server_middleware { |chain| chain.add MemoryProbeMiddleware }
end
Run this on a sample of jobs, not all of them: a full GC per job is expensive. A job class that consistently retains thousands of objects after collection is your leak.
Step 4 โ Find What Is Holding the References
# In a staging console, replay the suspect job and dump what survived
require "memory_profiler"
report = MemoryProfiler.report do
100.times { SuspectJob.new.perform(sample_args) }
end
report.pretty_print(to_file: "suspect_job.txt", retained_strings: 20)
The usual culprits in Ruby job code are narrow and recur constantly:
- Class-level accumulators.
@@seen ||= []or a memoised hash on the class keeps growing for the life of the process. - Global caches without bounds. A
Hashused as a cache with no eviction, particularly one keyed by record ID. - Logger or instrumentation buffers. Subscribers that append events and are never flushed.
- Connection or client objects created per job and registered somewhere global, so each job adds one.
- Symbols or string interning from untrusted input โ
to_symon user data grows a table that is never collected.
Step 5 โ Understand How Concurrency Multiplies Memory
Sidekiq runs jobs as threads in one process, so all threads share one heap. Concurrency does not multiply the base image, but it does multiply peak transient allocation.
def peak_rss_estimate(base_mb, per_job_peak_mb, concurrency)
# Threads share the heap, so the base is paid once โ but the peak is
# concurrent, and Ruby's heap does not shrink back readily.
base_mb + per_job_peak_mb * concurrency
end
peak_rss_estimate(220, 40, 10) # 620 MB
peak_rss_estimate(220, 40, 25) # 1220 MB โ the same job mix, tighter budget
This is why raising concurrency to improve throughput so often produces OOM kills a week later: the high-water mark rises with concurrency and the heap does not return the memory. If one job class allocates heavily, isolate it on a lower-concurrency process rather than lowering concurrency everywhere.
Step 6 โ Bound Memory Without Hiding the Cause
# A memory watchdog: stop fetching, let in-flight jobs finish, then exit.
Thread.new do
limit_kb = Integer(ENV.fetch("SIDEKIQ_MAX_RSS_KB", "1_200_000"))
loop do
sleep 30
rss = File.read("/proc/self/status")[/VmRSS:\s+(\d+)/, 1].to_i
next if rss < limit_kb
Sidekiq.logger.warn("RSS #{rss}KB over #{limit_kb}KB โ draining")
Process.kill("TSTP", Process.pid) # stop fetching new jobs
sleep 30 # let in-flight work finish
Process.kill("TERM", Process.pid) # graceful shutdown
break
end
end
Restarting is a mitigation, not a fix. Keep the retained-object metric from step 3 in a dashboard so the underlying leak stays visible after the restart makes it survivable โ otherwise the workaround becomes permanent and the next concurrency increase rediscovers it.
Step 7 โ Reduce Allocation in the Jobs Themselves
Once the cause is a genuine high-water mark rather than a leak, the fix is allocating less. Three patterns account for most of it in job code.
Loading whole result sets is the largest single cause: Order.where(...).each materialises every row before iterating. find_each batches at 1,000 by default and keeps peak memory flat regardless of table size. The same applies to file processing โ File.read on a 200MB export costs 200MB, while File.foreach costs a line.
Serialisation is the second. Parsing a large JSON payload creates a full object graph that survives as long as any reference to it does; if only three fields are needed, extract them and let the parsed structure go out of scope before the expensive part of the job begins. This is also an argument for keeping payloads small in the first place, as in the claim-check pattern โ a job that fetches only the fields it needs never allocates the rest.
The third is retained per-job state: instance variables on a worker object are fine, because Sidekiq creates a new instance per job, but anything assigned to a constant, a class variable, or a thread-local outlives the job and accumulates across the process's whole lifetime.
Verification
- RSS plateaus. After the fix, RSS over twelve hours should flatten below the container limit rather than trending upward.
- Retained objects are flat. The per-job retained metric should hover near zero for every job class.
- No OOM kills. Container restart reasons should show clean exits or none, not
OOMKilled. - Concurrency is safe. Raise concurrency by 50% in staging and confirm the peak estimate from step 5 still fits the limit.
Gotchas & Edge Cases
RSS includes shared pages. Multiple Sidekiq processes on one host share copy-on-write memory, so summing RSS overstates actual usage. Use PSS when sizing hosts.
A nightly restart masks everything. It also makes the leak undiagnosable, because the curve never has time to reveal its shape. Diagnose in staging with restarts disabled.
Killing on memory without draining loses jobs. A watchdog that sends KILL, or a container limit reached without warning, discards in-flight work โ the same problem as an abrupt deploy, covered in graceful shutdown & worker deployments.
Ruby's heap rarely shrinks. Once the process has grown to serve a peak, it holds that memory even when idle. A plateau at a high level after a genuine spike is therefore expected behaviour, not evidence of a leak โ judge by whether it keeps climbing, not by the absolute number.
GC.compact is not a general fix. It can help fragmentation on Ruby 3.x, but it is expensive and pauses the process; measure before adopting it, and never call it per job.
Memory growth can be a queue-mix change, not a code change. A process whose RSS jumped after a deploy that touched nothing memory-related is usually now receiving a different job mix โ a new task class routed onto its queue, or a shift in argument sizes. Check what changed in routing before profiling the code; the fix may be a queue split rather than an allocation fix.
Redis client buffers grow with large payloads. A very large job argument inflates the client's buffer, and with a big connection pool that is multiplied โ another reason to keep payloads small, and to size the pool per tuning the Sidekiq Redis connection pool.
Related
- Sidekiq Performance Tuning โ concurrency, latency, and throughput tuning around this.
- Tuning the Sidekiq Redis connection pool โ pool sizing, which interacts with both memory and concurrency.
- Sidekiq batch jobs and workflows โ splitting large jobs into smaller units that allocate less.
- Graceful Shutdown & Worker Deployments โ draining safely when a restart is the mitigation.