Why I Introduced a Message Queue for a Small App with only 100 Users
1. "Why is Sign-Up So Slow?"
In the early days of my service, I received user complaints.
"When I click Sign Up, the app freezes for a few seconds. Is it broken?"
Users only entered their ID and Password, but the processing took 3-5 seconds.
In the world of web UX, 3 seconds is an eternity. Impatient users were closing the tab.
I checked the sign-up logic code, and it was written in a Synchronous manner.
/* Bad Example: Synchronous (Sequential) */
app.post('/signup', async (req, res) => {
// 1. Save user to DB (0.1s - Fast)
const user = await db.saveUser(req.body);
// 2. Send Welcome Email via SMTP (3s...???) 🚨
await emailService.sendWelcome(user.email);
// 3. Send Slack Notification to Admin (1s) 🚨
await slackService.sendNotification(`New User: ${user.email}`);
// 4. Send Response (Total 4.1s)
res.send("Done!");
});
The bottlenecks were External Services.
Whenever the Email Server (Google SMTP) or Slack API was slow, my precious Sign-Up API slowed down with them.
Even worse, if the Email Server crashed, the Sign-Up failed too (500 Error).
It makes no sense to block a user from registering just because a welcome email failed to send.
This is a classic problem of Tight Coupling.
2. Solution: "Just Stick the Order Ticket on the Rail"
To solve this, I introduced a Message Queue.
The concept is exactly like a busy restaurant kitchen.
- Before (Synchronous): The waiter takes an order, walks to the kitchen, stands there watching the chef cook for 30 minutes, serves the food, and ONLY THEN takes the next customer's order. (Extremely inefficient).
- After (Asynchronous): The waiter takes an order, sticks the Order Ticket (Message) on the Kitchen Rail (Queue), yells "Order up!", and immediately attends to the next customer. The Chef (Worker) grabs tickets from the rail and cooks them one by one.
Now, the code changes. The waiter (Web Server) no longer waits for the cooking (Email Sending).
/* Good Example: Asynchronous (Fire and Forget) */
app.post('/signup', async (req, res) => {
// 1. Save to DB (Crucial Task)
const user = await db.saveUser(req.body);
// 2. Throw "Send Email" message to Queue (0.001s - Instant)
await messageQueue.add('send-email', { email: user.email });
// 3. Throw "Send Slack" message to Queue (0.001s)
await messageQueue.add('send-slack', { email: user.email });
// 4. Immediate Response (Total 0.102s)
res.send("Done!");
});
The user sees the "Sign Up Complete" message in 0.1 seconds. It feels instant.
The email will serve its purpose whenever it arrives—be it 1 second or 10 minutes later—handled by a background worker.
3. Tool Choice: Kafka vs RabbitMQ vs Redis?
When choosing a Message Queue, I was overwhelmed by options. What fits a small startup?
1. Apache Kafka
- Pros: Massive throughput, persistence (disk storage), replayability. Valid standard for Big Tech.
- Cons: Too heavy. Requires Zookeeper (historically), hard to manage.
- Verdict: We are not Netflix. Over-engineering.
2. RabbitMQ
- Pros: Traditional, robust message broker. Advanced routing.
- Cons: Needs a separate server instance. Configuration can be tricky.
- Verdict: Good, but looking for something lighter.
3. Redis (BullMQ)
- Pros: We already use Redis (for caching). No new infrastructure. Node.js library (Bull) is mature and excellent.
- Cons: Higher risk of data loss if Redis crashes (compared to Kafka).
- Verdict: "The company won't die if we lose one welcome email. Speed and dev productivity matter more." -> Winner: Redis!
For early-stage startups or specific features, Redis is often the best choice.
Comparison Table
| Feature | Apache Kafka | RabbitMQ | Redis (BullMQ) | AWS SQS |
|---|
| Best For | Log Streaming, Big Data | Complex Routing, Enterprise | Simple Job Queue | Serverless Apps |
| Speed | Extremely Fast | Fast | Instant (In-Memory) | Moderate |
| Persistence | Disk (Configurable) | Memory/Disk | Memory (Volatile) | Disk (Up to 14 days) |
| Complexity | High (Requires ZooKeeper/KRaft) | Medium | Low (Just Redis) | Zero (Managed) |
| Who uses it? | LinkedIn, Netflix, Uber | Banks, Legacy Systems | Startups, Indie Hackers | AWS Heavy Users |
Why BullMQ?
BullMQ adds features that Redis lists don't have natively: Delayed Jobs (process this 5 mins later), Retries (exponential backoff), and Priority Queues (VIP users first). Implementing this on raw Kafka or RabbitMQ takes weeks. On BullMQ, it's 2 lines of code.
4. Implementation: Done in 10 Minutes with BullMQ
Using BullMQ in Node.js made implementation trivial.
Producer (Web Server)
Receives user requests and adds jobs to the queue.
import { Queue } from 'bullmq';
// Connection Config
const connection = { host: 'localhost', port: 6379 };
const emailQueue = new Queue('email-queue', { connection });
async function signUp(user) {
// DB logic...
// Add Job to Queue with Retry options
await emailQueue.add('welcome-email',
{ email: user.email },
{
attempts: 3, // Retry up to 3 times on failure!
backoff: 5000 // Wait 5 seconds between retries
}
);
return "Sign Up Complete";
}
Worker (Background Process)
Pulls jobs from the queue and executes them. This should run as a separate process.
import { Worker } from 'bullmq';
const worker = new Worker('email-queue', async job => {
console.log(`[Job ${job.id}] Sending email to: ${job.data.email}`);
try {
// The slow task (3s)
await emailService.sendWelcome(job.data.email);
console.log("Sent successfully!");
} catch (err) {
console.error("Failed... Will retry automatically.");
throw err; // Throwing error triggers BullMQ's retry mechanism
}
}, { connection });
Now, even if the email server goes down, the sign-up succeeds.
Messages will pile up in the Queue. When the email server comes back online, the Worker will process all pending jobs.
We can also scale horizontally. Add 10 Worker servers, and email sending speed increases 10x.
This is the power of Scalability and Decoupling.
5. Caveats: Queues Are Not Magic Wands
"So should I put everything in a queue?" No.
- Order is not guaranteed: In parallel processing, message 2 might finish before message 1. If order is critical (e.g., Stock Trading), be careful.
- Debugging is harder: "I sent the message, but nothing happened." Logs are scattered between Producer and Worker. You need a dashboard (like BullBoard).
- Data Loss: If Redis crashes without persistence (AOF/RDB), queue data is gone. For critical financial data, use Kafka or a Transactional Outbox pattern with RDBMS.
6. Conclusion: Don't Make Users Wait
Synchronous processing is easy and intuitive. Code flows top to bottom.
But it steals users' time.
Asynchronous processing adds complexity, but it maximizes User Experience (UX) and System Stability.
Checklist:
- Does the user need the result RIGHT NOW? (Login, Payment Confirmation) -> Synchronous
- Can it wait? Or is it okay to retry later? (Email, Push Notification, Analytics, Image Resizing) -> Queue it.
If your API is slow, check if your waiter is cooking in the kitchen.
Throw the ticket and get back to the customer.