Why Queues Are Important for Designing and Building Resilient Systems

Imagine your application calls an external reporting API every time a customer requests a monthly report.

You already added timeouts. Your application no longer waits forever when the provider gets slow.

You also added retries. Temporary failures sometimes recover on the next attempt.

But now the customer clicks “Generate report,” and your application starts waiting.

The first attempt times out. Your application waits briefly and retries. The second attempt times out too.

Meanwhile, more customers request reports.

Each waiting request occupies a PHP worker. Soon, even users who aren’t generating reports notice that your application is getting slower.

What went wrong? Were your timeouts useless? Should you remove retries?

Not necessarily. Your application knows when to stop waiting and when to try again.

But it is still doing all that work inside the customer’s request.

Does the customer need to keep a browser request open while the report is generated?

Usually, no.

Your application can accept the request, process the report in the background, and let the customer know when it is ready.

That is where queues help.

But moving code into a job is only the beginning. You need three rules: move suitable work into the background, make failures recoverable, and keep the queue under control.

Here’s how.

Tip 1: Move work that can wait out of the customer’s request

Think of ordering food at a restaurant.

The waiter takes your order and sends it to the kitchen. They don’t stand beside the oven until your meal is ready before serving another customer.

Your application can work the same way.

The HTTP request accepts the work. A queue holds it. A worker processes it separately.

For example, after creating a report record, dispatch a job:

use App\Jobs\FetchMonthlyReport;

// $report is an existing record belonging to the authenticated user.
FetchMonthlyReport::dispatch($report->id)
    ->onQueue('reports')
    ->afterCommit();

return response()->json([
    'message' => 'Your report has been requested.',
    'report_id' => $report->id,
    'status' => 'pending',
], 202);

Use a background connection such as database or redis; sync executes jobs immediately. With the database driver, ensure the queue tables exist and migrations have run:

QUEUE_CONNECTION=database

Then run a worker:

php artisan queue:work database --queue=reports

afterCommit() defers dispatch until an active database transaction commits, preventing the worker from reading an uncommitted report. laravel.com

The customer now receives an acknowledgment without waiting for the reporting provider.

But be precise: “requested” does not mean “completed.”

Your interface should show a pending state and provide a way to check progress.

TaskUsually suitable for a queue?
Generating a monthly reportYes
Resizing an uploaded imageYes
Synchronizing a contact with a CRMYes
Checking permission before returning private dataNo—this must happen before access is granted

The question is simple: does this work need to finish before the user can continue?

One more detail: committing a database record and publishing a job are not automatically one atomic operation. For critical workflows, consider a transactional outbox to recover from a failure between those steps.

Tip 2: Make background jobs safe to retry

Moving a request into a queue does not make the external API more reliable.

It can still become slow. It can still return an error. Your worker can also restart halfway through processing.

The difference is that the customer no longer needs to wait while your application handles those problems.

Here is an example job for a read-only report lookup. It assumes a Report model with external_id, payload, and status fields, and a JSON cast for payload.

namespace App\Jobs;

use App\Models\Report;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Http;
use RuntimeException;

class FetchMonthlyReport implements ShouldQueue
{
    use Queueable;

    public $tries = 3;
    public $timeout = 15;

    public function __construct(public int $reportId) {}

    public function backoff(): array
    {
        return [10, 30];
    }

    public function handle(): void
    {
        $report = Report::findOrFail($this->reportId);

        if ($report->status === 'completed') {
            return;
        }

        $response = Http::connectTimeout(2)
            ->timeout(5)
            ->get(
                'https://reports.example.com/reports/'
                . rawurlencode($report->external_id)
            );

        if (in_array($response->status(), [502, 503, 504], true)) {
            $response->throw();
        }

        if (! $response->successful()) {
            $this->fail(new RuntimeException(
                "Report provider returned HTTP {$response->status()}"
            ));

            return;
        }

        $report->update([
            'payload' => $response->json(),
            'status' => 'completed',
        ]);
    }
}

The HTTP timeouts limit each call. Connection failures throw exceptions; selected HTTP errors are explicitly thrown so the job can retry. Other unsuccessful responses are treated as terminal in this example. Adapt that policy to the provider, including its rules for 429 responses. Validate the response payload before storing it in production. laravel.com

The job allows three total attempts. Its retry delays are 10 and 30 seconds. Keep the job timeout shorter than the connection’s retry_after setting—for example, 15 seconds versus 90 seconds. laravel.com

Notice that this job reads from the provider and updates one existing record.

If your job creates something externally, such as a shipment or a notification, retries require additional care.

A queue does not guarantee that your business operation happens exactly once.

The provider might accept the operation before your worker crashes. The next execution can repeat it.

Use a persisted idempotency key when the provider supports one. A local “completed” check alone cannot prevent duplicates in that situation.

Also avoid multiplying retries accidentally. Three job attempts with three HTTP attempts each can produce nine calls. This example leaves retries to the queue.

Tip 3: Control the backlog and make failures visible

Imagine a restaurant keeps accepting orders, but nobody checks whether the kitchen can finish them.

Customers receive order numbers immediately.

Their food never arrives.

A queue can create the same illusion: your API responds quickly while unfinished work keeps growing.

A fast acknowledgment is not proof of a healthy system.

Track these signals:

SignalWhat it tells you
Age of the oldest waiting jobHow long users are waiting
Failed jobsWhich tasks need investigation
Job durationWhether processing is slowing down
Arrival rate versus completion rateWhether the backlog is growing
Worker availabilityWhether anything is processing jobs

Use separate worker pools when slow reports must not consume the capacity needed for notifications:

# Run these as separate supervised processes.
php artisan queue:work database --queue=reports --timeout=15
php artisan queue:work database --queue=notifications --timeout=15

Separate workers help isolate processing capacity, although they can still compete for shared CPU, memory, and database resources.

More workers are not always the answer. If the provider is overloaded or rate-limited, increasing concurrency can make the problem worse.

Inspect failed jobs before retrying them:

php artisan queue:failed
php artisan queue:retry <job-id>

In production, use a process manager to keep workers running. Configure failed-job storage and alerting; recording a failure is not the same as notifying someone. laravel.com

Your application should also update the report’s status after terminal failure, so customers do not see “processing” forever.

Three small changes, a more resilient workflow

TipWhat it does
1. Move suitable work into the backgroundKeeps slow tasks out of the immediate response
2. Make jobs safe to retrySupports recovery without unintended duplicate effects
3. Monitor and control processingHelps prevent invisible failures and growing delays

Before shipping, test a temporary outage followed by recovery, a permanent error, repeated job execution, and a stopped worker.

Does the request return promptly? Does the job eventually finish or visibly fail? Can you recover without duplicating an operation?

A queue doesn’t repair a broken provider. It gives your application a way to manage suitable work independently of the customer’s request.

Timeouts limit waiting. Retries provide another chance. Idempotency makes repetition safer. Queues move that work into a workflow you can control and observe.

These are the decisions that help you build software people trust. They also give you concrete examples to discuss when interviewing for roles involving complex integrations.

Subscribe to discover the Secrets to Building Resilient Software! build-resilient-software-get-amazing-jobs

References

Leave a Comment