A queue is a buffer with semantics. Producers drop work in; consumers pull it out at their own pace. The result is a system that absorbs spikes, survives consumer outages, and stays responsive under load — provided you understand the failure modes you're now opting into.
← Back to Architecture| Broker | Sweet spot | Notes |
|---|---|---|
| Redis (BullMQ, Sidekiq, RQ, Celery+Redis) | App-level job queues, low setup cost. | Already in your stack as a cache; fine for moderate volume; durability is "good enough" with AOF. |
| RabbitMQ | Classic AMQP queues, complex routing. | Mature, flexible, exchange/binding model. Single-broker mental model. |
| Kafka / Redpanda | High-throughput event streams, replay. | Log, not a queue. Consumers track their own offsets. Great for analytics, painful as a generic job queue. |
| AWS SQS / GCP Pub/Sub / Azure Service Bus | Managed, infinite-scale, pay per message. | No ops; visibility timeouts and DLQs built in. Vendor lock comes with the convenience. |
| NATS / NATS JetStream | Low-latency messaging with optional persistence. | Lightweight, fast, increasingly popular for microservices. |
| Postgres-as-a-queue (e.g., River, Graphile Worker) | Small/mid teams already on Postgres. | Transactional with your DB; one less moving part. Has clear scale ceiling but it's surprisingly high. |
Default for small teams: Postgres-as-a-queue or Redis-backed. Reach for Kafka when you need replay/streaming, not just async jobs.
| Guarantee | Meaning | Reality |
|---|---|---|
| At-most-once | 0 or 1 deliveries. | Cheap. Fine for fire-and-forget telemetry; unsafe for anything you can't lose. |
| At-least-once | 1 or more deliveries. | The pragmatic default. Combine with idempotent handlers and you've got "effectively once". |
| Exactly-once | Exactly 1 delivery. | Distributed-systems folklore. True end-to-end exactly-once is rare and expensive; usually you build it from at-least-once + idempotency. |
The right phrase isn't "exactly-once delivery", it's "exactly-once effect" — and you achieve it by making handlers idempotent, not by tuning the broker.
If processing the same message twice produces the same outcome as processing it once, you're idempotent. If not, every retry is a potential bug.
processed_messages(message_id) is often enough.UPDATE … WHERE status = 'pending' beats unconditional writes.NullPointerException on the same input — no, that's a poison message.NonRetryable error should bypass retries and go straight to DLQ.Publishing to a queue and writing to a database are two separate operations. If the broker is unavailable after the DB commit, you've silently lost the event. The fix:
outbox table in the same DB transaction as your business write.Now your business write and your "intent to publish" are atomic. The publisher can crash and recover without losing events.
Every redirect publishes a click event to a queue. A worker enriches it (geo-IP, UA parsing) and writes to the clicks table. The redirect itself never waits for the write.
// Producer — on the redirect path
async function redirect(req, res, link) {
res.redirect(302, link.target_url); // user gets out fast
// Fire-and-forget enqueue (with outbox in real life)
await queue.add('click', {
id: crypto.randomUUID(), // stable message ID
link_id: link.id,
occurred_at: new Date().toISOString(),
ip: req.ip,
ua: req.get('user-agent'),
referrer: req.get('referer') ?? null
}, { attempts: 5, backoff: { type: 'exponential', delay: 1000 } });
}
// Consumer
worker.process('click', async (job) => {
const m = job.data;
// Idempotency: unique index on (message_id) absorbs duplicates
const inserted = await db.insertClickIfNew(m.id, m);
if (!inserted) return; // already processed
const enriched = await enrich(m); // geo, UA parsing
await db.upsertClickEnrichment(m.id, enriched);
});
// DLQ alarm: queue 'click:dead' depth > 0 for 5 minutes → page on-call
Notice: redirect latency is unaffected by analytics; duplicates are absorbed; failures retry with backoff and end up in a DLQ if they don't succeed; trace ID would be propagated in a real implementation.