---
title: 'Laravel Queues in Production: Timeouts and failed_jobs | DevSense'
description: 'How to configure Laravel queues for production: retry_after and --timeout, retries and backoff, failed_jobs, memory in long-lived workers, heavy imports via batches, unique jobs, Supervisor and Horizon.'
faq:
    - { question: 'What is the difference between retry_after and --timeout in Laravel queues?', answer: 'retry_after is set in config/queue.php and tells the connection after how many seconds a reserved job should be considered stuck and handed out again. --timeout (or the #[Timeout] attribute on a job) limits how many seconds the worker lets a job run before exiting with an error. The timeout must be a few seconds shorter than retry_after, otherwise a job may run twice.' }
    - { question: 'Why did a queued job run twice?', answer: "Most often because the job ran longer than retry_after and the connection handed it to a second worker, or because the worker was killed mid-execution (a deploy, OOM, SIGKILL) and the job went back to the queue. That's why jobs must be idempotent and timeouts must line up: HTTP client < job timeout < retry_after." }
    - { question: "Why does a queue worker's memory keep growing?", answer: "A queue:work worker is a long-lived process that doesn't reboot the framework between jobs. Memory is accumulated by static arrays and stateful singletons, the query log, Telescope, GD images and large collections. Limit the worker's lifetime with the --max-jobs, --max-time and --memory flags and run it under Supervisor, which will start the process again." }
    - { question: 'What should you do with records in the failed_jobs table?', answer: "Don't retry them blindly. First look at the exception (queue:failed, Horizon), fix the cause, then retry the jobs with queue:retry. For notifications, use the job's failed() method or the Queue::failing event, and regularly delete old records with a scheduled queue:prune-failed." }
published: '2026-10-03'
---
# Laravel Queues in Production: Timeouts, failed_jobs, Memory and Heavy Jobs

Locally, queues "just work": `queue:work` in the next terminal tab, jobs finish in a second, no errors. In production the stories belong to a different genre. A customer got the same invoice email twice. A 400,000-row price list import has been spinning for three hours and has eaten two gigabytes of memory. Twenty thousand records have piled up in `failed_jobs`, and nobody knows which of them matter. After a deploy, workers spend a week running jobs with the old code.

This article is about configuring Laravel queues so they survive failures, deploys and large volumes: how `retry_after` and `--timeout` relate, how retries work, what to do with `failed_jobs`, why worker memory grows and how to split heavy imports. Running queues locally in Docker is covered in [Sail: queues and workers](../tools/sail-queues), and choosing a broker in the [message queue comparison](message-queues-compared).

**Related guides:** [Resilient Integrations](resilient-external-integrations) · [Zero-downtime deployment](zero-downtime-deployment-laravel) · [Eloquent and Large Datasets](eloquent-large-datasets) · [Observability and Monitoring](observability-monitoring-laravel)

## Contents

