The comforting thing about a queued job is that it feels free. You call dispatch(), the request returns, the work happens later. At small scale that story holds. At volume it falls apart in two specific places, and neither shows up in the tutorials.
I learned both running a carrier-billing callback pipeline on Laravel queues: a firehose of events, tens of millions of them, fanned out to workers through Horizon. When you push that many jobs, two costs stop being rounding errors. Laravel shipped a first-party fix for each in the last couple of months, so this is a good moment to write down what they actually solve.
Cost one: dispatch is a database write
Here is the part people forget. Putting a job on the queue is I/O. For the database and Redis drivers, each dispatch() is an INSERT. Dispatch one job, one write. Dispatch a million jobs in a loop, a million writes, before a single worker has done anything useful. The dispatch loop itself becomes the bottleneck.
The usual reach is a batch (Bus::batch()), but a batch is built for tracking progress and running completion callbacks, so it writes on every job's completion too. If you don't need the progress tracking, you're paying for bookkeeping you'll never read.
Laravel 13's Bus::bulk() (June 2026) is the honest tool for "I just need these thousands of jobs on the queue, cheaply." It groups the jobs by queue and connection and does a single bulk insert per group:
use Illuminate\Support\Facades\Bus;
Bus::bulk(
$users->map(fn (User $user) => new ProcessUser($user))->all()
);
One insert per queue instead of one per job. At a few dozen jobs you'll never notice. At a million, it's the difference between dispatch being a spike that stalls the request and dispatch being a single round trip. This is the kind of thing you only appreciate after you've watched a naive foreach ($rows as $row) Job::dispatch($row) melt a database under load.
Cost two: the payload gets fat
The second cost is what you put inside the job. Every job serializes its constructor data into the queue message. Pass a model and Laravel's SerializesModels trait is smart about it, it stores just the primary key and re-fetches on the way out. But pass a big array, a raw API response, a collection of rows, or a file's contents, and all of that rides along in the message body.
That matters on SQS, which caps message size. It was 256 KB for years; AWS raised it to 1 MB in August 2025. Either way, a fat payload either blows the limit outright or bloats every message you move, and you pay for that in throughput.
The established workaround is the S3-pointer pattern: write the real payload to object storage, put a tiny pointer on the queue, and have the worker fetch it on the way out. There have been community packages for exactly this for years. As of Laravel 13 it's first-party (merged May 2026). You turn it on per connection:
// config/queue.php, on the sqs connection
'sqs' => [
'driver' => 'sqs',
// ...usual sqs config...
'extended_store_options' => [
'enabled' => true,
'disk' => 's3', // where oversized payloads go
'prefix' => 'sqs-payloads', // path prefix on that disk
'always' => false, // true = offload every payload, not just large ones
'cleanup' => true, // delete the file after the job succeeds
],
],
When a payload crosses the threshold (or always is set), Laravel writes it to the disk at {prefix}/{uuid}.json and sends SQS a small pointer message, roughly {"@pointer": "sqs-payloads/....json"}. On pop, the job detects the @pointer, pulls the real body off the disk, and runs as if nothing happened. cleanup deletes the file once the job succeeds so you don't accrete orphaned payloads. It's backward compatible and off by default, which is the right call.
The lesson under both
The two fixes look unrelated, but they share a root: a queue job is not free, and "async" doesn't mean "no cost." Dispatch is a write. The message is a payload you move. At volume you engineer both.
The discipline that actually keeps a high-volume queue healthy is older than either feature:
- Keep dispatch cheap. Bulk-insert when you're enqueuing in the thousands. Better yet, don't enqueue what you can collapse: one job that processes a batch beats a thousand jobs that each process one row, whenever the work allows it.
- Keep payloads thin. Pass identifiers, not objects. Let the job re-fetch what it needs at the moment it runs, when the data is fresh anyway.
// Fat: a big collection is serialized into the message body.
ProcessRows::dispatch($thousandsOfRows);
// Thin: pass the ids, re-fetch inside the job. The message stays tiny.
ProcessRows::dispatch($rowIds);
Offloading to S3 is the escape hatch for when the payload genuinely has to be big. It is not permission to stop thinking about payload size.
There's a third thing volume forces on you that no feature will hand you: idempotency. At a million jobs, retries are not a maybe, they are a certainty. Workers die, a deploy interrupts a batch, SQS redelivers. Every job has to be safe to run twice. I care about this more than most because I came up in payments, where running a charge twice is not a bug, it's a refund and an apology. Bulk dispatch makes this more important, not less: the easier it is to fling a million jobs onto the queue, the more certain it is that some of them will run again.
The take
Both of these shipped first-party in Laravel within a couple of months of each other, and that timing tells you something. The framework is catching up to what every high-volume Laravel shop already hand-rolled: a cheaper bulk dispatch, and a way to keep fat payloads out of the queue. If you've never felt these costs, you don't need either feature yet, and that's fine. If you have, you already know exactly which INSERT graph or which oversized-message error sent you looking, and you'll reach for these the day you hit them again.
The details are in the Laravel changelog for Bus::bulk() and in PR #59734 for the SQS payload offloading.