Debug Intermittent Laravel Queue Timeouts in Production
Maksudur Rahman
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 --verboseAfter 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=60stopwaitsecs 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
doneMake 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.confThe script runs in the background, ensuring the Redis socket never becomes idle long enough to be closed.
Settings Comparison
Setting | Default (Problem) | Recommended |
|---|---|---|
| 60 s | 120 s (≥ 2× job timeout) |
| 10 s | 60 s |
Redis | 0 (disabled) | 30 s via keep‑alive script |
Worker | 60 s | 90 s |
Common Pitfalls & Troubleshooting
Missing
--verbose: Without it, Redis socket errors are swallowed; always run workers with--verboseduring debugging.Supervisor kills before job finishes: Increase
stopwaitsecsor sendSIGTERMmanually (supervisorctl stop laravel-worker:*).Docker network DNS resolves to stale IP: Use the service name (
redis) consistently; avoid hard‑coding IPs.Redis
maxclientsreached: CheckINFO clientsand raise the limit if the keep‑alive script spawns many connections.PHP 8.3 async signal support disabled: Ensure the
pcntlextension 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
timeoutsetting (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-keepaliveconfig? 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:restartenough 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.