Why I queue emails instead of sending them
A 12k-row CSV taught me that sending email synchronously is a request-handling bug in disguise. Here's the rewrite that fixed it.
We deployed our first email feature on a Friday. Sync SMTP. Worked great with the three emails per day we were testing with. Monday morning, someone bulk-imported a 12k-row CSV and hit "Send to all."
The server didn't crash, exactly. It just stopped responding. Other endpoints timed out. Health checks failed. The first PagerDuty ping landed at 9:17 AM.
That's the day I learned that sending email synchronously is a request-handling bug disguised as a feature.
What I changed
The fix is the boring one. Push the send to a queue, return 202 immediately, let workers grind through delivery on their own time.
// before: synchronous send blocks the request
app.post("/send", async (req, res) => {
const result = await transporter.sendMail({ ... });
res.json(result);
});
// after: enqueue and return immediately
app.post("/send", async (req, res) => {
const job = await emailQueue.add("send", { to, subject, body });
res.json({ jobId: job.id, status: "queued" });
});Now the API responds in ~8ms regardless of whether SMTP is happy. The 12k-row CSV becomes 12k jobs sitting in Redis, and eight workers chew through them at a steady rate.
Why BullMQ + Redis
I'd written my own queue once, in college. Postgres table, polling loop. It worked, but I spent more time on it than on the actual feature.
BullMQ gives you the things you'd have to build yourself:
- Concurrent workers with named jobs
- Retries with exponential backoff
- Delayed jobs (send this at 6 AM tomorrow)
- A dead-letter queue for jobs that exhaust their retries
- Priority queues if some jobs are more urgent than others
Redis is the storage engine. Fast, in-memory, supports the data structures BullMQ needs (lists for queues, sorted sets for delayed jobs).
The actual win
This pattern isn't novel. But the shift in thinking, that the API's job is to accept work, not do it, turned out to be the most useful mental model I picked up this year. Pretty much any expensive write becomes a 5-line queue handoff. The downstream gets to be slow without taking the API down with it.
If you're sending email synchronously right now, your CSV-uploading user is already on their way.