Checking at Signup Without Blocking the Flow

There are three defensible ways to fit a risk check into a signup flow, and the right one depends on how costly a bad account is to create versus how costly a slow response is to your conversion rate. This page walks through all three with working code.

The three patterns, at a glance

PatternUser waits forBest when
BlockingThe full check result before the account is createdA bad account is expensive (paid signup, high-trust action) and a few seconds of latency is acceptable
Fully asyncNothing — account is created immediately, check runs afterSignup speed matters most, and acting on a bad account after the fact (revoke, flag, restrict) is easy
HybridA fast, cache-only pre-check onlyYou want to reject the most obvious abuse instantly, without the latency of a full check on every signup

Pattern 1: Blocking (check before creating the account)

The signup handler calls the API and waits for the result before deciding whether to create the account at all. This is the simplest pattern and the one most articles default to — it's the right choice when a bad account is genuinely costly (a paid trial with real credit consumption, a marketplace listing, anything with immediate financial exposure).

signup-blocking.ts
async function handleSignup(email: string, password: string) {
  const result = await fetch("https://api.emailriskradar.com/v1/email/check", {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.EMAIL_RISK_API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({ email, mode: "standard", context: "signup" }),
    signal: AbortSignal.timeout(3000), // don't hang the signup request indefinitely
  }).then((r) => r.json()).catch(() => null); // treat a failed/timed-out check as "unknown", not a crash

  if (result?.decision === "block") {
    return { error: "This email address can't be used to sign up." };
  }

  const account = await createAccount({ email, password, needsReview: result?.decision === "review" });
  return { account };
}

Two details matter here: a timeout (so a slow API response doesn't hang your signup form indefinitely) and a fallback when the check fails entirely (treat it as unknown and let the account through, rather than blocking every signup because of a transient network issue — a false block is usually worse than a missed one).

Pattern 2: Fully async (check after the account exists)

The account is created immediately — the user never waits on the risk check at all. The check runs in the background (fire-and-forget, a queue job, or a webhook-driven bulk pipeline), and the result is used to flag, restrict, or revoke the account afterward. This is the right pattern when signup speed matters most and a bad account is cheap to unwind (a free tier with no immediate cost exposure).

signup-async.ts
async function handleSignup(email: string, password: string) {
  const account = await createAccount({ email, password });

  // Fire-and-forget - don't await this in the request/response cycle.
  // Errors are still handled explicitly; they're just not on the critical path.
  checkEmailInBackground(account.id, email).catch((err) =>
    logger.error("background email check failed", { accountId: account.id, err })
  );

  return { account };
}

async function checkEmailInBackground(accountId: string, email: string) {
  const result = await fetch("https://api.emailriskradar.com/v1/email/check", {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.EMAIL_RISK_API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({ email, mode: "standard", context: "signup" }),
  }).then((r) => r.json());

  if (result.decision === "block") {
    await restrictAccount(accountId, { reason: "email_risk_block", signals: result.signals });
  } else if (result.decision === "review") {
    await flagAccountForReview(accountId, result);
  }
}

A plain fire-and-forget call (as above) is fine at low volume, but for anything production-scale, push the check onto a real background job queue (BullMQ, SQS, Cloud Tasks, whatever you already use) instead of relying on a dangling promise in a serverless function that might get frozen mid-request. The core logic — call the API, act on the decision — stays the same either way.

Pattern 3: Hybrid (fast pre-check, full check in background)

A middle ground: run a fast, cache-only check synchronously (mode: "fast" never makes a live network call, so it adds negligible latency), and only block on the small number of cases that come back as an obvious, high-confidence block. Everything else — including anything the fast check couldn't confidently resolve — gets a fuller standard or full check in the background, same as pattern 2.

signup-hybrid.ts
async function handleSignup(email: string, password: string) {
  const fastResult = await fetch("https://api.emailriskradar.com/v1/email/check", {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.EMAIL_RISK_API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({ email, mode: "fast", context: "signup" }),
  }).then((r) => r.json());

  // Fast mode is cache-only, so this only catches things already known
  // (a disposable domain already in the list, an obviously invalid syntax) -
  // it will not catch everything full/standard mode would.
  if (fastResult.decision === "block") {
    return { error: "This email address can't be used to sign up." };
  }

  const account = await createAccount({ email, password });
  checkEmailInBackground(account.id, email); // standard/full check, same as pattern 2
  return { account };
}

This is a signup-time pattern, not a per-login check

Risk checking belongs at account creation (signup), not on every subsequent login. Once an address has been checked and an account exists, there's normally no reason to re-run the check on every sign-in — that would add latency to your most frequent, already-trusted flow for no benefit. If you want to catch risk that changes over time (a domain that becomes disposable after signup, for instance), that's a periodic re-check on a schedule, not something tied to the login flow.

Quick decision guide

  • Free trial, low cost per bad account → fully async (pattern 2)
  • Paid signup, checkout, or anything with immediate financial exposure → blocking (pattern 1), with a timeout and fallback
  • High signup volume where most addresses are obviously fine → hybrid (pattern 3)
  • Bulk import of an existing user base → the bulk endpoint, not a loop of single checks — see Bulk List Cleanup

We use Google Analytics to understand site traffic, and only load it if you accept — nothing runs before you choose. Signing in still stores a strictly necessary session token regardless. See the Privacy Policy for details.