* [Job lifecycle: attempts, release, failure](#lifecycle)
* [retry_after and --timeout: the timeout ladder](#timeouts)
* [Retries: tries, backoff, retryUntil](#retries)
* [failed_jobs: triage, retry, cleanup](#failed-jobs)
* [Memory in long-lived workers](#memory)
* [Heavy imports and exports](#heavy-jobs)
* [Duplicates and races: unique jobs and after_commit](#uniqueness)
* [Separate queues and priorities](#priorities)
* [Supervisor and Horizon](#supervisor)
* [Queue monitoring](#monitoring)
* [Common Mistakes](#common-mistakes)
* [Checklist](#checklist)
* [Self-Test Quiz](#self-test-quiz)

---

<a id="lifecycle"></a>
## Job lifecycle: attempts, release, failure

When a worker picks up a job, an **attempt** begins. The attempt is consumed even if `handle()` didn't run to completion. According to the Laravel documentation, an attempt is "used up" by:

* an unhandled exception in the job;
* a manual release back to the queue via `$this->release()`;
* middleware such as `WithoutOverlapping` or `RateLimited` that failed to acquire a lock and released the job;
* exceeding the timeout;
* a successful run of `handle()`.

**By default Laravel makes a single attempt.** If a job fails, it's immediately considered failed and lands in `failed_jobs`. If you use `RateLimited` or `WithoutOverlapping`, one attempt is almost certainly not enough: the job "burns out" on its very first release without ever starting its work.

```
dispatch → [queue] → worker reserves → handle()
                          │                 │
                          │         ┌───────┴────────┐
                          │      success      exception / timeout / release
                          │         │                │
                          │      delete      attempts < tries? ──yes──→ back to queue (after backoff)
                          │                          │
                          │                          no
                          │                          ▼
                          └── worker died ──→   failed() + failed_jobs
                              (job reappears after retry_after)
```

---

<a id="timeouts"></a>
## retry_after and --timeout: the timeout ladder

These are two different mechanisms that are easy to mix up.

* **`retry_after`** is a connection setting in `config/queue.php`. It answers the question "after how many seconds should a reserved job be considered abandoned and handed to another worker". The broker doesn't know whether the worker is alive, so it relies on time alone. On Amazon SQS, the Visibility Timeout in the queue settings plays this role instead.
* **`--timeout`** on `queue:work` (or the `#[Timeout]` attribute on a job) is how many seconds the worker lets a job run. The default is 60 seconds. When time runs out, the worker process exits with an error and Supervisor starts a new one. Timeouts require the **pcntl** extension.

If `--timeout` is greater than `retry_after`, a job running longer than `retry_after` will be **handed to a second worker while the first one is still running it**. Hence duplicate emails, double charges and race conditions. The documentation states the rule plainly: `--timeout` must be at least several seconds shorter than `retry_after`.

In practice you need to align a whole ladder of values — each step larger than the previous one:

| Level | Where it's set | Example |
|---------|--------------|--------|
| HTTP client and SQL timeouts | `Http::timeout()`, `connect_timeout`, `statement_timeout` | 10–30 s |
| Job timeout | `#[Timeout(120)]` or `--timeout=120` | 120 s |
| Connection `retry_after` | `config/queue.php` | 150 s |
| Worker shutdown grace period | `stopwaitsecs` in Supervisor, `stop_grace_period` in Docker | 180 s |

```php
// config/queue.php
'redis' => [
    'driver' => 'redis',
    'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
    'queue' => env('REDIS_QUEUE', 'default'),
    'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 150),
    'block_for' => 5,
    'after_commit' => true,
],
```

```php
// app/Jobs/SyncSupplierPrices.php
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Queue\Attributes\Tries;

#[Tries(3)]
#[Timeout(120)]
final class SyncSupplierPrices implements ShouldQueue
{
    use Queueable;

    public function handle(SupplierClient $client): void
    {
        // The HTTP client has its own, shorter timeout: blocking IO
        // (sockets, HTTP) may not be interrupted by the job timeout.
        $client->withTimeout(seconds: 20)->syncPrices();
    }
}
```

> [!WARNING]
> **The job timeout doesn't interrupt a hung socket.** The documentation specifically warns that blocking I/O (sockets, outgoing HTTP connections) may not respond to the worker timeout. Always set your own timeouts on the HTTP client and database queries.

For Redis, `block_for` tells the driver how many seconds to wait for a new job in blocking mode. A value of `0` blocks the worker indefinitely — and it stops handling signals like `SIGTERM` until the next job arrives, which breaks graceful shutdown during deploys.

If a job must not be retried after a timeout (for example, it may already have sent data to an external system), mark it with the `#[FailOnTimeout]` attribute: a timeout then moves the job straight to failed, without retries.

---

<a id="retries"></a>
## Retries: tries, backoff, retryUntil

In Laravel 13, retry settings are conveniently declared with PHP attributes right on the job class (in older versions, via the `$tries`, `$timeout` and `$backoff` properties):

```php
use Illuminate\Queue\Attributes\Backoff;
use Illuminate\Queue\Attributes\MaxExceptions;
use Illuminate\Queue\Attributes\Tries;

#[Tries(10)]
#[MaxExceptions(3)]
#[Backoff([10, 60, 300])]
final class PushOrderToCrm implements ShouldQueue
{
    use Queueable;

    public function __construct(public int $orderId) {}

    public function middleware(): array
    {
        // Releases caused by rate limiting consume attempts, hence Tries(10),
        // while MaxExceptions(3) fails the job after three real errors.
        return [new RateLimited('crm')];
    }

    public function handle(CrmClient $crm, OrderSummaryQuery $orders): void
    {
        $crm->upsertOrder($orders->summary($this->orderId));
    }
}
```

* **`Tries`** — the maximum number of attempts. The value on the class takes precedence over the worker's `--tries` flag.
* **`MaxExceptions`** — after how many real exceptions the job fails, even if attempts remain. This lets you allow plenty of rate-limit releases without hammering a broken API ten times.
* **`Backoff`** — the delay before a retry after an exception. The array `[10, 60, 300]` sets increasing delays: 10 seconds, a minute, and five minutes for the third and subsequent retries.
* **`retryUntil()`** — an alternative to an attempt count: retry any number of times, but no later than the given moment. If both `tries` and `retryUntil()` are set, `retryUntil()` wins.

```php
public function retryUntil(): DateTime
{
    return now()->addMinutes(30);
}
```

For unstable external APIs, the `ThrottlesExceptions` middleware is useful: after a series of errors it delays jobs for a set period instead of burning attempts on a service that's clearly down. It's the Circuit Breaker pattern implemented at the queue level (covered in detail in the article on [resilient integrations](resilient-external-integrations)).

```php
public function middleware(): array
{
    return [(new ThrottlesExceptions(10, 5 * 60))->backoff(5)];
}
```

> [!NOTE]
> **A retry is safe only if the job is idempotent.** A job may run again after a timeout, a worker crash or your own `queue:retry`. Use `upsert` instead of `insert`, idempotency keys with payment APIs, and an "already done?" check at the start of `handle()`.

---

<a id="failed-jobs"></a>
## failed_jobs: triage, retry, cleanup

Once the attempts run out, Laravel calls the job's `failed()` method and writes it to the `failed_jobs` table along with the payload and the exception text.

```php
public function failed(?Throwable $exception): void
{
    // Runs in the worker after the last attempt: notify, compensate, mark state.
    Order::whereKey($this->orderId)->update(['crm_sync_status' => 'failed']);

    report($exception);
}
```

The working cycle for failed jobs:

```bash
php artisan queue:failed                 # list failed jobs with exception summaries
php artisan queue:retry <uuid>           # retry one job after fixing the cause
php artisan queue:retry --queue=crm      # retry every failed job from one queue
php artisan queue:retry all              # retry everything (use with care)
php artisan queue:forget <uuid>          # delete one record
php artisan queue:prune-failed --hours=168
```

Rules that will save your nerves:

* **Don't run `queue:retry all` blindly.** If the cause isn't fixed, the jobs will fail again, and non-idempotent ones will manage to do something a second time.
* **Clean the table on a schedule.** By default `queue:prune-failed` deletes records older than 24 hours; set `--hours` to fit your triage process.
* **Delete jobs whose models were deleted.** If a model was deleted while the job was waiting in the queue, the `#[DeleteWhenMissingModels]` attribute quietly deletes the job instead of failing with `ModelNotFoundException`.
* **Alert on the trend, not on every error.** One failed job an hour is noise; a hundred a minute is an incident. The `Queue::failing()` event is handy for a metrics counter.

```php
// routes/console.php
Schedule::command('queue:prune-failed --hours=168')->daily();
Schedule::command('queue:prune-batches --hours=48 --unfinished=72')->daily();
```

---

<a id="memory"></a>
## Memory in long-lived workers

`queue:work` is a daemon: it boots the application once and runs jobs in a loop without restarting the framework. That's fast, but anything a job leaves in memory stays there until the process dies. Typical sources of growth:

* static arrays and singletons that accumulate state (a "cache" in a service property);
* the query log (`DB::enableQueryLog()`), Telescope and Debugbar in production;
* GD/Imagick resources without `imagedestroy()` or `clear()`;
* large collections loaded in full via `get()` instead of `lazyById()`.

The defence is to cap the worker's lifetime and let the process manager restart it:

```bash
php artisan queue:work redis --queue=default \
    --tries=3 --timeout=120 \
    --max-jobs=1000 --max-time=3600 --memory=256
```

* `--max-jobs` — exit after N jobs;
* `--max-time` — exit after N seconds of running;
* `--memory` — exit if, after a job, the process uses more than N megabytes (128 by default).

It's important to understand the order: `--memory` is checked **between** jobs. If a single job on its own exceeds PHP's `memory_limit`, the process dies with a fatal error mid-execution, the job returns to the queue only after `retry_after` — and fails again. Such jobs shouldn't be "treated" with limits; they need to be rewritten: stream processing and splitting into parts.

---

<a id="heavy-jobs"></a>
## Heavy imports and exports

A single "import a 400,000-row file" job is everything at once: long execution, timeout risk, memory growth and zero progress if it fails on row 399,999. A scheme that works is to break the work into many small idempotent jobs and combine them into a **batch**.

```php
// app/Domain/Catalog/Actions/StartPriceImport.php
public function handle(string $path, int $userId): Batch
{
    $jobs = [];
    foreach ($this->reader->chunkOffsets($path, rowsPerChunk: 2000) as [$from, $to]) {
        $jobs[] = new ImportPriceChunk($path, $from, $to);
    }

    return Bus::batch($jobs)
        ->name("price-import:{$userId}")
        ->onQueue('imports')
        ->allowFailures()
        ->then(fn (Batch $batch) => PriceImportFinished::dispatch($userId, $batch->id))
        ->catch(fn (Batch $batch, Throwable $e) => report($e))
        ->finally(fn (Batch $batch) => Storage::delete($path))
        ->dispatch();
}
```

```php
// app/Jobs/ImportPriceChunk.php
use Illuminate\Bus\Batchable;
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Queue\Attributes\Tries;

#[Tries(3)]
#[Timeout(90)]
final class ImportPriceChunk implements ShouldQueue
{
    use Batchable, Queueable;

    public function __construct(
        public string $path,
        public int $fromRow,
        public int $toRow,
    ) {}

    public function handle(PriceFileReader $reader): void
    {
        if ($this->batch()?->cancelled()) {
            return;
        }

        $rows = $reader->rows($this->path, $this->fromRow, $this->toRow);

        // Idempotent: re-running the same chunk overwrites the same SKUs.
        DB::table('product_prices')->upsert($rows, uniqueBy: ['sku'], update: ['price_cents', 'updated_at']);
    }
}
```

What matters here:

* **The job receives the coordinates of the work, not the data.** A file path and a row range weigh a few bytes. An array of two thousand rows in the constructor bloats the payload, Redis memory and the `failed_jobs` table.
* **Each part is idempotent.** An `upsert` by `sku` gives the same result on a retry.
* **Progress is visible.** `$batch->progress()`, `processedJobs()` and `failedJobs` can be shown to the user. Batches need the `job_batches` table (`php artisan make:queue-batches-table`), and it also needs cleaning with `queue:prune-batches`.
* **`allowFailures()`** keeps a single broken part from cancelling the whole import; without it, the first error cancels the batch.
* **A separate `imports` queue** with its own workers keeps the import from delaying emails and notifications.

The same logic applies to exports: the job streams data via `lazyById()` (see [Eloquent and Large Datasets](eloquent-large-datasets#export)), writes a file to storage and sends the user a link.

If a job accepts a model, Laravel serializes it along with its loaded relations. The `#[WithoutRelations]` attribute (or `$model->withoutRelations()`) keeps only the identifier in the payload — the model is reloaded from the database when the job runs.

---

<a id="uniqueness"></a>
## Duplicates and races: unique jobs and after_commit

**The job starts before the data is committed.** A classic bug: the job is dispatched inside a transaction, a worker picks it up instantly, looks up the order by ID — and the transaction hasn't been committed yet. `ModelNotFoundException`, or worse — the job works with stale data. Solutions:

* `'after_commit' => true` in the connection settings — all jobs, queued events, mail and notifications wait for the commit;
* the `ShouldQueueAfterCommit` interface on a specific job;
* `->afterCommit()` at dispatch time.

**The same job is queued several times.** A user clicked "Recalculate" three times, and there are now three identical heavy jobs in the queue. The `ShouldBeUnique` interface won't let a second one be queued until the first has finished:

```php
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Queue\Attributes\UniqueFor;

#[UniqueFor(3600)]
final class RecalculateCustomerStats implements ShouldQueue, ShouldBeUnique
{
    use Queueable;

    public function __construct(public int $customerId) {}

    public function uniqueId(): string
    {
        return (string) $this->customerId;
    }
}
```

Uniqueness relies on an atomic lock in the cache (Redis, database, memcached). The lock is released once the job completes or finally fails; `ShouldBeUniqueUntilProcessing` releases it right before execution starts, allowing the next job to be queued while the current one runs. Uniqueness doesn't apply inside batches.

**Two different jobs modify the same data at the same time.** This is where the `WithoutOverlapping` middleware with a per-entity key helps: jobs with the same key run one after another.

```php
public function middleware(): array
{
    return [(new WithoutOverlapping($this->customerId))->releaseAfter(30)->expireAfter(180)];
}
```

`expireAfter` is mandatory in practice: if a worker dies mid-job, a lock without an expiry stays forever.

---

<a id="priorities"></a>
## Separate queues and priorities

A single `default` queue for everything is a common cause of "the password reset email arrived twenty minutes later" complaints: ten thousand import jobs were ahead of it. Separate jobs by the nature of their load:

* `high` — what the user is waiting for: password reset emails, notifications, payment webhooks;
* `default` — regular background jobs;
* `imports` / `reports` — heavy and long-running, with their own timeouts and memory limits.

A worker processes queues by priority from left to right: `--queue=high,default`. For heavy queues, run separate workers with different `--timeout` and `--memory`, and therefore a separate connection with a suitable `retry_after`.

In Laravel 13 job-to-queue routing can be defined in one place instead of `->onQueue()` on every dispatch:

```php
// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Queue;

public function boot(): void
{
    Queue::route([
        SendPasswordResetLink::class => 'high',
        ImportPriceChunk::class => ['redis-long', 'imports'],
    ]);
}
```

---

<a id="supervisor"></a>
## Supervisor and Horizon

Workers must run continuously and come back up after a crash, `--max-time` or `queue:restart`. Without a process manager, that won't happen.

```ini
; /etc/supervisor/conf.d/laravel-worker.conf
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/current/artisan queue:work redis --queue=high,default --tries=3 --timeout=120 --max-time=3600 --memory=256
autostart=true
autorestart=true
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/www/app/shared/storage/logs/worker.log
stopwaitsecs=180

[program:laravel-imports]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/current/artisan queue:work redis-long --queue=imports --timeout=600 --memory=512
autostart=true
autorestart=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/app/shared/storage/logs/imports.log
stopwaitsecs=660
```

`stopwaitsecs` must be longer than the longest job in that group: on stop, Supervisor sends `SIGTERM`, the worker finishes its current job, and only after `stopwaitsecs` expires is the process killed with `SIGKILL`. In Docker, `stop_grace_period` plays the same role.

**Horizon** is more convenient for Redis-backed queues: worker configuration in code, automatic process balancing across queues, and a dashboard with metrics, failed jobs and tags.

```php
// config/horizon.php
'environments' => [
    'production' => [
        'supervisor-default' => [
            'connection' => 'redis',
            'queue' => ['high', 'default'],
            'balance' => 'auto',
            'autoScalingStrategy' => 'time',
            'minProcesses' => 2,
            'maxProcesses' => 10,
            'tries' => 3,
            'timeout' => 120,
            'memory' => 256,
            'maxTime' => 3600,
            'maxJobs' => 1000,
        ],
        'supervisor-imports' => [
            'connection' => 'redis-long',
            'queue' => ['imports'],
            'balance' => false,
            'maxProcesses' => 2,
            'timeout' => 600,
            'memory' => 512,
        ],
    ],
],
```

The timeout rule applies here too: a Horizon supervisor's `timeout` must be a few seconds shorter than the connection's `retry_after` and at the same time longer than the timeout of any individual job. Horizon metrics are built from snapshots — add `Schedule::command('horizon:snapshot')->everyFiveMinutes()`.

**Deploys.** Workers hold the code in memory and don't see changes without a restart. After switching the release, run `php artisan queue:restart` (or `php artisan horizon:terminate`): workers finish their current jobs and exit, and Supervisor starts them again with the new code. The restart signal is stored in the cache, so the cache must be shared across all servers. Compatibility of old job payloads with new code is a separate topic, covered in the article on [zero-downtime deployment](zero-downtime-deployment-laravel#queues).

---

<a id="monitoring"></a>
## Queue monitoring

Queues break silently: the website works, there are no 500 errors, and emails haven't gone out for three hours. The minimum set of signals:

* **Queue length and wait time** — a growing queue means there aren't enough workers or they've stalled.
* **Failure rate** — the number of records in `failed_jobs` per interval.
* **Worker liveness** — Horizon shows the status of its supervisors; without Horizon, monitor processes through Supervisor.

The built-in `queue:monitor` command checks queue sizes and, when a threshold is exceeded, dispatches a `QueueBusy` event you can attach a notification to:

```php
// routes/console.php
Schedule::command('queue:monitor redis:high,redis:default --max=500')->everyMinute();
```

More on metrics, logs and alerts in the article on [observability](observability-monitoring-laravel).

---

<a id="common-mistakes"></a>
## Common Mistakes

**1. `--timeout` greater than `retry_after`.**
A long job is handed to a second worker while the first is still running it. The result is duplicates and races.

**2. No timeout on the HTTP client.**
A hung socket isn't always interrupted by the job timeout. The worker stalls and the queue grows.

**3. The default single attempt combined with `RateLimited` or `WithoutOverlapping`.**
The very first release fails the job. Increase `Tries` and cap errors with `MaxExceptions`.

**4. Non-idempotent jobs.**
A retry after a timeout, a worker crash or `queue:retry` sends a second email or makes a second charge.

**5. Data in the payload instead of identifiers.**
Large arrays and models with relations bloat Redis and `failed_jobs`. Pass IDs and file paths.

**6. Dispatching a job from a transaction without `after_commit`.**
The worker can't find a record that hasn't been committed yet, or works with stale data.

**7. Workers with no lifetime limits.**
Memory grows for weeks until the OOM killer kills the process mid-job. Use `--max-jobs`, `--max-time`, `--memory`.

**8. Forgetting `queue:restart` after a deploy.**
Workers run jobs with the old code against the new database schema.

**9. `queue:retry all` without analysing the causes.**
Jobs fail again, and non-idempotent ones manage to repeat their side effects.

---

<a id="checklist"></a>
## Checklist

1. The timeout ladder is aligned: HTTP/SQL < job timeout < `retry_after` < `stopwaitsecs` / `stop_grace_period`.
2. The pcntl extension is installed; Redis `block_for` is not `0`.
3. Every job has deliberately chosen `Tries`, `Backoff` and, where needed, `MaxExceptions` or `retryUntil()`.
4. Jobs are idempotent; external calls use idempotency keys.
5. Jobs are dispatched after the commit (`after_commit` or `ShouldQueueAfterCommit`).
6. Payloads hold identifiers and paths, not large data; models without relations.
7. Heavy imports are split into batches with a separate queue and their own workers.
8. Workers restart via `--max-jobs`, `--max-time`, `--memory` under Supervisor or Horizon.
9. `queue:restart` / `horizon:terminate` is a mandatory deploy step.
10. `queue:prune-failed` and `queue:prune-batches` are scheduled; alerts are set up for queue size and failure rate.

---

## Summary

Queues in production are only as reliable as their numbers are aligned and their jobs are idempotent. Timeouts form a ladder, retries are tuned to the nature of the error, heavy work is split into small repeatable parts, and workers live for a limited time and restart on every deploy. Then `failed_jobs` turns from a graveyard into a working list you can triage and retry.

---

<a id="self-test-quiz"></a>
## Self-Test Quiz

### Question 1: The connection's `retry_after` is 90 seconds, and the worker runs with `--timeout=300`. A job takes 200 seconds. What happens?
- A) The job completes without issues — the worker has timeout headroom.
- B) After 90 seconds the connection hands the job to another worker, and it starts running a second time in parallel with the first.
- C) The worker interrupts the job after 90 seconds.

<details>
<summary>Click to view the answer</summary>

**Answer: B**
`retry_after` is tracked by the broker, which doesn't know whether the worker is alive. After 90 seconds the job is considered abandoned and handed out again. That's why `--timeout` must be a few seconds shorter than `retry_after`.
</details>

### Question 2: A job uses the `RateLimited` middleware and ends up in `failed_jobs` without ever running `handle()`. Why?
- A) `RateLimited` doesn't work with the Redis driver.
- B) Releasing the job back to the queue because of the limit consumes an attempt, and by default a job has only one attempt.
- C) Middleware runs after `handle()`.

<details>
<summary>Click to view the answer</summary>

**Answer: B**
Every `release()` is a consumed attempt. For rate-limited jobs, increase `Tries` (or use `retryUntil()`) and cap the number of real errors with `MaxExceptions`.
</details>

### Question 3: What's the right way to import a 400,000-row file through a queue?
- A) A single job with `#[Timeout(7200)]` and `--memory=4096` on the worker.
- B) A batch of small idempotent jobs, each processing its own range of rows, in a separate queue with its own workers.
- C) Pass all the file's rows into the job constructor so the worker doesn't have to read the file.

<details>
<summary>Click to view the answer</summary>

**Answer: B**
Small parts fit within timeouts and memory limits, are retried independently and show progress. Option A loses all the work if it fails on the last row, and option C bloats the payload, Redis memory and `failed_jobs`.
</details>