Laravel Queues at Scale: Backpressure, Dead-Letter Queues, and Graceful Degradation
#laravel #queues #reliability #backend #scalability

Laravel Queues at Scale: Backpressure, Dead-Letter Queues, and Graceful Degradation

3 min read Mohamed Said Mohamed Said

The Problem Nobody Talks About

Most Laravel queue tutorials stop at php artisan queue:work. That is fine for low-traffic apps, but once you have burst traffic — a flash sale, a viral email campaign, a cron that fans out 50,000 jobs — you need three things your default setup does not give you: backpressure, dead-letter isolation, and graceful degradation.


Backpressure: Slowing the Producer

Backpressure means the producer (the HTTP request or command that dispatches jobs) slows down when the queue is already overwhelmed. Without it, a spike fills Redis memory and workers fall further behind.

Measuring Queue Depth Before Dispatching

use Illuminate\Support\Facades\Redis;

final class ThrottledDispatcher
{
    private const MAX_DEPTH = 5_000;

    public function dispatch(ShouldQueue $job, string $queue = 'default'): void
    {
        $depth = (int) Redis::llen('queues:' . $queue);

        if ($depth >= self::MAX_DEPTH) {
            throw new QueueSaturatedException(
                "Queue '{$queue}' depth {$depth} exceeds limit."
            );
        }

        dispatch($job)->onQueue($queue);
    }
}

In an HTTP context you can catch QueueSaturatedException and return HTTP 503 with a Retry-After header. For internal fan-out commands, log the saturation and skip non-critical jobs.


Dead-Letter Queues: Isolating Poison Pills

Laravel's failed_jobs table is a dead-letter store, but it is a single flat table. When a job fails repeatedly it keeps consuming worker time on retries. A better pattern is a dedicated dead-letter queue that workers never touch automatically.

Routing Exhausted Jobs to a DLQ

use Throwable;
use Illuminate\Queue\InteractsWithQueue;

class ProcessOrderExport implements ShouldQueue
{
    use InteractsWithQueue;

    public int $tries = 3;
    public int $backoff = 60;

    public function failed(Throwable $e): void
    {
        // Re-queue onto a monitored DLQ instead of only logging
        dispatch(new DeadLetterJob(
            originalClass: static::class,
            payload: $this->order->id,
            reason: $e->getMessage(),
        ))->onQueue('dead-letter');
    }
}

DeadLetterJob itself never retries ($tries = 1) and simply persists a record to a dead_letter_events table with full context. A Filament resource or a nightly alert then surfaces these for human review.


Graceful Degradation: Shedding Load Intentionally

Not every job is equal. Sending a transactional email is critical; regenerating a report thumbnail is not. Tag jobs with a priority tier and shed low-priority work under load.

enum QueueTier: string
{
    case Critical = 'critical';
    case Standard = 'default';
    case Background = 'background';
}

class RegenerateThumbnail implements ShouldQueue
{
    public string $queue = QueueTier::Background->value;
    public int $tries = 1; // shed silently on failure
}

In config/horizon.php (or your supervisor config) assign more workers to critical, fewer to background, and set --stop-when-empty on background workers during incidents:

'environments' => [
    'production' => [
        'supervisor-critical' => [
            'queue' => 'critical',
            'processes' => 10,
            'balance' => 'auto',
        ],
        'supervisor-background' => [
            'queue' => 'background',
            'processes' => 2,
            'balance' => 'simple',
        ],
    ],
],

During a degraded incident you can temporarily scale supervisor-background processes to zero via the Horizon API or a feature flag without touching critical workers.


Observability Glue

None of this works without metrics. Emit a gauge from a scheduled command:

Schedule::call(function () {
    foreach (['critical', 'default', 'background', 'dead-letter'] as $q) {
        $depth = (int) Redis::llen('queues:' . $q);
        Metric::gauge('queue.depth', $depth, ['queue' => $q]);
    }
})->everyMinute();

Alert when dead-letter depth grows or critical depth exceeds your SLA threshold.


Key Takeaways

  • Backpressure protects Redis and gives callers a clear signal to back off; return 503 from HTTP, skip from CLI.
  • Dead-letter queues isolate poison-pill jobs so they do not starve healthy workers on retries.
  • Tier your queues and assign worker counts per tier so you can shed background load without touching critical paths.
  • Metrics on queue depth per named queue are non-negotiable in production; alert before the backlog becomes a crisis.
  • Keep DeadLetterJob simple and non-retrying — its only job is durable persistence for human triage.

Found this useful?

Frequently Asked Questions

2 questions
Q01 Does Laravel's built-in failed_jobs table replace a dead-letter queue?
It covers the storage part, but failed_jobs does not prevent exhausted jobs from consuming retry attempts before landing there. A dedicated DLQ pattern routes jobs there immediately after the final failure, keeping your main queues clean and giving you a single place to monitor and replay problematic jobs.
Q02 How do I replay jobs from a dead-letter queue safely?
Store enough context in your dead_letter_events table (original class, serialized payload, failure reason, timestamp) to reconstruct and re-dispatch the original job. Add a Filament action or artisan command that deserializes the payload, instantiates the job, and dispatches it back to the original queue — optionally with a flag to skip the DLQ on the next failure so you can investigate interactively.

Continue reading

More Articles

View all