Debug Intermittent Laravel Queue Timeouts in Production

Avatar
M

Maksudur Rahman

Software Engineer•
0Views
5mRead
0Reactions

Queue workers in a Laravel 11 app on PHP 8.3 start failing after a few hours, throwing timeout errors with no clear log entry. The root cause is usually an idle Redis connection being closed by the server, combined with a supervisor shutdown that leaves jobs hanging. The fix is to add a Redis keep‑alive script, enable detailed worker tracing, and configure Supervisor for graceful restarts.

TL;DR / Quick Summary

Reproduce the timeout with a synthetic high‑concurrency job, enable --verbose and --timeout tracing, add a keep‑alive script that pings Redis every 30 seconds, and update supervisord.conf to use stopwaitsecs=60 and process_name=%(program_name)s_%(process_num)02d. Apply the retry strategy in config/queue.php for PHP 8.3.

Reproducing the Intermittent Timeout

When debugging, start with a job that stresses the queue and mimics production load.

Synthetic High‑Concurrency Job

Create app/Jobs/StressTestJob.php:

<?php

namespace App\Jobs;

use Illuminate\Bus\Batchable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Contracts\Queue\ShouldQueue;

class StressTestJob implements ShouldQueue
{
    use Batchable, SerializesModels;

    public function handle()
    {
        // Simulate work that lasts 2 seconds
        usleep(2_000_000);
    }
}

Dispatch 500 jobs from a tinker session or a route:

for ($i = 0; $i < 500; $i++) {
    \App\Jobs\StressTestJob::dispatch();
}

Running the Job Locally

Assuming Docker Compose with services app (Laravel) and redis:

docker compose exec app php artisan queue:work redis --tries=3 --timeout=60 --verbose

After a few hours you should see workers exiting with Connection timed out but no stack trace.

Enabling Detailed Queue Tracing

Laravel hides connection errors unless the worker is run in verbose mode.

Update config/queue.php

// config/queue.php
return [
    'default' => env('QUEUE_CONNECTION', 'redis'),
    'connections' => [
        'redis' => [
            'driver' => 'redis',
            'connection' => 'default',
            'retry_after' => 90, // > job timeout
            'block_for' => null,
            'after_commit' => false,
        ],
    ],
    // Enable tracing globally
    'failed' => [
        'database' => env('DB_CONNECTION', 'mysql'),
        'table' => 'failed_jobs',
    ],
];

Run the worker with --verbose and --timeout=90 to capture the exact moment the Redis socket is closed.

Supervisor Configuration

Create or edit docker/supervisor/conf.d/laravel-worker.conf:

[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/artisan queue:work redis --sleep=3 --tries=3 --timeout=90 --verbose
autostart=true
autorestart=true
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/log/worker.log
stopwaitsecs=60

stopwaitsecs gives the worker enough time to finish the current job before Supervisor kills the process.

Graceful Shutdown & Retry Strategy (PHP 8.3)

Laravel 11 on PHP 8.3 supports native retry_after handling.

Configure Retry in config/queue.php

'connections' => [
    'redis' => [
        'driver' => 'redis',
        'connection' => 'default',
        'retry_after' => 120, // twice the job timeout
        'block_for' => null,
    ],
],

Add a Signal Listener (optional)

Create app/Providers/QueueServiceProvider.php if not present:

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Log;

class QueueServiceProvider extends ServiceProvider
{
    public function boot()
    {
        if (extension_loaded('pcntl')) {
            pcntl_async_signals(true);
            pcntl_signal(SIGTERM, function () {
                Log::info('Queue worker received SIGTERM, exiting gracefully');
                exit;
            });
        }
    }
}

Register the provider in config/app.php.

Redis Keep‑Alive Script

Idle connections are dropped by Redis when tcp-keepalive is not set. A lightweight script prevents the drop.

docker/scripts/redis-keepalive.sh

#!/usr/bin/env bash
# Ping Redis every 30 seconds to keep the TCP socket alive
while true; do
  redis-cli -h redis ping > /dev/null
  sleep 30
done

Make it executable and add to the container entrypoint:

# Dockerfile (app service)
COPY docker/scripts/redis-keepalive.sh /usr/local/bin/redis-keepalive.sh
RUN chmod +x /usr/local/bin/redis-keepalive.sh

# In the entrypoint script
exec /usr/local/bin/redis-keepalive.sh &
exec supervisord -c /etc/supervisor/supervisord.conf

The script runs in the background, ensuring the Redis socket never becomes idle long enough to be closed.

Settings Comparison

Setting

Default (Problem)

Recommended

retry_after

60 s

120 s (≥ 2× job timeout)

stopwaitsecs (Supervisor)

10 s

60 s

Redis tcp-keepalive

0 (disabled)

30 s via keep‑alive script

Worker --timeout

60 s

90 s

Common Pitfalls & Troubleshooting

  • Missing --verbose: Without it, Redis socket errors are swallowed; always run workers with --verbose during debugging.

  • Supervisor kills before job finishes: Increase stopwaitsecs or send SIGTERM manually (supervisorctl stop laravel-worker:*).

  • Docker network DNS resolves to stale IP: Use the service name (redis) consistently; avoid hard‑coding IPs.

  • Redis maxclients reached: Check INFO clients and raise the limit if the keep‑alive script spawns many connections.

  • PHP 8.3 async signal support disabled: Ensure the pcntl extension is installed (docker-php-ext-install pcntl).

For a deeper dive on stuck workers after a deploy, see the article Fix laravel queue workers stuck after deploy.

FAQ

  • Why does the timeout appear only after several hours? The Redis server silently closes idle TCP sockets after its timeout setting (default 0 means no timeout, but many hosted providers enforce a 300‑second idle timeout). The worker only notices the closed socket when it tries to fetch the next job.

  • Do I need to change the Redis tcp-keepalive config? Changing the server config works, but adding a client‑side keep‑alive script is safer in Docker‑orchestrated environments where you may not control the Redis instance.

  • Can I rely on Laravel's built‑in retry instead of a custom script? Laravel retries only after a job fails; it does not prevent the underlying connection error. The keep‑alive script stops the connection from failing in the first place.

  • Is php artisan queue:restart enough to recover? It forces workers to reload, but if the Redis socket remains idle, the new workers will hit the same timeout. Combine a restart with the keep‑alive fix.

Recommended Resources & Courses

Partner Links

React to this article