Be part of JetBrains PHPverse 2026 on June 9 – a free online event bringing PHP devs worldwide together.

kopz's avatar
Level 2

Job runtime longer than timeout (Horizon)

Does anyone know how to explain a job running for a lot longer than the timeout value? (Laravel v. 5.8.38, Horizon v. 3.7.2)

config/queue.php

'redis' => [
    'driver' => 'redis',
    'connection' => 'default',
    'queue' => env('REDIS_QUEUE', 'default'),
    'retry_after' => 125,
    'block_for' => null,
],

This job runs every minute and normally takes < 1s.

app/Console/Kernel.php

$schedule->job(new DeliveryEstimateJob())->cron("* * * * *");

config/horizon.php

        'supervisor-1' => [
            'connection' => 'redis',
            'queue' => ['default'],
            'balance' => 'simple',
            'processes' => 8,
            'tries' => 1,
            'timeout' => 120,
        ],

Workers running as:

/usr/bin/php artisan horizon:work redis --delay=0 --memory=128 --queue=default --sleep=3 --timeout=120 --tries=1 --supervisor=tms-0ZQC:supervisor-1

DeliveryEstimateJob has tries set to 1:

public $tries = 1;

I expect the job to timeout and fail after 120 seconds but ocasionally I am seeing failed jobs with long runtimes.

Example:

DeliveryEstimateJob, Runtime: 812.98s

DeliveryEstimateJob, Runtime: 814.44s*

Illuminate\Queue\MaxAttemptsExceededException: App\Jobs\Routing\DeliveryEstimateJob has been attempted too many times or run too long. The job may have previously timed out.

How is this possible?

0 likes
3 replies
Glukinho's avatar

Are you sure your job goes to routing queue? I think it goes to default queue unless queue name is explicitly set in job class. So, your timeouts are not applied.

Try this:

$schedule->job(new DeliveryEstimateJob, 'routing')->cron("* * * * *");

https://laravel.com/docs/5.8/scheduling#scheduling-queued-jobs

update: alternatively, you may set queue name right in the job class, but I'm not sure it was implemented in v5.8, docs don't mention it:

class DeliveryEstimateJob implements ShouldQueue

public function __construct()
{
    $this->onQueue('routing');
}
kopz's avatar
Level 2

@Glukinho

I made a mistake, the job is actually running on the default queue. I've updated my question.

LaryAI's avatar
Level 58

This is a common issue when using Laravel Horizon (and queue workers in general), and it comes down to how PHP process management and job timeouts work in Laravel.

Why does this happen?

The timeout you set (in Horizon config and as a CLI argument) only applies to how long the worker process will allow a job to run before sending a SIGKILL (kill signal) to the PHP process. However, there are a few caveats:

  1. Timeout is not always enforced immediately:
    The worker checks the timeout, but if the job is running PHP code that doesn't check for signals (e.g., a long-running loop, or a blocking call to an external service), the process may not respond to the kill signal right away.

  2. PCNTL Extension:
    Laravel uses the pcntl extension to handle timeouts. If your PHP is not running with pcntl enabled (common on some servers, especially Windows), the timeout cannot be enforced at all.

  3. Long-running system calls:
    If your job is making a blocking call (like a long cURL request, or waiting on a database), the process may not be able to handle the timeout signal until the call completes.

  4. Supervisor/Process Management:
    If you're running Horizon under a process manager (like Supervisor), and the worker process gets stuck, it may not be killed and restarted as expected.

Why do you see jobs running for 800+ seconds?

  • The job started, and for some reason (e.g., a blocking call, or pcntl not enabled), the worker process did not kill the job after 120 seconds.
  • Eventually, the job finished or was killed, but by then, the runtime was much longer than the configured timeout.
  • Horizon records the actual runtime, not just the configured timeout.

How to fix or mitigate

1. Ensure pcntl is enabled

Check your PHP installation:

php -m | grep pcntl

If it's not listed, install/enable it (on Ubuntu: sudo apt-get install php-pcntl).

2. Avoid blocking calls in jobs

If your job does things like:

  • Long cURL requests without a timeout
  • Waiting for external APIs
  • Long database queries

Make sure to set timeouts on those operations.

3. Use process managers correctly

If using Supervisor, make sure your configuration allows for process restarts if they hang.

4. Upgrade Laravel/Horizon

You're on Laravel 5.8 and Horizon 3.7.2, which are quite old. There have been many improvements and bugfixes in newer versions regarding job handling and timeouts.

5. Set lower retry_after

Your retry_after is 125 seconds, which is just above your timeout (120). It's recommended to set retry_after to be greater than the maximum expected job runtime, but not too close to the timeout, to avoid jobs being released back to the queue while still running.

6. Graceful job interruption

If possible, refactor your job code to periodically check for signals or timeouts, and exit gracefully.


Summary

The most likely reason is that the PHP process running your job is not being killed after the timeout, due to either blocking code or missing pcntl support.
Check your PHP extensions, avoid blocking calls, and consider upgrading Laravel/Horizon for better reliability.


References


Example: Setting a timeout on a Guzzle HTTP request in your job

$client = new \GuzzleHttp\Client([
    'timeout' => 10, // seconds
]);

$response = $client->get('https://example.com/api');

If you provide more details about what your job is doing, I can give more targeted advice!

Please or to participate in this conversation.