Your signup endpoint takes four seconds. The user clicks "Create Account," stares at a spinner, and you're just sitting there sending a welcome email synchronously. The password got hashed in 200ms. The database write took 50ms. The remaining 3.7 seconds? Your SMTP provider thinking about life.
And the user doesn't care about that email. They just want in.
This is the exact problem message queues solve. You take the slow work, shove it into a queue, and respond to the user immediately. Something else picks it up later. Probably within milliseconds. But the point is: not on the request path.
Same story with image resizing, PDF generation, webhook delivery. Anything that's slow and doesn't need to happen before you respond to the user? Queue it.
🎯 What a queue actually is
A message queue is a buffer sitting between a producer and a consumer, managed by a broker. The producer drops a message in. The consumer pulls it out and processes it. That's it.
The producer doesn't know or care who processes the message. The consumer doesn't know or care who sent it. They're decoupled. If your email service goes down for thirty seconds, messages pile up in the queue and get processed when it recovers. No lost signups. No retries from the client.
This is point-to-point messaging: one message goes to one consumer. If you've got three workers pulling from the same queue, the broker hands each message to exactly one of them. Competing consumers. More workers means faster drain.
Here's what enqueueing looks like with BullMQ (a Redis-backed queue for Node.js):
import { Queue } from "bullmq";
const emailQueue = new Queue("welcome-emails");
// In your signup handler - takes ~1ms instead of 4 seconds
await emailQueue.add("send-welcome", {
userId: "usr_abc123",
email: "newuser@example.com",
});
And the worker that picks it up:
import { Worker } from "bullmq";
const worker = new Worker("welcome-emails", async (job) => {
// This runs outside the request path
await sendWelcomeEmail(job.data.email);
console.log(`Welcome email sent to ${job.data.userId}`);
}, { connection: { host: "127.0.0.1", port: 6379 } });
worker.on("failed", (job, err) => {
console.error(`${job?.id} failed: ${err.message}`);
});
Your signup endpoint now returns in 250ms. The email gets sent a few hundred milliseconds later by a separate process. The user never notices.
⚡ Ack, visibility timeout, and redelivery
So what happens when a worker crashes mid-processing? The message just disappears? No.
Queues don't delete a message when it's picked up. They hide it. In SQS, this is called a visibility timeout: the message becomes invisible to other consumers for N seconds (default 30). If your worker finishes and deletes the message, great. If it dies, the timeout expires and the message reappears for another worker to grab.
RabbitMQ does the same thing differently. Your consumer sends a manual ack when it's done. No ack? The broker requeues.
But here's where it gets tricky. Say processing takes 35 seconds and your visibility timeout is 30. The message reappears while the first worker is still on it. Now two workers are processing the same job. Duplicates.
This is why queues give you at-least-once delivery, not exactly-once. Your consumers need to be idempotent. Processing the same message twice should produce the same result. Check if the email was already sent before sending it again.
🛠️ Dead letter queues and what to alert on
Sometimes a message is just bad. Malformed payload, referencing a deleted user, hitting a bug that'll never self-heal. It fails, requeues, fails again. Forever.
A dead letter queue (DLQ) catches these. You configure a max receive count, say 3. After three failed attempts, the broker moves the message to a separate DLQ instead of requeuing it. Your main queue stays healthy. You check the DLQ during working hours, figure out what went wrong, fix the bug or data issue, and replay the messages. No one got paged at 2am over a malformed payload.
Now, the metric that actually matters: queue depth. Specifically, the number of messages waiting to be processed. In SQS, that's ApproximateNumberOfMessagesVisible.
If depth is growing, your consumers can't keep up. Maybe one crashed. Maybe traffic spiked. Maybe a downstream service is slow. Whatever the cause, a growing queue is the canary. Set an alarm on it. Auto-scale your consumers based on it. A flat or zero depth means things are healthy.
Quick note on brokers: RabbitMQ gives you routing, priorities, and complex topologies. SQS gives you zero ops and infinite scale. BullMQ is great for Node.js apps already running Redis. Kafka is a distributed commit log designed for high-throughput streaming — it's a different animal entirely, and I wrote about how its partitions and consumer groups work separately. Pick based on what you're already running.
More writing
The rest of my writing lives at arnavsharma.dev, if this was useful.
Top comments (0)