Latency vs runtime — the two halves
Perceived job delay = queue latency + job runtime. They're separate problems:
| Problem | Cause | Fix |
|---|---|---|
| High queue latency | Not enough worker capacity for dispatch rate | Scale workers or split queues |
| High runtime | The job itself is slow (DB, external API, computation) | Optimize or decompose the job |
Measuring latency yourself
Laravel fires JobProcessing when a worker picks up a job. The job's payload includes the dispatch timestamp in pushedAt:
app/Providers/EventServiceProvider.php
use Illuminate\Queue\Events\JobProcessing;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
Event::listen(function (JobProcessing $event) {
$payload = $event->job->payload();
$pushedAt = $payload['pushedAt'] ?? null;
if (!$pushedAt) return;
$latencyMs = (microtime(true) - $pushedAt) * 1000;
DB::table('queue_latency')->insert([
'queue' => $event->job->getQueue(),
'job_class' => $event->job->resolveName(),
'latency_ms' => $latencyMs,
'started_at' => now(),
]);
});
Aggregate with percentile_cont(0.95) WITHIN GROUP (ORDER BY latency_ms). Roll up per queue per minute.
Per-queue SLOs
Assign each queue a latency target based on what it serves:
| Queue | Work type | SLO (p95) |
|---|---|---|
| transactional | Password resets, 2FA codes | < 3s |
| default | User notifications, light background work | < 15s |
| indexing | Search index updates | < 60s |
| batch | Nightly exports, bulk imports | < 10m |
Separate fast from slow
Mixing fast and slow jobs on the same queue with the same workers is the number-one cause of latency spikes. A 5-second job class that fires 100 times floods the queue and starves the sub-second email jobs behind it. Fix by splitting queues and dedicating workers:
app/Jobs/SendPasswordResetEmail.php
public $queue = 'transactional';app/Jobs/GenerateMonthlyReport.php
public $queue = 'batch';supervisord config
[program:laravel-transactional-worker]
command=php artisan queue:work --queue=transactional --sleep=0 --tries=3
numprocs=5
[program:laravel-batch-worker]
command=php artisan queue:work --queue=batch --sleep=3 --timeout=600 --tries=1
numprocs=2Alerting on backlog
Alert on age-of-oldest-pending-job rather than count:
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\DB;
// For database driver
$oldestPendingAge = DB::table('jobs')
->where('queue', 'transactional')
->min('created_at');
if ($oldestPendingAge && now()->diffInSeconds($oldestPendingAge) > 10) {
alert('Transactional queue lag > 10s');
}THE EASY WAY
NightOwl records queue latency per queue with p95 trending
Per-queue dashboards show p95 latency over any time range, separate from job runtime. Set per-queue SLOs and get alerted when p95 exceeds them for 5+ minutes. Works with Redis, database, SQS, Beanstalkd.
composer require nightowl/agent
php artisan nightowl:installFrom $5/month flat. Data in your PostgreSQL.