The fastest way to make a web app feel slow is to make the user wait for something they shouldn't have to wait for. Sending a welcome email, generating a PDF, resizing an uploaded image, syncing data to a third-party API — none of that needs to happen before the response returns. But without a queue, it does.

Laravel's queue system is one of the most practical features in the framework. It moves slow work off the request cycle, and Laravel Horizon gives you a dashboard to watch it happen.

What a Queue Actually Does

A queue is a list of work to be done later. Your app adds a job to the list, returns a response immediately, and a separate process works through the list in the background. The user sees a fast response. The slow work still happens — just not in the time the user is waiting.

This matters more than most teams realize until they run into it. A user uploading a CSV with 5,000 rows shouldn't stare at a spinner while your app processes every row synchronously. Dispatch a job, respond with "we're on it," and let the queue handle the rest.

Writing a Job

A Laravel job is a class with a handle() method. That's where the work goes.

class ProcessCsvImport implements ShouldQueue
{
    public function __construct(public Import $import) {}

    public function handle(): void
    {
        $rows = $this->import->parsedRows();

        foreach ($rows as $row) {
            User::create($row);
        }
    }
}

Dispatch it from your controller:

ProcessCsvImport::dispatch($import);

return response()->json(['message' => 'Import started. We\'ll email you when it\'s done.']);

The controller returns in milliseconds. The import runs in the background. The user gets a clear, honest response instead of a timeout.

Retries and Failure Handling

Background jobs fail. Network blips, third-party APIs being down, a database constraint you didn't anticipate. The queue system handles this gracefully.

class SyncToStripe implements ShouldQueue
{
    public int $tries = 3;
    public int $backoff = 60; // seconds between retries

    public function failed(Throwable $exception): void
    {
        // notify the team, log the failure, etc.
    }
}

Set $tries and the job automatically retries on failure. The failed() method gives you a hook to alert your team or log context before the job gives up. Jobs that exhaust all retries land in the failed_jobs table, where you can inspect them and re-dispatch with queue:retry.

Prioritizing Work

Not all jobs are equal. A password reset email should go out immediately. A weekly digest can wait. Laravel queues support named queues for exactly this:

PasswordResetEmail::dispatch($user)->onQueue('high');
WeeklyDigest::dispatch($user)->onQueue('low');

Your queue workers can be configured to drain the high queue before touching low. Critical work ships fast; lower-priority work fills in around it.

Horizon: Visibility into Your Queue

Running queues without observability is flying blind. Laravel Horizon is a first-party dashboard that shows you what's running, what failed, how long jobs are taking, and how much throughput your workers are handling.

You configure your workers in config/horizon.php — how many processes to run, which queues they watch, and how to scale under load:

'production' => [
    'supervisor-1' => [
        'maxProcesses' => 10,
        'queue'        => ['high', 'default', 'low'],
        'balance'      => 'auto',
    ],
],

With balance set to auto, Horizon monitors queue depths and spins up additional processes where the backlog is growing. During a spike, it scales; during quiet periods, it scales back down.

The Horizon dashboard lives at /horizon in your app. You can restrict access to authenticated users or internal IPs using Horizon's authorization gate.

What to Queue vs. What to Keep Synchronous

Not everything belongs in a queue. A quick database write — fine to keep synchronous. Anything that touches a third-party API, generates files, sends messages, or processes more than a handful of records — queue it.

The rule of thumb: if you're unsure, ask whether the user actually needs the result before the page can render. If no, it's a queue candidate.

The Compounding Benefit

Fast UIs retain users. Background jobs let you do more work per request without making users pay the cost in wait time. Add Horizon on top and you have real visibility — you can spot a job that's consistently slow and fix it before users notice.

This isn't optimization for its own sake. It's the difference between an app that feels responsive and one that feels like it's always a half-beat behind.

If your app has slow requests you've been tolerating, there's a good chance queues are the fix. Let's look at what's worth offloading.