Laravel queue workers grow in memory for hours, slow down, then get OOM-killed
Our Laravel export workers started at 70 MB and reached 1 GB after six hours, getting slower until Kubernetes killed them mid-job. An always-on query log, a static memo cache and whole-table loads were to blame. We fixed each and added worker limits.
Our analytics product lets customers export reports to CSV and XLSX. The exports run in Laravel queue workers on Kubernetes. Over a few weeks, support tickets about "export stuck at 90%" increased, and the worker pods showed a pattern in Grafana: memory climbed steadily from about 70 MB after start to the 1 GiB container limit over five to seven hours, and job duration crept up with it. Then the pod restarted:
Last State: Terminated
Reason: OOMKilled
Exit Code: 137
When a worker is killed mid-job, the job is not released cleanly; it waits until retry_after
expires and then runs again from the start, which explained the stuck exports.
Reproducing it
We ran a worker locally against a copy of the production queue with a small script that logged
memory after each job, using the JobProcessed event:
Queue::after(function (JobProcessed $event) {
Log::info('job done', [
'job' => $event->job->resolveName(),
'mem_mb' => round(memory_get_usage(true) / 1048576, 1),
'peak_mb' => round(memory_get_peak_usage(true) / 1048576, 1),
]);
});
After 300 jobs, mem_mb had grown from 68 to 412 and never went down. Garbage collection was not
the issue; something was holding references.
What we ruled out
- One huge export. Memory grew roughly linearly with job count, regardless of export size.
- A PHP or extension bug. The same code under
queue:work --once(one job per process) showed flat memory, so it was state carried between jobs inside our code. - Laravel's own internals. The framework resets a lot between jobs, and the growth stopped when we disabled our own service provider in a test build.
Root cause
We logged memory_get_usage() before and after suspicious calls and bisected by disabling our own
service providers one at a time in a test build. Three things accumulated across jobs:
- The query log was on. An in-house
AuditServiceProvidercalledDB::enableQueryLog()inboot()for every environment, intended for local debugging. In a long-running worker, every query and its bindings were appended to an array that never cleared: about 80,000 entries per hour. - A static memo cache.
CurrencyConverterkeptprivate static array $rates = [], keyed by date and currency pair, and exports covering years of data filled it with tens of thousands of entries. - Whole-table loads. The XLSX export did
Report::query()->where(...)->get()and built rows in memory, so peak usage per job was high and fragmented the heap.
What is happening under the hood
php artisan queue:work boots the framework once and then processes jobs in a loop. That is what
makes it fast, and it is also why anything stored in a static property, a singleton or a
long-lived array survives from one job to the next. PHP-FPM hides these mistakes because each web
request starts with a clean process state; workers do not.
The query log is disabled by default precisely because it grows without bound. Once enabled, the
connection keeps every executed query in memory until DB::flushQueryLog() is called.
The worker has a built-in safety valve: --memory (default 128 MB). After each job it checks
memory_get_usage(true) and, if it is over the limit, exits so the process manager can start a fresh
one. We had set --memory=2048 long ago to "stop workers from restarting", which disabled the valve
and handed the decision to the kernel's OOM killer instead, mid-job.
Environment
| Component | Version / value |
|---|---|
| OS | Debian 12 containers on Kubernetes 1.32 |
| PHP | 8.4.10 CLI |
| Framework | Laravel 12.19 |
| Queue | Redis 7.2 driver, retry_after 900 |
| Worker command | php artisan queue:work redis --queue=exports --memory=2048 --timeout=840 |
| Pod limit | 1 GiB memory |
Minimal reproduction
class LeakyJob implements ShouldQueue
{
use Queueable;
public function handle(): void
{
DB::enableQueryLog(); // left on for the worker's lifetime
for ($i = 0; $i < 500; $i++) {
DB::select('select ? as n', [$i]);
self::$memo[uniqid()] = str_repeat('x', 256); // static array that only grows
}
}
private static array $memo = [];
}
Dispatch it 1,000 times and watch memory_get_usage(true) in the worker climb with each job.
Who is affected
Laravel (and other long-running PHP runtimes like Octane, RoadRunner or Symfony Messenger consumers)
when code uses static caches, enables query logging, registers listeners per job, or loads large
result sets into collections. Also anyone who raised --memory above the container limit.
The fix
1. Query log only where it belongs.
public function boot(): void
{
- DB::enableQueryLog();
+ if ($this->app->environment('local')) {
+ DB::enableQueryLog();
+ }
}
For the audit feature that actually needed query counts in production, we switched to
DB::listen() and recorded counts and durations into a metric, keeping no query strings in memory.
2. Replace the static memo with a bounded, per-job cache. The converter became a scoped binding, so the container creates a fresh instance per job and discards it afterwards:
// AppServiceProvider::register()
$this->app->scoped(CurrencyConverter::class);
final class CurrencyConverter
{
/** @var array<string, string> */
private array $rates = [];
public function rate(string $pair, CarbonImmutable $day): string
{
$key = $pair . '|' . $day->toDateString();
return $this->rates[$key] ??= $this->lookup($pair, $day);
}
}
The container flushes scoped instances whenever a queue worker starts processing a new job, which is the lifetime we wanted.
3. Stream exports instead of loading tables.
- $rows = Report::query()->where('account_id', $id)->get();
- foreach ($rows as $row) { $writer->addRow($this->map($row)); }
+ Report::query()
+ ->where('account_id', $id)
+ ->orderBy('id')
+ ->lazyById(2000)
+ ->each(fn (Report $row) => $writer->addRow($this->map($row)));
lazyById() pages by primary key behind a LazyCollection, so only one chunk is in memory at a
time. We also turned off model event dispatching for the read-only export path where it was not
needed.
4. Put the safety valves back. The worker now restarts itself well before the container limit and periodically regardless:
php artisan queue:work redis --queue=exports \
--memory=512 --max-jobs=500 --max-time=3600 --timeout=840 --tries=3
Kubernetes restarts the container when the worker exits, and the pod's terminationGracePeriodSeconds
is longer than --timeout, so a deploy waits for the current job.
Alternatives and trade-offs
- Only
--max-jobs/--max-time, no code changes. Recycling hides leaks cheaply and is worth having anyway, but the slowdown before each restart remains, and one large job can still exceed the limit. --onceper job. Perfectly clean state, but booting Laravel per job cost us about 120 ms and more CPU; fine for rare, heavy jobs, wasteful for thousands of small ones.- Horizon. Horizon's supervisor config offers the same
memory,maxJobsandmaxTimeoptions per queue, with a dashboard. We use it on another service.
How to test the fix
A feature test runs a batch of jobs in one worker loop and asserts memory stays flat:
public function test_worker_memory_is_stable_across_jobs(): void
{
config(['queue.default' => 'database']); // the test env defaults to the sync driver
Report::factory()->count(5000)->create(['account_id' => 7]);
for ($i = 0; $i < 20; $i++) {
ExportReportJob::dispatch(accountId: 7);
}
$before = memory_get_usage(true);
Artisan::call('queue:work', ['--stop-when-empty' => true, '--memory' => 512]);
$after = memory_get_usage(true);
$this->assertLessThan(32 * 1048576, $after - $before);
$this->assertSame([], DB::getQueryLog());
}
php artisan test --filter=test_worker_memory_is_stable_across_jobs
Confirming in production
- Worker memory now plateaus between 90 and 140 MB; the per-job memory log line shows no upward trend over 500 jobs.
OOMKilledevents for the export deployment: zero in the four weeks after release, versus 9 to 14 per day before.- p50 export duration fell from 3m40s to 1m55s, mostly because late-in-life slow workers disappeared.
- Jobs retried due to
retry_afterexpiry dropped from about 60 per day to under 2.
kubectl get events -n analytics --field-selector reason=OOMKilling --since=168h | wc -l
Regression guard
- The memory stability test runs in CI on every change to
app/Jobsorapp/Exports. - An architecture test (Pest arch) forbids
DB::enableQueryLogoutside local-only providers and flags newstatic arrayproperties inapp/Services. - An alert fires when any worker pod exceeds 70% of its memory limit for 15 minutes.
Security implications
The query log held SQL bindings, including customer email addresses used as export filters, in worker memory for hours; a heap dump or core file would have contained them. Turning the log off in production removes that exposure. Otherwise the fix only changes resource usage.
Prevention checklist
- Treat workers as long-running processes: no static caches without bounds, no per-job listeners.
- Keep
--memorybelow the container limit, and add--max-jobsand--max-time. - Stream large datasets with
lazyById()orchunkById(); neverget()a whole table. - Log memory per job in staging when adding new job types.
- Grep for debugging toggles (
enableQueryLog, debug listeners) in service providers.
References
- Laravel 12.x documentation: Queues, running the queue worker and resource considerations — https://laravel.com/docs/12.x/queues
- Laravel 12.x documentation: Service container, scoped bindings — https://laravel.com/docs/12.x/container
- Laravel 12.x documentation: Eloquent, chunking and lazy collections — https://laravel.com/docs/12.x/eloquent
- Laravel 12.x documentation: Horizon configuration — https://laravel.com/docs/12.x/horizon
Add a label
Embed this post
<iframe src="https://hibug.me/laravel-queue-worker-memory-growth" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.