In 30 seconds
A queue is a to-do list for your backend. The web request adds a job (send this email, sync this order) and returns immediately; separate worker processes pick jobs up and run them, retrying on failure. Queues make apps faster for users and more resilient when external services are slow or down.
Why I use it
- I use queues because emails, SMS, payment callbacks, ERP sync and reports should never block a customer's request.
- I use retries with backoff because external APIs fail all the time and most failures are temporary.
- I use queues for webhooks in both directions, so a partner outage never takes my API down with it.
- I make jobs idempotent so a retry never charges twice or sends the same SMS twice.
How it works
01Why queues: producers, consumers and brokers
Picture a restaurant ticket rail. The waiter (producer) pins an order ticket and goes back to customers; cooks (consumers) take tickets when they are free. If ten orders arrive at once, tickets wait on the rail instead of the waiter freezing. In software the rail is a broker: Redis, a database table, RabbitMQ or SQS. Your web app is the producer; worker processes are consumers. Benefits: fast responses, smoothing traffic spikes, isolating failures of slow services, and scaling workers independently from web servers. The cost: work becomes asynchronous, so you need to think about retries, duplicates, ordering and monitoring.
- Job: a small message with a type and data, ideally just ids, not whole objects.
- Queue names separate priorities: critical, default, emails, reports.
- Concurrency: how many jobs a worker runs at the same time.
02Retries, backoff and dead-letter queues
Jobs fail: a payment API times out, a database deadlocks, a third party returns 503. Retry, but carefully. Exponential backoff waits longer each time (for example 10 seconds, 1 minute, 5 minutes), giving the other side time to recover; jitter spreads retries so they do not all hit at once. Limit attempts. Distinguish errors: a timeout is worth retrying; a validation error ('customer does not exist') will fail forever, so mark it failed immediately. After the last attempt, move the job to a dead-letter queue (in Laravel, the failed_jobs table; in BullMQ, the failed set). Somebody must look at it: alert on failure counts and build a way to inspect and replay jobs after fixing the cause.
class SyncOrderToErp implements ShouldQueue
{
use Queueable;
public int $tries = 5;
public int $timeout = 60;
public function __construct(public int $orderId) {}
public function backoff(): array
{
return [10, 60, 300, 900];
}
public function handle(ErpClient $erp): void
{
$order = Order::findOrFail($this->orderId);
$erp->upsertOrder($order); // idempotent by order id on the ERP side
}
}
03Idempotent jobs: safe to run twice
Most queues guarantee at-least-once delivery: a job can run twice, for example if a worker crashes after doing the work but before acknowledging it. So every job must be idempotent, meaning running it again has no extra effect. Techniques: use a natural unique key (create the invoice for order 123 only if it does not exist), store a processed-marker with a unique constraint, use upserts instead of inserts, and pass an idempotency key to payment providers. Check state before acting ('already sent?'). Exactly-once delivery is largely a myth across systems; exactly-once effect comes from idempotent handlers. Also avoid a classic race: dispatch jobs only after the database transaction commits, otherwise a worker may pick up a job for a row that does not exist yet.
04BullMQ in Node
BullMQ is the standard Redis-based queue for Node. A Queue adds jobs, a Worker processes them with a set concurrency, and options control attempts, backoff, delays, priority and cleanup. Set a jobId to deduplicate: adding a job with an existing id is ignored. Repeating work uses job schedulers (upsertJobScheduler) with a cron pattern or interval; in BullMQ v6 the old repeat option was removed in favour of schedulers. Run workers as separate processes from the API so heavy jobs never slow down requests, and give each queue its own concurrency. Redis must be configured with maxmemory-policy noeviction, otherwise Redis may silently delete jobs.
import { Queue, Worker } from 'bullmq';
const connection = { host: '127.0.0.1', port: 6379 };
export const webhooks = new Queue('webhooks', { connection });
await webhooks.add('deliver', { eventId }, {
jobId: 'evt-' + eventId, // dedupe
attempts: 8,
backoff: { type: 'exponential', delay: 5_000 },
removeOnComplete: 1000,
removeOnFail: 5000,
});
new Worker('webhooks', async (job) => deliverWebhook(job.data.eventId), {
connection,
concurrency: 10,
});
05Scheduling and running workers in production
Scheduled work (nightly reports, expiring subscriptions, hourly syncs) runs on a timer. Classic cron runs a command at fixed times; Laravel's scheduler needs one cron entry and defines the rest in code; BullMQ schedulers store schedules in Redis. Make scheduled jobs safe to overlap or prevent overlap explicitly, and run them on one server only. Workers are long-running processes, so a process manager must keep them alive: supervisor or systemd for Laravel workers, pm2 or systemd for Node, or one container per worker type in Docker. Restart workers on every deploy so they load new code, and give them graceful shutdown so a job in progress can finish.
; /etc/supervisor/conf.d/app-worker.conf
[program:app-worker]
command=php /var/www/app/artisan queue:work redis --queue=critical,default --sleep=3 --tries=3 --max-time=3600
process_name=%(program_name)s_%(process_num)02d
numprocs=2
user=app
autostart=true
autorestart=true
stopwaitsecs=3600
stdout_logfile=/var/log/app-worker.log
06Webhooks in and out
Incoming webhooks (from payment providers, ERPs, delivery services): verify the signature, store the raw event, respond 200 quickly, and process it in a job. Providers retry, so deduplicate by their event id. Never trust the payload alone for money: re-fetch the payment status from the provider's API. Outgoing webhooks (to your partners): write the event to an outbox table inside the same transaction as the business change, let a worker deliver it, sign the body with HMAC-SHA256 and a timestamp so receivers can verify it and reject replays, retry with backoff for a day or more, and show delivery logs to the partner. This outbox pattern guarantees you never notify about something that was rolled back, nor lose an event that was committed.
Use it when
- Emails, SMS, push notifications and anything calling a third-party API.
- Heavy work: exports, imports, image processing, report generation.
- Syncing with ERP, accounting or fiscal systems that can be slow or offline.
- Receiving and sending webhooks, and fanning out domain events.
Skip it when
- When the user needs the result right now to continue (validate a coupon, show the price).
- Tiny, fast operations where queue overhead and complexity add nothing.
- As a replacement for a database transaction when the steps must succeed or fail together.
Alternatives
- Laravel queues + Horizon
Built-in queues with Redis, database or SQS drivers; Horizon adds a dashboard.
- RabbitMQ
Full message broker with routing and acknowledgements; good for many services.
- Amazon SQS
Fully managed queue; no servers, pay per request.
- Kafka
Event log for high-throughput streaming and replay; more than most products need.
Best practices
- Pass ids in jobs, not whole objects, and load fresh data in the handler.
- Make every job idempotent; assume it will run more than once.
- Retry with exponential backoff and a limit; fail fast on permanent errors.
- Dispatch jobs after the database transaction commits.
- Separate queues by priority and run workers under a process manager.
- Alert on failed jobs and queue depth; build a replay tool.
- Sign outgoing webhooks and verify incoming ones.
Common mistakes
- Non-idempotent jobs that double-charge or double-send on retry.
- Retrying forever with no backoff, hammering a service that is already down.
- Nobody watching the failed jobs table for months.
- Forgetting to restart workers on deploy.
- Redis with an eviction policy that deletes queued jobs under memory pressure.
Deep dive
07How a job is 'reserved': visibility timeouts, locks and stalled jobs
When a worker takes a job, the broker does not delete it. It hides it for a while and waits for a confirmation. If no confirmation arrives in time, it assumes the worker died and hands the job to someone else. In SQS this is the visibility timeout. In Laravel it is retry_after in config/queue.php: if a job is still running after that many seconds, another worker starts it again, so the job's timeout must always be shorter than retry_after, or long jobs run twice in parallel. BullMQ uses a lock that the worker keeps renewing while it processes; if the lock expires, for example because CPU-heavy code blocked the event loop, the job is marked stalled and moved back to waiting, and after too many stalls it fails. All three designs give at-least-once delivery, which is exactly why idempotency is required.
// config/queue.php
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 960, // longer than the slowest job's timeout
'block_for' => 5,
'after_commit' => true, // push only after the DB transaction commits
],
// Slow export job: public $timeout = 900; run it on a worker
// started with --timeout=900, on its own queue.
08Controlling concurrency: uniqueness, overlap and rate limits
Retries and parallel workers create new problems: two syncs for the same customer racing each other, or a burst of jobs hitting a partner's rate limit. Laravel gives you building blocks. ShouldBeUnique ignores a new dispatch while an identical job, by uniqueId, is still pending. The WithoutOverlapping job middleware lets only one job per key run at a time and releases the others back to the queue. RateLimited applies a named rate limiter to jobs, and ThrottlesExceptions backs off after repeated failures, acting like a circuit breaker for a flaky API; pair it with retryUntil so time, not attempts, limits retries. Unique and overlap locks need a cache store with atomic locks, such as Redis or the database. In BullMQ, the deduplication option ignores repeated jobs with the same id, and a worker limiter caps jobs per time window.
class SyncCustomerToCrm implements ShouldQueue, ShouldBeUnique
{
use Queueable;
public function __construct(public int $customerId) {}
public function uniqueId(): string
{
return (string) $this->customerId;
}
public function middleware(): array
{
return [
new WithoutOverlapping('crm-'.$this->customerId),
new ThrottlesExceptions(10, 5 * 60), // 10 errors, then pause 5 min
];
}
public function retryUntil(): DateTime { return now()->addHours(6); }
}
09Workflows: chains, batches and parent-child flows
Real work is often several jobs with dependencies. A chain runs jobs one after another and stops if one fails: generate invoice, then send it, then notify the accountant. A batch runs many jobs in parallel and calls back when they all finish, which suits imports and exports split into thousands of small jobs; Laravel tracks progress in a job_batches table and jobs in it use the Batchable trait. In BullMQ, a FlowProducer creates parent-child trees where the parent runs only after all children complete, and the parent can read the children's results. Design each step to be idempotent and small, so retrying one step does not redo the whole workflow. When a later step fails after earlier ones changed external systems, you need explicit compensation, such as cancelling a reservation, because a queue cannot roll back a payment.
$batch = Bus::batch(
$orderIds->map(fn (int $id) => new ExportOrderRow($exportId, $id))->all()
)
->name('orders-export-'.$exportId)
->allowFailures()
->then(fn (Batch $batch) => BuildExportFile::dispatch($exportId))
->catch(fn (Batch $batch, Throwable $e) => report($e))
->finally(fn (Batch $batch) => CleanupExportChunks::dispatch($exportId))
->onQueue('reports')
->dispatch();
// Progress for the UI: Bus::findBatch($batch->id)->progress()
10Capacity and observability: depth, age and Little's law
How many workers do you need? Little's law gives a first answer: the average number of jobs in progress equals the arrival rate times the average processing time. If 30 jobs arrive per second and each takes 200 ms, you need at least 6 concurrent slots just to keep up, and more for spikes and retries. Queue depth alone is misleading: 10,000 tiny jobs may clear in seconds, while 50 slow ones may be hours behind. The most useful single metric is the age of the oldest waiting job per queue, alerted against a target such as 'emails within 60 seconds'. Also track throughput, failure rate and processing time percentiles. Horizon can balance workers between queues automatically, and php artisan queue:monitor can fire an event when a queue grows beyond a threshold. In multi-tenant systems, watch fairness: one tenant's import should not delay everyone's receipts.
- Autoscale workers on oldest-job age, not CPU.
- Separate queues per latency target: critical, default, bulk.
- Cap per-tenant concurrency for heavy job types.
Real-world scenarios
A payment webhook fulfilled one order twice
Problem
A customer was charged once but received two shipments. Logs show the payment provider sent the same 'payment succeeded' webhook twice, a few seconds apart, and both were processed.
How to solve it
Providers deliver webhooks at least once, so duplicates are normal. Store each incoming event with the provider's event id under a unique index before queueing it; a duplicate insert fails and you return 200 without processing. In the job, lock the order row or use a conditional update, such as set status to paid where status is pending, and continue only if a row changed. Make fulfilment idempotent too: one shipment per order, enforced by a unique constraint. Re-fetch the payment status from the provider's API before acting. Finally, add a test that runs the same webhook twice and asserts one shipment.
The ERP was down for six hours
Problem
The accounting system behind the ERP sync went offline for a whole afternoon. Thousands of sync jobs failed, retried, and ended in the failed jobs table, and the team had to fix data by hand.
How to solve it
Design for outages instead of reacting. Use ThrottlesExceptions or a circuit breaker so that after repeated failures jobs pause instead of burning attempts, and limit retries by time with retryUntil, for example 24 hours, rather than by a small attempt count. Keep backoff growing with a cap. Make the jobs idempotent upserts keyed by order id, so replaying is safe. Add a health check that pauses the sync queue when the ERP is unreachable and alerts someone. After recovery, replay with php artisan queue:retry for the affected queue and verify totals with a reconciliation report that compares both systems.
A big export delays password reset emails
Problem
When an admin starts a large product export, customers stop receiving password reset and order confirmation emails for twenty minutes.
How to solve it
Everything runs on one default queue with one pool of workers, so thousands of export chunks sit in front of the emails. Split queues by latency target: critical for authentication emails and receipts, default for normal work, reports for exports and imports. Run dedicated workers for critical so it can never be starved, and limit the concurrency of the reports queue so it cannot exhaust the database either. With Horizon, define supervisors per queue group with their own process counts. Add an alert on the oldest-job age of the critical queue, for example above 60 seconds, so the next regression is caught quickly.
Cheat sheet
- Retry every failed Laravel job
php artisan queue:retry all- Retry failed jobs of one queue
php artisan queue:retry --queue=emails- List failed jobs
php artisan queue:failed- Delete failed jobs older than 48 hours
php artisan queue:prune-failed --hours=48- Fire an event when a queue is too long
php artisan queue:monitor redis:default,redis:emails --max=100- Recycle a worker to limit memory growth
php artisan queue:work --max-jobs=1000 --max-time=3600- Process the queue then exit (CI, one-off containers)
php artisan queue:work --stop-when-empty- BullMQ: ignore duplicate jobs
await queue.add('sync', data, { deduplication: { id: 'customer-' + id } })- BullMQ: cron schedule
await queue.upsertJobScheduler('nightly-report', { pattern: '0 2 * * *' }, { name: 'report' })- Redis must not evict jobs
maxmemory-policy noeviction
In my real projects
- In a multi-tenant POS SaaS, domain events go through a transactional outbox into BullMQ (about 20 queues), and HMAC-signed webhooks are delivered via a queue.
- On a pet-supplies platform, ERP and accounting sync runs as scheduled, feature-flagged jobs with health alerts and price revert.
- In a gym CRM, scheduled jobs run in a fixed, non-overlapping order (unfreeze, expire, activate), and SMS is retried only on connection failure so no message is sent twice.
- On production servers, Laravel workers run under supervisor and Node apps under pm2, with cron driving the schedulers.
Questions & answers
Q1What does idempotent mean for a job, and how do you achieve it?
An idempotent job produces the same result whether it runs once or five times. It matters because queues deliver at least once: a worker can crash after doing the work but before acknowledging it, and the job runs again. I achieve it with unique keys and state checks: create the invoice only if none exists for this order, use upserts, record processed event ids under a unique constraint, and pass idempotency keys to payment and messaging providers. I also keep side effects at the end of the job and avoid partial writes by using transactions.
Q2How do you design retries for a job calling an external API?
First classify errors. Timeouts, connection errors, 429 and 5xx responses are usually temporary, so retry them. 4xx validation errors are permanent, so fail immediately and record why. For temporary errors I use exponential backoff with jitter and a capped number of attempts, respecting Retry-After headers. Each call has a timeout shorter than the job timeout. After the final attempt the job lands in a failed or dead-letter store with full context, an alert fires, and there is a way to replay it once the cause is fixed. The job must be idempotent so retries are safe.
Q3What is a dead-letter queue?
It is where messages go after they fail every retry, so they stop blocking or burning resources but are not lost. In Laravel that is the failed_jobs table, in BullMQ the failed set, in SQS or RabbitMQ a dedicated queue. A dead-letter queue is only useful if someone watches it, so I alert on new entries and track the count. Each entry should include the payload, the error and the attempt history. After fixing the bug or waiting out an outage, you replay the jobs. Patterns in failed jobs are also a great signal of integration problems.
Q4Why use the outbox pattern instead of dispatching a job directly?
Because writing to the database and publishing a message are two separate systems, and one can fail after the other succeeds. If you dispatch inside the transaction and the transaction rolls back, a worker processes an event that never happened. If you dispatch after commit and the process crashes in between, the event is lost. With an outbox you insert the event into a table in the same transaction as the business change, so both commit or neither does. A relay then reads the outbox and publishes to the queue, marking rows as sent. Consumers stay idempotent because delivery is at least once.
Q5How do you run and monitor workers in production?
Workers are long-lived processes, so they run under a process manager: supervisor or systemd for Laravel, pm2 or systemd for Node, or separate containers. I separate queues by priority and size concurrency per queue, so a big export cannot starve password-reset emails. Workers restart on each deploy and shut down gracefully, finishing the current job. Monitoring covers queue depth, job age, failure rate and processing time, with alerts when the oldest job is too old. Horizon or a BullMQ dashboard helps with inspection, but alerts are what wake you up when something is stuck.
Senior-level questions
S1How do you guarantee per-entity ordering when many workers process a queue?
Global ordering with parallel workers is impossible without giving up parallelism, so I ask what really needs order. Usually it is events for one entity: an order's status changes, or one customer's balance updates. Options: route all jobs for an entity to the same partition or group and process each group serially, which Kafka partitions, SQS FIFO message groups and BullMQ Pro groups support natively. With plain Laravel or BullMQ queues, I use a per-entity lock such as WithoutOverlapping keyed by the entity id, plus a version number on each event so a handler ignores anything older than the stored state. Often the simplest robust design is to make handlers read the current state from the database rather than trust the event's payload, so order matters less.
S2Redis queues, RabbitMQ, SQS or Kafka: how do you choose?
Start from the job, not the tool. For background work inside one product, such as emails, syncs and exports, Redis-based queues like Laravel queues or BullMQ are simple, fast and already in the stack; the trade-off is that durability depends on Redis persistence settings and memory. SQS is fully managed and very durable, with visibility timeouts and dead-letter queues built in, but has higher latency and AWS lock-in. RabbitMQ adds rich routing, exchanges and per-message acknowledgements, which fit many services exchanging commands. Kafka is a replayable event log with partitions and consumer groups, ideal for high-throughput streams, event sourcing and multiple independent consumers, but it is heavier to operate. I pick Redis queues until a concrete need, replay, cross-team streams or massive throughput, appears.
S3How do you size and autoscale a worker fleet?
I measure arrival rate and processing time per job type and use Little's law for a baseline: concurrency needed equals jobs per second times average seconds per job, plus headroom for spikes and retries. Then I check what the workers depend on, because more workers can simply move the bottleneck to the database or a rate-limited partner API. Autoscaling should react to the age of the oldest waiting job, or to depth divided by throughput, not CPU, since many jobs are I/O-bound. Each queue gets its own latency target and its own worker pool, with limits on heavy queues so they cannot starve critical ones. Scale-in must be graceful: workers stop taking jobs, finish the current one and exit within the grace period, otherwise jobs are retried.
S4A batch import of 100,000 rows partly failed. How should the system behave?
It should finish what it can, report clearly what failed, and allow safe replay. I split the import into chunks or one job per row group inside a batch that allows failures, so one bad row does not cancel the rest. Validation errors are permanent, so they are recorded per row with a reason and never retried; transient errors such as timeouts are retried with backoff. Every write is an upsert keyed by a natural id, so re-running the whole import or only failed chunks never duplicates data. When the batch finishes, the user gets a summary, imported, skipped and failed with a downloadable error file, and can fix and re-upload only those rows. The trade-off is more bookkeeping, but the alternative, all-or-nothing imports, makes large files nearly impossible to load.
Glossary
- Visibility timeout
- How long a taken job stays hidden from other workers before the broker assumes failure and redelivers it.
- At-least-once delivery
- A guarantee that a message will be delivered, possibly more than once, so handlers must tolerate duplicates.
- Idempotency key
- A unique id sent with an operation so the receiver can recognise and ignore repeats.
- Poison message
- A job that fails every time because of its content, and would block or loop forever without a retry limit.
- Stalled job
- In BullMQ, a job whose worker stopped renewing its lock; it is moved back to waiting or failed.
- Job batch
- A group of jobs tracked together, with callbacks when all succeed, any fail, or all finish.
- Little's law
- Average jobs in the system equals arrival rate times average time per job; used to size workers.
- Circuit breaker
- A guard that stops calling a failing service for a while, giving it time to recover.
- Fairness
- Making sure one tenant or job type cannot monopolise workers and delay everyone else.
Keep learning
Have a product in mind?Let's build it.
Tell me about your idea, your product or your team. I usually reply within one working day.