Make Logins Feel Instant by Sending Email in the Background
Never await SMTP in a request handler; fire transactional email after you respond so users get their code without the hang.
A user types their email, hits sign in, and waits. And waits. The little spinner turns for two full seconds before the "we sent you a code" screen finally appears. Nothing is broken. The login works. But every sign-in feels sluggish, and the reason is almost always the same: somewhere in the login handler, the code is sitting and waiting for an email to actually leave the building before it responds.
That wait is self-inflicted, and it is one of the easiest performance wins in any application. The user does not need the email to have been delivered before they see the next screen. They need to be told a code is coming. Those are different events, and tying the first to the second is the whole bug. Here is why it happens, what it costs, and the small change that makes logins feel instant.
The hidden cost of awaiting SMTP
Sending an email is slow in a way that surprises people, because it does not feel like work. Under the hood it is a network conversation with a mail server: connecting, the SMTP handshake, the back-and-forth of envelope and headers and body, and the server accepting the message. Each leg has latency, and the whole exchange routinely adds 100 to 500 milliseconds, sometimes more if the mail server is busy or far away or having a slow moment.
When your login handler does this:
await sendVerificationEmail(user.email, code);
return res.json({ message: 'Code sent' });
every single sign-in pays that full SMTP round trip before the response goes out. The user stares at a spinner not because anything is computing, but because your server is politely waiting for a mail server on another machine to finish a conversation that has nothing to do with what the user sees next. The latency the user feels is entirely the latency of an operation they do not need to wait on.
It gets worse under load. When traffic spikes, every concurrent login holds a request open for the duration of its email send, which ties up server resources and can cascade into timeouts. The thing that was a minor annoyance at low traffic becomes a real bottleneck exactly when you can least afford it. The visible symptom is a slow time to first byte, the same number slashing time to first byte with streaming server rendering is chasing from the other direction.
The question that fixes it
Before you await anything in a request handler, ask one question: does the user's next step actually depend on this finishing? For a verification email, the answer is no. The next step is "the user sees a screen telling them to check their inbox." That screen is true the instant you have decided to send the code. It does not become more true after the SMTP server acknowledges receipt.
So you respond first and send second. Compute what the response needs, in this case issue the verification challenge, return the response immediately, and let the email go out in the background after the user already has their answer.
function queueEmail(args) {
// fire and forget: send, but never make the caller wait
void sendVerificationEmail(args).catch(logError);
}
// in the handler
queueEmail({ to: user.email, code });
return res.json({ message: 'Code sent' }); // returns now, not in 400ms
The queueEmail helper kicks off the send without awaiting it. The handler returns immediately. On a long-lived Node server, the send continues in the background and the email lands a moment later. The user sees "check your email" instantly, and the code arrives by the time they have switched to their inbox. The two-second login becomes a sub-100-millisecond one, and nothing about the user's experience got worse, because the only thing that changed is that they stopped waiting on a step that was never theirs to wait on.
But what if the send fails?
This is the objection that keeps people awaiting SMTP, and it does not hold up. The fear is: if I do not await the send, I cannot tell the user when it fails. But look at what awaiting actually buys you. If the send fails and you awaited it, you show the user an error and they retry. If the send fails and you did not await it, the user does not get the email, notices, and clicks "resend." Both paths end in a retry. The fire-and-forget version just made the success case, which is the overwhelming majority, fast.
A two-second hang on every login to handle a rare failure is a bad trade, and the resend itself should be rate-limited so it cannot become a probe, which is part of locking out credential stuffers with progressive rate limiting. The failure case already has a recovery path the user understands, the resend button, and that path is far better UX than making everyone wait on the off chance their particular send is the one that breaks. Log the failure so you can see it in your monitoring, and let the user resend. That is the right design.
This applies to every transactional email
Verification codes are the clearest example because login latency is so visible, but the same principle covers the whole category of email tied to a request. Signup confirmations, password-reset codes, contact-form notifications, receipts, "we got your message" acknowledgments, every one of these is a side effect of an action the user took, and in none of them does the user's next step depend on the email having been delivered.
Fire them all in the background. The signup completes and the response returns; the welcome email follows. The password reset is requested and the "check your inbox" screen appears; the email sends after. The contact form accepts the submission and thanks the user; the notification to your team goes out behind the response. In each case the user gets an instant, honest answer, and the slow external operation happens off the critical path where its latency costs nobody anything. The broader version of this idea, that any slow side effect belongs off the request, is the whole of firing email and side effects in the background so logins stay snappy.
The only time you actually block on a side effect is when the response genuinely cannot be produced without it. A Stripe Checkout redirect, where you need the session URL before you can send the user anywhere, is a real dependency. An email is almost never one.
Where the email actually goes matters too
Moving the send off the request path solves the latency. It does not solve whether the email arrives, and that is a separate discipline worth getting right. A verification email that sends fast but lands in spam is still a broken login from the user's point of view, just a differently broken one.
This is where the send and the deliverability behind it have to work together. Fire the email in the background so the request is fast, and make sure the mail server it goes through is configured to actually reach the inbox, with proper authentication, a clean sending reputation, and the records that keep transactional mail out of the spam folder. If you run that mail server yourself, a self-hosted Postfix and Dovecot stack that lands in the inbox is the foundation all of this sits on. The authentication side of that is setting up SPF, DKIM, and DMARC so your mail stops hitting spam, and the reputation side often comes down to splitting transactional and marketing mail across subdomains the right way so a marketing blast never drags your login codes into the junk folder. That second half is its own craft, and it is exactly what dedicated email deliverability work exists to handle, on infrastructure built to send transactional mail that arrives. Fast and undelivered is not a win; fast and in the inbox is.
The shape of an instant login
The end state is a login that feels immediate because it is. The user submits their email, the server issues the challenge, and the "check your inbox" screen appears in the time it takes the response to cross the network, with no SMTP round trip in the middle. The verification email is already on its way, sent in the background, and it arrives while the user is still moving their attention to their mail app. If the rare send fails, it is logged and the user resends, no worse off than they would have been after a blocking error.
The change is small, a fire-and-forget helper and a habit of not awaiting what the user does not need. The effect is large, because login is the first thing every returning user does, and a login that hangs sets the tone for everything after it. Stop making people wait on email they were never waiting for, and the whole app feels faster on the very first interaction.






