A two-second wall clock on a signup form is not pessimism. It is the budget you can spend before the user notices the submit button stopped responding. Email validation that exceeds that budget is not "still checking" — it is already failing the product, whether or not the HTTP call eventually returns a verdict.

This page is about that budget: how to set it, what to do when it fires, and how not to confuse a client abort with the API's own unknown / dns_error answer. The broader rule that validation must stay advisory is in Fail Open: Email Validation Must Never Block a Signup; here the timeout is the concrete mechanism that makes the rule real.

Two different "I don't know" signals

Do not collapse these into one branch:

What happened Who decided What you store
Your client aborted at 2s, or the TCP path never completed You email_verdict = unchecked, email_mx_checked = 0
nobounce returned verdict: "unknown", reason: "dns_error", checked.mx: false The API email_verdict = unknown, email_reason = dns_error, email_mx_checked = 0
nobounce returned undeliverable / domain_not_found The API reject (or offer suggestion if present)

The middle row is a completed HTTP response: the engine ran, DNS over HTTPS answered with SERVFAIL (Status 2) or a transport failure on the resolver path, and the API refused to guess. That split — SERVFAIL fails open, NXDOMAIN rejects — is covered in SERVFAIL Is Not NXDOMAIN.

The top row is different. No verdict object arrived. Inventing unknown in your database for a call that never completed makes later cleanup queries lie: you cannot tell "we asked and DNS was broken" from "we never got an answer because our own deadline fired." Keep a distinct unchecked (or null) for client-side failures.

Pick a budget and enforce it on the client

Interactive signup should sit in the 1.5–3 second range. nobounce's own /integrate.md example uses AbortSignal.timeout(3000). Language how-tos on this blog commonly use 2 seconds. Batch imports are a different product surface and can wait longer — do not reuse a 30-second batch timeout on a form submit.

async function validateEmail(email) {
  try {
    const response = await fetch("https://nobounce.dev/v1/check", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.NOBOUNCE_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ email }),
      signal: AbortSignal.timeout(2000),
    });

    if (!response.ok) {
      // RFC 9457 problem+json — log body.fix, do not reject the user
      return { kind: "unchecked", cause: "http_error", status: response.status };
    }

    const body = await response.json();
    return { kind: "verdict", body };
  } catch (error) {
    const timedOut =
      error?.name === "TimeoutError" ||
      error?.name === "AbortError" ||
      error?.code === 20; // DOMException.ABORT_ERR in some runtimes
    return {
      kind: "unchecked",
      cause: timedOut ? "timeout" : "transport",
    };
  }
}

Three details matter more than the library:

  1. The key stays in an Authorization header, loaded from the environment. Never in a URL or query string.
  2. Non-2xx is fail-open too. Quota exhaustion (429), auth failures, and upstream 5xx are not evidence the address is bad.
  3. AbortSignal.timeout cancels the in-flight fetch. A hanging socket that you merely ignore still holds a connection; aborting frees it.

If you already created an AbortController for blur-vs-submit races, pass AbortSignal.any([controller.signal, AbortSignal.timeout(2000)]) so a newer keystroke and the wall clock both cancel the same request.

Map the result without a second policy

const result = await validateEmail(email);

if (result.kind === "unchecked") {
  return createAccount({
    email,
    emailVerdict: "unchecked",
    emailReason: result.cause, // "timeout" | "transport" | "http_error"
    mxChecked: false,
  });
}

const { verdict, reason, suggestion, checked } = result.body;

if (suggestion) {
  // Suggestion outranks the verdict — including risky/likely_typo.
  return respondWithSuggestion(suggestion);
}

if (verdict === "undeliverable") {
  return respondWithError(reason);
}

// deliverable, risky (no suggestion), unknown — accept and annotate
return createAccount({
  email,
  emailVerdict: verdict,
  emailReason: reason,
  mxChecked: checked.mx,
});

A timeout must not open a retry loop on the same submit. The user already waited two seconds; firing three more checks turns a soft failure into a hard hang and burns quota on an address you decided to accept anyway. If you want coverage later, queue a background re-check of rows where email_mx_checked = 0 — the same cleanup pattern as after a validator outage.

What a timeout is not

It is not proof the domain is bad. Rejecting on abort recreates the SERVFAIL trap in your own process: every slow day looks like a wave of invalid signups.

It is not a signal to fall back to SMTP probing. Mailbox probing is permanently out of scope for nobounce, and a slow HTTP call does not justify opening port 25 from your signup workers.

It is not a reason to call POST /demo/check from production. That endpoint is fixture-only, needs no key, and cannot validate arbitrary real addresses — useful for shaping UI states, useless as a backup validator.

It is not free. A request that times out after the server accepted it may still have consumed work on the far side; design quotas assuming borderline calls are not "saved" just because your client gave up. GET /v1/me is the authority for what was billed.

Verify the path you hope never runs

Point the helper at https://10.255.255.1/ (or shrink the timeout to 1ms) and submit a real signup. Confirm:

  • the HTTP response to the browser is success, not 400/502;
  • the user row lands with email_mx_checked = 0 and a distinct unchecked marker;
  • logs carry the RFC 9457 fix (for non-2xx) or the abort cause — never the raw address;
  • a second submit of the same form does not fan out retries.

A happy-path screenshot proves the API works. A forced timeout proves your conversion rate survives when it does not.

For wiring the same contract into a React form's action states, see Handle Email Validation Verdicts in a React Signup Form. The ordered agent procedure, including the 3-second integrate.md example, is at /integrate.md.