Web Development Bug Report

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.

Daniel Tran Daniel Tran @fun_dev · August 05, 2025 · 8 min read · 23 Views
Banner: Laravel queue workers growing in memory for hours until they are OOM-killed

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:

  1. The query log was on. An in-house AuditServiceProvider called DB::enableQueryLog() in boot() 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.
  2. A static memo cache. CurrencyConverter kept private static array $rates = [], keyed by date and currency pair, and exports covering years of data filled it with tens of thousands of entries.
  3. 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.
  • --once per 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, maxJobs and maxTime options 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.
  • OOMKilled events 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_after expiry 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/Jobs or app/Exports.
  • An architecture test (Pest arch) forbids DB::enableQueryLog outside local-only providers and flags new static array properties in app/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 --memory below the container limit, and add --max-jobs and --max-time.
  • Stream large datasets with lazyById() or chunkById(); never get() 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

Add a label

#laravel #php #kubernetes #memory-leak #queues
Written by
Daniel Tran @fun_dev

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.