Skip to content
DERKONLINE

Run Side Effects in the Background So Logins Stay Fast

Stop awaiting slow SMTP and webhook calls in request handlers and return the response the instant the user actually needs it.

Derrick S. K. Siawor8 min read

Every request handler has a critical path, the work that genuinely has to finish before the user can get their answer, and everything else. The mistake that quietly slows down a huge number of applications is letting the everything-else block the answer. The user submits a form, and behind that submission your code sends a notification email, fires an analytics event, syncs the record to your CRM, and pings a Slack channel, and the user waits for all four to finish before the page tells them the form went through. None of those four things were on the critical path, and yet the user paid for every one of them in latency.

The fix is a single discipline applied everywhere: only block on what the response actually needs, and fire everything else in the background after you have already responded. It sounds obvious stated plainly, and it is routinely violated, because each individual await looks harmless and the cost only shows up as a sluggish-feeling app that nobody can quite explain. Here is how to find that latency and remove it.

The question that defines the critical path

Before you await anything in a request handler, ask one question: does the user's next step actually depend on this finishing? That single question sorts every operation in the handler into "block on it" or "fire it in the background," and the sorting is usually lopsided. Most of what handlers wait on, the user did not need to wait for.

The operations that genuinely belong on the critical path are the ones whose result the response carries. A Stripe Checkout session, where you need the session URL before you can redirect the user, has to complete first, because the response is that URL. A database write that the next screen reads back has to commit before you respond, and if that write is slow, the fix is usually a query problem like an N plus one quietly slowing your API, not a blocking side effect. These are real dependencies; the user's next step literally cannot happen without them.

The operations that do not belong on the critical path are the side effects, the things that change something elsewhere but do not change what the user sees next. The notification email. The analytics beacon. The CRM sync. The Slack ping. The webhook fan-out to downstream systems. The user's confirmation screen is true the moment the core action succeeded; it does not become more true after the analytics event is recorded or the CRM acknowledges the sync. So those should not block the response. They should fire after it.

Why these side effects are slow

The reason this matters so much is that side effects are disproportionately the slow part. They are almost all network calls to other systems, and network calls to other systems are where the latency lives.

An email send is an SMTP conversation with a mail server, routinely a few hundred milliseconds, which is exactly why sending email in the background makes logins feel instant. An analytics or marketing beacon is an HTTP call to a third-party endpoint that you do not control and whose latency you cannot predict. A CRM sync is an API call to Salesforce or HubSpot, which has its own response time and rate limits. A Slack notification is another HTTP round trip. A webhook fan-out can be several of them, and any callback that comes back the other way still needs its signature verified before it moves money. Each one adds its latency to your handler when you await it, and they stack: four sequential side effects at two hundred milliseconds each is most of a second of pure waiting, on top of whatever your actual work cost.

And it gets worse precisely when you can least afford it. Under a traffic spike, every concurrent request that awaits its side effects holds resources open for the duration of those external calls, which can cascade into timeouts and tie up your server right when it is busiest. The thing that was a minor sluggishness at low traffic becomes a real failure mode at high traffic.

The fire-and-forget pattern

Critical-path await versus fire-and-forget side effects after the response returns

The mechanism is simple: kick off the side effect without awaiting it, then return the response. On a long-lived server process, the side effect continues in the background after the response has already gone out. A small helper makes the intent explicit and handles the one thing you must not forget, catching errors so an unhandled rejection in the background does not crash anything:

function fireAndForget(promise) {
  // start it, do not wait on it, never let it throw unhandled
  void promise.catch((err) => logError(err));
}

// in the handler
fireAndForget(sendNotificationEmail(data));
fireAndForget(trackAnalyticsEvent(event));
fireAndForget(syncToCrm(record));
return res.json({ ok: true }); // returns now, not after three network calls

The handler does its real work, kicks off the three side effects without waiting, and returns immediately. The user gets their confirmation in the time the core work took, and the email, the analytics event, and the CRM sync all happen a moment later, in the background, where their latency costs the user nothing. The four-hundred-millisecond-plus handler becomes a sub-hundred-millisecond one, and nothing about the user's experience got worse, because the only thing that changed is that they stopped waiting on work that was never theirs to wait on.

"But what if it fails" is not a reason to block

The objection that keeps people awaiting side effects is failure handling. If I do not await the email, how do I know it sent? If I do not await the CRM sync, how do I handle a sync error? The answer is that awaiting buys you far less than it seems, and costs far more.

Awaiting a side effect to handle its failure means making every single user wait, on every single request, for the rare case where the side effect breaks. That is a bad trade. The right place to handle side-effect failures is not by blocking the user but by logging the failure so it shows up in your monitoring, and where the operation genuinely must succeed eventually, by retrying it in the background or putting it on a proper job queue with retry semantics. The user's confirmation does not need to wait on the email succeeding; your observability needs to know if it did not, and your retry logic needs to try again. Those are background concerns, not request-path concerns.

For the side effects that truly must not be lost, like a payment receipt or a critical downstream sync, a durable background queue is the correct home, not a blocking await. The request enqueues the job and returns; a worker processes it with retries and dead-letter handling, and those retries have to be safe to repeat, which is the whole point of making agent and job calls idempotent before they double-charge a customer. The user is fast, and the side effect is reliable, which awaiting gives you neither of as cleanly.

Apply it everywhere, not just to email

The trap is treating this as a special case for one slow operation when it is a general principle for the whole category. Every form submission, every signup, every login, every action that triggers a notification or a sync or a beacon, all of them have a critical path and a set of side effects, and the side effects should fire in the background in every one.

The login that issues a session and fires the verification email in the background. The signup that creates the account, returns the welcome screen, and syncs to the CRM behind the response. The contact form that accepts the message, thanks the user, and sends the team notification afterward. The purchase that completes, shows the receipt, and fans out the fulfillment webhooks in the background. In each case the user gets an instant, honest answer to the thing they did, and the slow external coordination happens off the path where its latency is invisible.

This is the kind of optimization that makes an app feel uniformly fast rather than randomly sluggish, and it is part of how we build web applications that stay responsive under real traffic. On the user's side, the perception of speed is reinforced by optimistic UI and smart skeletons, which show the result before the background work has even landed. It pairs naturally with the deliverability and infrastructure work underneath, because firing email in the background only helps if the email actually arrives, which is its own discipline of getting transactional mail to the inbox reliably. Fast and undelivered is not a win; fast and reliable is, and the two are built together.

The snappy app

The end state is an application where every action returns the instant the user's actual answer is ready, and never a moment later. The core work runs, the response goes out, and the side effects, the emails, the analytics, the syncs, the pings, all fire in the background where they belong. Failures are logged and retried rather than imposed on the user as a wait. Under load, handlers return quickly and free their resources instead of holding them open through external calls.

The discipline is one habit applied without exception: before you await anything, ask whether the user's next step depends on it, and if it does not, fire it in the background and return now. The latency the user feels is the only latency that matters, and keeping the critical path to exactly what the response needs is what makes the whole app feel fast. Everything else can happen after the user already has their answer.