---
title: "How to Handle an Email Validation Timeout at Signup"
description: "Bound /v1/check to two seconds, treat AbortError and RFC 9457 errors as no opinion, and keep a timed-out signup from looking like a rejected address."
slug: "handle-email-validation-timeout"
date: 2026-09-29
updated: 2026-09-29
last_tested: 2026-09-29
summary: "A signup-path timeout is a client decision, not a verdict: abort the request, accept the user, annotate mxChecked=false, and never retry the same submit."
cluster: "Signup flows"
intent: workflow
sources:
  - title: "MDN — AbortSignal.timeout()"
    url: "https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static"
  - title: "Node.js globals — fetch and AbortSignal.timeout"
    url: "https://nodejs.org/api/globals.html#abortsignaltimeoutmilliseconds"
  - title: "RFC 9457 — Problem Details for HTTP APIs"
    url: "https://www.rfc-editor.org/rfc/rfc9457.html"
  - title: "RFC 1035 — DNS response codes (RCODE)"
    url: "https://www.rfc-editor.org/rfc/rfc1035.html#section-4.1.1"
  - title: "nobounce.dev integration guide"
    url: "https://nobounce.dev/integrate.md"
  - title: "nobounce.dev verdict taxonomy"
    url: "https://nobounce.dev/v1/config"
---

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](https://nobounce.dev/blog/fail-open-email-validation-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](https://nobounce.dev/blog/servfail-vs-nxdomain-email-validation/).

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.

```js
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

```js
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](https://nobounce.dev/blog/email-validation-verdicts-in-react-form/). The ordered agent procedure, including the 3-second integrate.md example, is at [/integrate.md](https://nobounce.dev/integrate.md).
