Skip to content
All writing

NOTES & IDEAS

Don't Make the Customer Wait While You Send the Email

Sending an email while the user waits ties your app's speed to a server you don't control - and loses the email if it fails. Here's the simple fix, explained with a coffee shop counter.

NestJSNode.jsBullMQRedisSystem Design

In Writer's Heaven (my blog platform), signing up sends you a one-time code by email. The first version of that code did something that looks completely innocent and isn't:

await sendEmail(user.email, otpCode);   // send the email right here, now
return res.json({ message: 'OTP sent' });

It works on your laptop. In production it caused two problems, and the fix is the same idea behind almost every fast, reliable app. Let me explain it with a coffee shop.

The whole idea: take the order, don't make the latte at the till

Picture a busy coffee shop. You're the cashier. A customer orders a latte.

The wrong way to run the till: you take the order, then walk to the machine and make the latte yourself while the customer - and the whole line behind them - just stands there waiting. Worse, if the espresso machine jams, you come back with empty hands and the customer leaves with nothing.

The right way: you take the order, clip the ticket to the rail, hand over the receipt, and shout "next!". A barista pulls tickets off the rail and makes the drinks. The line keeps moving, and a jammed machine is the barista's problem to sort out, not something the customer stands and watches.

That's the whole pattern. Map it onto the code:

  • The cashier is your API - the part the user is waiting on. Its only job is take the order and hand back a receipt, fast.
  • The ticket rail is a queue. I use BullMQ, which keeps the tickets in Redis. (Redis here is just a fast place to hold the list of tickets.)
  • The barista is a separate worker program that does the slow part later.

Sending the email inline was the cashier personally making every latte. Here's the cashier rewritten to just clip a ticket:

export const MAIL_QUEUE = 'mail';
export const SEND_EMAIL_JOB = 'send-email';

@Injectable()
export class MailService {
  constructor(@InjectQueue(MAIL_QUEUE) private readonly mailQueue: Queue<SendEmailJobData>) {}

  async enqueue(data: SendEmailJobData): Promise<void> {
    await this.mailQueue.add(SEND_EMAIL_JOB, data, {
      attempts: 3,                                    // try up to 3 times
      backoff: { type: 'exponential', delay: 30_000 }, // wait longer each retry
      removeOnComplete: { count: 100 },               // keep a few done tickets
      removeOnFail: { count: 500 },                   // keep failed tickets to inspect
    });
  }
}

Now the request just does await mailService.enqueue(...) - clipping a ticket to the rail is a tiny, instant action - and returns in milliseconds. The user never waits on the email server again.

What those four options actually buy you

OptionWhat it means in plain terms
attempts: 3If making the drink fails, try again up to 3 times before giving up
backoffBetween tries, wait longer each time - 30s, then 60s, then 120s
removeOnCompleteToss most finished tickets; keep the last 100 so you can sanity-check
removeOnFailKeep failed tickets (last 500) so you can see what went wrong

Why wait longer each time instead of retrying instantly

If the email server is briefly overwhelmed, retrying the instant it fails is the worst thing you can do - you're banging on a door that's already struggling, which keeps it struggling. Waiting longer each time (this is called exponential backoff) gives it room to recover. It's the difference between "knock, wait a bit, knock again a little later" and "pound on the door as fast as humanly possible." For anything that talks to a server you don't control, the polite knock wins.

The one line people get wrong

Here's the barista - the worker that pulls a ticket and actually sends the email:

@Processor(MAIL_QUEUE)
export class MailProcessor extends WorkerHost {
  async process(job: Job<SendEmailJobData>): Promise<void> {
    const { to, subject, html } = job.data;
    const result = await this.transporter.sendMail({ from: process.env.EMAIL, to, subject, html });

    if (!result.accepted?.includes(to)) {
      throw new Error(`email server did not accept ${to}`);   // ← the important line
    }
  }
}

Here's the part that surprises people: the ticket rail only tries again if the barista throws the ticket back. BullMQ decides "did this succeed?" purely on whether your code throws an error. So you have to actually check that the email was accepted and throw if it wasn't. If you instead shrug and return normally, BullMQ marks the ticket "done," and the email is silently lost - the exact problem we were escaping. The rule: let failures throw.

So what do you get for the trouble?

  • Instant responses. The app replies the moment the ticket is on the rail; the slow email server is the worker's problem, not the user's.
  • Self-healing. Most failures are just brief blips, and the 3 retries quietly ride them out before anyone notices.
  • A paper trail. Keeping failed tickets means you can look at why something didn't send instead of guessing.
  • Easy to scale. Emails piling up? Run more baristas (worker processes). The cashier doesn't change. And the same Redis already in the app doubles as the ticket rail - no new moving parts.

This is the same pattern behind the pull-request queue in my AI PR Reviewer - only the work the barista does is different.

Takeaway

Any work that is slow, likely to fail, or not needed to answer the user does not belong in the request. Take the order, clip a ticket, reply right away, and let a worker do the hard part later with patient retries. Cashiers take orders; baristas make the drinks and are allowed to fail and try again. Just remember the punchline: a job only retries if it throws.