← Back to blog
Backend Jan 16, 2026 · 4 min read

Queues at Scale: What Actually Breaks First

Queues at Scale: What Actually Breaks First

The throughput fallacy: Why adding workers doesn't save you

When an asynchronous background processing system begins backing up under high traffic, the instinctive reaction is to treat it as a throughput problem: spin up more worker processes, scale out CPU resources, and process more jobs per second.

In high-scale production systems (processing millions of daily jobs on Laravel Horizon or Redis), throughput is rarely the root cause of queue outages. Instead, the first component that breaks is worker capacity starvation.

graph TD
    Queue["Incoming Queue<br/>10,000 Fast Jobs Waiting"] --> Workers["20 Worker Processes<br/>(100% Occupied)"]
    API["Slow External API Calls<br/>(Hangs 60s, No Timeout)"] --> Workers
    Workers -->|Worker Starvation| Blocked["System Throughput = 0 req/s<br/>Queue Lag Spikes to Infinity"]

Consider a worker pool of 20 php artisan queue:work processes handling 5,000 jobs per minute. If 20 incoming jobs call an external third-party API that hangs due to network degradation, all 20 worker processes freeze simultaneously. System throughput drops to zero instantly, and thousands of critical, fast background tasks (such as transactional emails or authorization webhooks) pile up behind the blocked workers.

Hard timeouts, HTTP deadlines, and queue isolation

Preventing worker starvation requires multi-layered defensive programming across job configuration, network requests, and worker pool topologies.

1. Enforce Hard Job Timeouts

Every single job class must define an explicit $timeout property. Relying on default worker timeouts is dangerous because a hung socket or un-timeout-configured C extension call can sit indefinitely, quietly draining worker availability without emitting errors:

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;

class ProcessThirdPartyWebhookJob implements ShouldQueue
{
    use Dispatchable, Queueable;

    // Hard PCNTL signal timeout (in seconds)
    public int $timeout = 15;

    // Automatically fail the job if execution time is exceeded
    public bool $failOnTimeout = true;

    // Maximum retry attempts
    public int $tries = 3;

    public function handle(): void
    {
        // Explicit HTTP client timeout (must be LOWER than $timeout)
        $response = Http::timeout(5)
            ->connectTimeout(2)
            ->post('https://api.thirdparty.com/webhook', [
                'data' => $this->payload,
            ]);
    }
}

2. Queue Isolation and Priority Partitioning

Never mix latency-sensitive, fast tasks with heavy, long-running processes on the same queue worker pool.

Laravel Queue Worker Failure Modes and Mitigation

Partition work across isolated queues and assign dedicated worker pools to each:

# Worker Pool A: High-priority, latency-critical jobs (5-second timeout)
php artisan queue:work --queue=high,notifications --timeout=5 --tries=2

# Worker Pool B: Standard application background tasks
php artisan queue:work --queue=default --timeout=30 --tries=3

# Worker Pool C: Heavy, long-running reports and PDF exports
php artisan queue:work --queue=low,reports,exports --timeout=300 --tries=1

By separating queues, a failure or spike in long-running PDF exports will never starve high-priority user notifications.

The Danger of Non-Idempotent Retries

The second most common source of production incidents is retry storms on non-idempotent jobs.

Consider a payment processing job (ProcessPaymentJob). The job calls a payment gateway API. The payment succeeds at the gateway, but a temporary network jitter prevents the HTTP response from returning to the worker before the 10-second timeout fires.

The queue runner assumes the job failed and schedules a retry attempt. Without idempotency guards, the retried job executes the payment gateway call a second time, resulting in a double-charge incident!

Implementing Idempotency Guards with Atomic Locks

Every job that modifies state or triggers side effects must implement idempotency keys using atomic locks:

namespace App\Jobs;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;

class ChargeSubscriptionJob implements ShouldQueue
{
    public function __construct(
        public string $subscriptionId,
        public int $amountCents,
        public string $idempotencyKey
    ) {}

    public function handle(PaymentGateway $gateway): void
    {
        // Acquire an atomic lock for 2 minutes using the idempotency key
        $lock = Cache::lock('payment_lock:' . $this->idempotencyKey, 120);

        if (! $lock->get()) {
            Log::warning('Duplicate payment execution prevented by idempotency lock', [
                'key' => $this->idempotencyKey,
            ]);
            return;
        }

        try {
            // Check if payment was already recorded in database
            if (Payment::where('idempotency_key', $this->idempotencyKey)->exists()) {
                return;
            }

            // Perform external payment gateway charge
            $charge = $gateway->charge($this->amountCents, $this->idempotencyKey);

            Payment::create([
                'subscription_id' => $this->subscriptionId,
                'idempotency_key' => $this->idempotencyKey,
                'transaction_reference' => $charge->id,
            ]);
        } finally {
            $lock->release();
        }
    }
}

Payload Serialization Optimization

A subtle memory bottleneck at scale involves how job instances serialize payloads into Redis.

Using Laravel's SerializesModels trait automatically serializes Eloquent models into JSON references (class and id). However, passing raw array structures containing large nested datasets bloats Redis memory usage:

// ❌ Bad: Serializes massive 5MB array into Redis memory
dispatch(new ExportReportJob($fiveMegabyteDataArray));

// ✅ Good: Store data in temporary storage and pass reference key
$storagePath = Storage::put('temp/export_' . $id . '.json', json_encode($data));
dispatch(new ExportReportJob($storagePath));

Keeping payload sizes small reduces Redis memory saturation and minimizes network bandwidth consumed during job popping operations.

Production Checklist for Queue Resilience

Implementing these architectural patterns across a 10M+ daily job environment yielded a 95% reduction in queue-related production incidents:

Best Practice Implementation Impact
Strict Timeouts $timeout on every job class Eliminates phantom worker starvation
HTTP Timeouts Http::timeout(5) on external APIs Prevents hung socket locks
Queue Partitioning Separate high, default, low queues Protects critical user workflows
Idempotency Locks Cache::lock() with unique transaction keys Prevents duplicate payment/action retries
Light Payloads Pass model IDs/references instead of arrays Reduces Redis memory usage by ~80%