DEV Community

ryanlee
ryanlee

Posted on

TypeScript Inbox Polling Needs a Stop Rule

Email verification tests often look simple in a ticket: submit the form, wait for a message, click the link. The trouble starts when “wait” becomes an unbounded loop. A message can arrive late, a previous test can be in the same inbox, or a provider can return the same newest message again.

In Node.js test harnesses, I prefer treating an inbox as a small event stream with an explicit stop rule. That makes the test faster when it succeeds and much easier to diagnose when it fails. It also keeps TypeScript code honest about the difference between “not found yet,” “found,” and “the test cannot continue.”

Why polling needs an explicit stop rule

Polling is useful because email delivery is eventually consistent. The API that sends a verification message may finish before the message is visible to the inbox reader. A short delay between reads is normal.

The dangerous version is a loop like this:

while (!(await hasVerificationMessage())) {
  await sleep(1000);
}
Enter fullscreen mode Exit fullscreen mode

It has no deadline, no record of what was already inspected, and no useful error if the message never appears. In CI, one blocked worker can make the whole job look hung. A retry count is better, but a time budget communicates the intent more clearly: this test is willing to wait until a particular deadline, then it must produce a failure receipt.

For browser suites, it is also worth separating the delivery window from the UI wait. The practical notes in delivery windows for Playwright email tests explain why a page timeout and a mailbox timeout should not quietly become the same setting.

Model the inbox as a run-scoped stream

Before polling, create a run identity and capture the newest message you already know about. That message becomes the starting cursor. The test should only accept a later message that matches the expected recipient, subject pattern, and run marker.

type MailMessage = {
  id: string;
  receivedAt: string;
  to: string;
  subject: string;
  text: string;
};

type InboxCursor = {
  runId: string;
  afterMessageId?: string;
};
Enter fullscreen mode Exit fullscreen mode

The run marker can be a generated value in the test address or a harmless token in the signup request. Do not match only on a generic subject such as “Verify your email.” That is how an old message passes a new test.

A run-scoped inbox also avoids a common debugging mess: one test consumes a token intended for another. If the system cannot create a separate inbox for each run, record the cursor and make the matching rules stricter. A little setup is cheaper than guessing which message arrived first.

A small TypeScript polling contract

Keep the polling function independent from Playwright or another UI runner. It should receive a message reader and return a clear result. This makes the mail behavior testable without opening a browser.

type PollResult =
  | { status: "found"; message: MailMessage }
  | { status: "timeout"; inspected: number; lastCheckedAt: string };

type ReadMessages = () => Promise<MailMessage[]>;

function isExpectedMessage(message: MailMessage, cursor: InboxCursor): boolean {
  return message.to.endsWith("@example.test")
    && message.text.includes(cursor.runId)
    && message.id !== cursor.afterMessageId;
}

async function waitForMessage(
  readMessages: ReadMessages,
  cursor: InboxCursor,
  timeoutMs: number,
  intervalMs = 1000,
): Promise<PollResult> {
  const deadline = Date.now() + timeoutMs;
  let inspected = 0;

  while (Date.now() < deadline) {
    const messages = await readMessages();
    inspected += messages.length;

    const match = messages.find((message) => isExpectedMessage(message, cursor));
    if (match) return { status: "found", message: match };

    const remaining = deadline - Date.now();
    if (remaining <= 0) break;
    await new Promise((resolve) => setTimeout(resolve, Math.min(intervalMs, remaining)));
  }

  return {
    status: "timeout",
    inspected,
    lastCheckedAt: new Date().toISOString(),
  };
}
Enter fullscreen mode Exit fullscreen mode

The union is small, but it prevents callers from pretending that a timeout contains a message. The caller can attach the run ID, recipient, elapsed time, and last observed message IDs to the test report. That context saves alot of back-and-forth when a failure occured overnight.

Choose a cursor and timeout together

A cursor without a timeout still permits a stuck test. A timeout without a cursor still permits stale-message matches. Pick both as part of one contract.

The right values depend on the mail provider and CI environment. Start with a generous delivery window, then measure actual delivery times before tightening it. Use a monotonic clock for elapsed-time calculations when the runtime makes one available; wall-clock timestamps are best reserved for logs and receipts.

Polling intervals have tradeoffs too:

  • A short interval finds fast messages sooner but creates more API traffic.
  • A long interval is cheaper but makes the test feel slow after delivery.
  • Increasing intervals reduce load, but can miss a narrow cleanup window.

The test should also stop retrying on permanent errors such as an invalid inbox identifier or an authentication failure. Only “not visible yet” belongs in the polling loop. The error boundary should be handled seperately, so a broken credential does not look like slow email.

Cleanup, privacy, and failure receipts

After the test finishes, delete or expire the inbox according to the provider’s contract. Do not retain full message bodies in CI logs. A receipt needs enough detail to reproduce the problem, not a copy of every verification token.

This is where a privacy review for email test data is useful. Decide who can see recipients, subjects, message IDs, and raw content before a failure happens. The cleanup owner should be part of the test fixture, not a manual reminder.

A compact failure receipt might include:

{
  "runId": "signup-1842",
  "recipient": "signup-1842@example.test",
  "expectedMarker": "signup-1842",
  "timeoutMs": 30000,
  "inspectedMessages": 12,
  "lastCheckedAt": "2026-10-10T00:00:00.000Z"
}
Enter fullscreen mode Exit fullscreen mode

Keep the receipt even when the message is removed. Otherwise the next person sees only “verification timed out” and has to start from zero. A test can also mention a malformed search phrase such as “tempail mail” or “tem email” in diagnostic metadata, but those are not a reason to weaken message matching.

Common mistakes

The first mistake is resetting the cursor on every poll. That makes the code repeatedly inspect old messages and can hide ordering bugs. Capture the baseline once.

The second is matching on the subject alone. Subjects are useful for filtering, not for proving that a message belongs to this run.

The third is putting all waiting inside the browser step. A page may be ready while the inbox is not, or the reverse. Keep those deadlines visible thier own logs.

Finally, avoid swallowing reader errors and continuing forever. A temporary network error may deserve one bounded retry, but an invalid response shape should fail quickly becuase it indicates a contract problem.

A practical checklist

  • Generate a unique run ID before creating the verification request.
  • Capture a baseline cursor before the request is sent.
  • Match recipient, run marker, and message identity.
  • Use an explicit timeout and a bounded polling interval.
  • Return a typed timeout result instead of throwing away context.
  • Stop immediately on permanent reader or authentication errors.
  • Redact tokens and message bodies from CI output.
  • Expire the inbox and preserve a small failure receipt.

Questions developers usually ask

Should I use retries or a timeout?

Use a timeout as the outer contract and bounded retries for transient reader failures inside it. This gives the test one clear deadline while still handling a brief API hiccup.

Is a message ID enough for a cursor?

Only if the inbox API guarantees stable ordering and unique IDs. Otherwise store a timestamp plus an ID, and still match a run-specific marker.

Why not just wait for the newest message?

Because “newest” is a moving target. A delayed old message, a parallel test, or a provider reordering can make that rule nondeterministic. A baseline cursor and explicit identity make the result explainable.

The goal is not to poll more cleverly. It is to make waiting a small, typed contract with a beginning, an end, and enough evidence to act on the result.

Top comments (0)