DEV Community

Silviu Technology
Silviu Technology

Posted on

Playwright Email Polling Needs Exit Codes

Playwright is great at driving the browser, but email verification still becomes the flaky part in many signup suites. I keep seeing the same pattern: the test creates an account, starts polling an inbox, and then either waits too long or fails with no clue about what really happened. That kind of failure wastes time because QA, backend, and product all read the same red build in diffrent ways.

The fix is not "retry more". The fix is to give email polling a small contract with clear exit reasons. When I use a throwaway email generator in end-to-end flows, I want the test to say whether it timed out, found the wrong message, saw a stale message, or never got the expected subject. That sounds basic, but it changes triage a lot.

Why email polling flakes in Playwright

Most flaky email checks are not caused by Playwright itself. They come from vague expectations:

  • no maximum polling window
  • no reason code on failure
  • no capture of what messages were actually seen
  • no distinction between "mail server slow" and "assertion wrong"

Without those details, a failing test only says "email not found" and everybody guesses. Sometimes the inbox had old messages. Sometimes the app sent the email late. Sometimes the test searched for an exact subject even though the product team changed one word. I have seen all three, and they look annoyingly similar at first glance.

A small contract for bounded polling

The contract I like is short:

  1. poll for a fixed window
  2. store the last inbox snapshot
  3. return a machine-readable exit code
  4. attach one human hint for triage

That gives a test enough structure to fail usefully. It also makes your QA reports more honest, becuase you stop calling every issue "flaky" when some are really content mismatches or delayed workers.

Example exit codes can be:

  • MESSAGE_FOUND
  • EMPTY_INBOX
  • ONLY_STALE_MESSAGES
  • SUBJECT_MISMATCH
  • POLL_TIMEOUT

Those codes work well with dashboards and CI annotations. They also pair nicely with operational ideas like inbox correlation ids in auth flows, where one identifier lets you connect the UI action to the email trail.

Playwright example with exit reasons

Here is the pattern in a compact helper:

type PollResult = {
  code:
    | "MESSAGE_FOUND"
    | "EMPTY_INBOX"
    | "ONLY_STALE_MESSAGES"
    | "SUBJECT_MISMATCH"
    | "POLL_TIMEOUT";
  messageId?: string;
  lastSubjects: string[];
  detail: string;
};

export async function waitForVerificationEmail(
  inbox: { listMessages: () => Promise<Array<{ id: string; subject: string; createdAt: string }>> },
  expectedSubject: string,
  startedAtIso: string,
  timeoutMs = 20000,
): Promise<PollResult> {
  const deadline = Date.now() + timeoutMs;
  let lastSubjects: string[] = [];

  while (Date.now() < deadline) {
    const messages = await inbox.listMessages();
    lastSubjects = messages.map((item) => item.subject);

    if (messages.length === 0) {
      await new Promise((resolve) => setTimeout(resolve, 2000));
      continue;
    }

    const freshMessages = messages.filter(
      (item) => new Date(item.createdAt).getTime() >= new Date(startedAtIso).getTime(),
    );

    if (freshMessages.length === 0) {
      await new Promise((resolve) => setTimeout(resolve, 2000));
      continue;
    }

    const match = freshMessages.find((item) => item.subject.includes(expectedSubject));
    if (match) {
      return {
        code: "MESSAGE_FOUND",
        messageId: match.id,
        lastSubjects,
        detail: "Expected verification email found in bounded window.",
      };
    }

    return {
      code: "SUBJECT_MISMATCH",
      lastSubjects,
      detail: "Fresh email arrived, but the expected subject was not present.",
    };
  }

  return {
    code: lastSubjects.length === 0 ? "EMPTY_INBOX" : "POLL_TIMEOUT",
    lastSubjects,
    detail: "No matching verification email was found before the deadline.",
  };
}
Enter fullscreen mode Exit fullscreen mode

This helper is intentionally boring, which is why it tends to survive. It avoids infinite waits, records what was visible, and returns one result object your test can print directly into CI logs. If your team uses phrases like temp mailid in scenario notes, keep that text in metadata or fixtures, not in your main assertion logic.

I also like pairing this with shared signup email rules so frontend and backend agree on what counts as a valid verification message. That removes a surprsing amount of drift.

What to record when the inbox stays empty

When a run fails, I want four facts right away:

  • when polling started
  • how many polls ran
  • the last seen subjects
  • the exit code

That tiny receipt saves a lot of Slack back-and-forth. It tells you whether the inbox integration broke, the app sent nothing, or the worker was just slower than normal. Google’s testing guidance also pushes teams toward fast, deterministic feedback loops instead of oversized retries that hide root causes source.

One more practical note: keep only a few end-to-end email tests. Use them for the critical path, then cover formatting, queue logic, and edge cases closer to the service layer. Otherwise the suite gets expensive and a bit messy fast.

Quick Q&A

Should every email test poll a real inbox?

No. Reserve real inbox polling for the flows where delivery itself matters. Many other cases can assert job state or provider payloads.

Is a longer timeout safer?

Not usualy. A longer timeout can reduce noise for one week and then quietly mask a latency regression for the next month.

What matters most?

Clear exit codes. When the failure tells you what kind of miss happened, the fix gets much faster and the test feels way less random.

Top comments (0)