When a queue backs up, nobody gets an error. The password reset email just arrives four minutes late, by which point the user has requested three more. Queue latency is the delay between dispatching work and a worker actually starting it, and it's the part of background processing your users feel most directly — even though it never shows up as a failure. A healthy-looking queue with zero failed jobs can still be quietly ruining the experience if the wait time has crept up.
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');
}Point-in-time isn't enough
The trap with queue monitoring is watching the current backlog. A dashboard that shows "0 pending jobs right now" looks reassuring and tells you nothing about the 9am spike where the transactional queue sat at 40 seconds of lag for twenty minutes. By the time you look, it has drained. Latency is a distribution over time, not a number you glance at — you want p95 latency per queue trended over hours and days, so the recurring 9am problem shows up as a shape on a graph instead of a coincidence you happened to catch. Point-in-time views (including Horizon's Pending view) are for firefighting; trended percentiles are for actually fixing it.
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.