← Back to Blog

How Playwright expect.poll Beats Fixed Sleeps for Email OTP

Stop page.waitForTimeout in OTP E2E. Use expect.poll with real timeouts and intervals to wait for disposable-inbox codes without flaky CI.

testingplaywrightotpci-cdhow-to
How Playwright expect.poll Beats Fixed Sleeps for Email OTP

Email OTP tests fail in the dumbest way: the code arrives, but your suite already moved on — or you slept for ten seconds every run and the suite still timed out under load. Fixed sleeps and page.waitForTimeout trade one flake for another. Playwright already has the tool for this shape of problem: expect.poll.

This how-to shows how to wait for a disposable-inbox verification code with real timeouts and intervals, using @playwright/test and OTPBox. No invented benchmarks — defaults and options below match Playwright's assertion docs and the OTPBox API.

The fixed-sleep failure mode

A common CI sketch looks like this:

  1. Submit signup with a throwaway address
  2. await page.waitForTimeout(10_000)
  3. Scrape an inbox or assume the code is there
  4. Fill the OTP and hope

When mail is fast, you waste wall clock. When the provider or CI runner is slow, you assert too early and the job goes red. Parallel workers make shared inboxes race. The broader failure modes are catalogued in 7 Mistakes That Make Email OTP Tests Flake in CI; the full Playwright wiring lives in the Ultimate Guide to Email OTP Testing with Playwright.

Playwright's docs prefer auto-retrying assertions and, for complex async conditions, expect.poll or expect.toPass. Fixed sleeps are discouraged because they ignore application state. Prefer waiting for a condition — here, " /latest returned an otp or link."

What expect.poll does

expect.poll turns a synchronous matcher into an asynchronous polling loop. You pass a function that returns the current value; Playwright re-invokes it until the matcher passes or the timeout fires.

From the assertions docs:

Setting Default
Timeout 5 seconds
Intervals [100, 250, 500, 1000] ms

Options you will actually set for email:

  • timeout — overall deadline for the poll
  • intervals — delay sequence between probes (last value repeats)
  • message — custom label in the failure report

Example shape from the docs (status code → for us, OTP string):

await expect
  .poll(
    async () => {
      const response = await page.request.get("https://api.example.com");
      return response.status();
    },
    {
      message: "make sure API eventually succeeds",
      timeout: 10_000,
      intervals: [1_000, 2_000, 10_000],
    }
  )
  .toBe(200);

Five seconds and sub-second probes are fine for DOM readiness. Email delivery is not DOM readiness. Keep the API; change the numbers.

Why email needs longer timeouts and slower intervals

Disposable OTP inboxes are eventual:

  • Your app queues mail through SES, SendGrid, Postmark, or similar
  • The provider accepts, then delivers to *@box.otpbox.app
  • OTPBox extracts otp / link and serves them on /latest

That path is usually a few seconds. CI variance (cold runners, provider blips, parallel load) is why the docs suggest starting around every 3 seconds for up to ~30 seconds, and why a ~60 second poll deadline is a reasonable CI cushion. Qualitative only — do not treat those as SLAs.

Polling every 100ms for a minute mostly burns HTTP and log noise. Slower intervals (2–3s after the first probes) match how mail actually arrives.

OTPBox specifics to stay consistent with other posts:

Call Role Rate notes
POST /api/inbox Create → email, token, expiresAt 10 creates/hour per IP; free, no API key
GET /api/inbox/:token/latest Latest otp / link (404 if empty) Poll freely

Product OTP addresses are *@box.otpbox.app. Inboxes last about one hour. Create once outside the poll; poll /latest inside expect.poll. Creating inside the loop burns the create quota and does not make mail arrive faster. Shell and Actions patterns: How to Test Email OTP Verification in CI. Local SMTP catchers vs hosted OTP: Mailpit vs OTPBox for CI Verification Codes.

Bad vs good TypeScript

Bad — fixed sleep

import { test, expect } from "@playwright/test";

test("signup OTP (flaky)", async ({ page }) => {
  // ... create inbox + fill signup ...
  await page.getByRole("button", { name: "Create account" }).click();

  // Discouraged: ignores whether mail arrived
  await page.waitForTimeout(10_000);

  const res = await fetch(`https://otpbox.app/api/inbox/${token}/latest`);
  const data = await res.json();
  await page.getByLabel("Verification code").fill(data.otp);
});

Problems: always waits the full sleep when mail is early; fails when mail is late; one-shot fetch has no retry; failures say "timeout" without a clear poll story.

Good — expect.poll with email-sized options

import { test, expect } from "@playwright/test";

test("signup OTP (poll)", async ({ page }) => {
  // ... create inbox + fill signup ...
  await page.getByRole("button", { name: "Create account" }).click();
  await expect(page.getByText(/check your email|enter the code/i)).toBeVisible();

  const otp = await expect
    .poll(
      async () => {
        const res = await fetch(`https://otpbox.app/api/inbox/${token}/latest`);
        if (!res.ok) return null;
        const data = (await res.json()) as { otp?: string | null };
        return data.otp ?? null;
      },
      {
        message: "waiting for email OTP from OTPBox /latest",
        timeout: 60_000,
        intervals: [2_000, 3_000],
      }
    )
    .not.toBeNull();

  await page.getByLabel("Verification code").fill(otp as string);
  await page.getByRole("button", { name: "Verify" }).click();
  await expect(page).toHaveURL(/\/(dashboard|home|app)/);
});

The poll stops as soon as /latest returns a code. If nothing arrives by the deadline, the failure includes your message and the call log — much easier to debug than a naked waitForTimeout.

Full helper: create → trigger → poll → fill

Put shared helpers next to your tests (for example tests/helpers/otpbox.ts):

import { expect } from "@playwright/test";

export type LatestExtraction = {
  otp?: string | null;
  link?: string | null;
  from?: string;
  subject?: string;
};

export async function createInbox(): Promise<{ email: string; token: string }> {
  const res = await fetch("https://otpbox.app/api/inbox", { method: "POST" });
  if (!res.ok) {
    throw new Error(`OTPBox create failed: ${res.status}`);
  }
  const data = (await res.json()) as { email: string; token: string };
  return { email: data.email, token: data.token };
}

/** Poll /latest until otp or link appears. Create the inbox once *before* calling this. */
export async function waitForLatestWithPoll(
  token: string,
  {
    timeout = 60_000,
    intervals = [2_000, 3_000] as number[],
  }: { timeout?: number; intervals?: number[] } = {}
): Promise<LatestExtraction> {
  const latest = await expect
    .poll(
      async () => {
        const res = await fetch(`https://otpbox.app/api/inbox/${token}/latest`);
        if (!res.ok) return null;
        const data = (await res.json()) as LatestExtraction;
        if (data.otp || data.link) return data;
        return null;
      },
      {
        message: "waiting for OTPBox /latest otp or link",
        timeout,
        intervals,
      }
    )
    .not.toBeNull();

  return latest as LatestExtraction;
}

Wire it in a test:

import { test, expect } from "@playwright/test";
import { createInbox, waitForLatestWithPoll } from "./helpers/otpbox";

test("signup verifies email OTP", async ({ page }) => {
  test.setTimeout(90_000); // room for UI + up to 60s poll

  const { email, token } = await createInbox();
  console.log(`OTPBox inbox: ${email}`);

  await page.goto("/signup");
  await page.getByLabel("Email").fill(email);
  await page.getByLabel("Password").fill("correct-horse-battery");
  await page.getByRole("button", { name: "Create account" }).click();

  await expect(page.getByText(/check your email|enter the code/i)).toBeVisible();

  const { otp } = await waitForLatestWithPoll(token);
  expect(otp).toBeTruthy();

  await page.getByLabel("Verification code").fill(otp!);
  await page.getByRole("button", { name: "Verify" }).click();

  await expect(page).toHaveURL(/\/(dashboard|home|app)/);
  await expect(page.getByRole("heading", { name: /welcome/i })).toBeVisible();
});

Magic-link variant: poll the same way, then await page.goto(link!) and assert with toHaveURL / role matchers instead of filling a code field.

Tuning: test timeout vs poll timeout vs message

Three clocks people confuse:

  1. expect.poll timeout — how long the inbox poll may run (use ~60s for CI email).
  2. test.setTimeout / config timeout — the whole test, including navigation and UI asserts. Set this above the poll timeout (for example 90s) or the runner kills the test while the poll is still legal.
  3. Default expect timeout (5s) — applies to locator assertions like toBeVisible, not to a poll that sets its own timeout.

Use the message option so CI logs say waiting for OTPBox /latest otp or link instead of a generic poll failure. Soft polls (expect.soft.poll) exist if you must continue after a failed wait; for OTP, fail closed — a missing code usually means send failed, not "need another sleep."

Starting points that match the docs' qualitative advice:

  • Intervals: [2_000, 3_000] (or [1_000, 2_000, 3_000] if you want a faster first probe)
  • Poll timeout: 30_000 for snappy staging; 60_000 when CI variance bites
  • Do not create a new inbox on each interval

When expect.toPass helps

expect.toPass retries a block until every assertion inside passes. Defaults differ: by default toPass has timeout 0 (no deadline unless you set one) and the same default intervals as poll ([100, 250, 500, 1000]). Prefer expect.poll when you are reducing to one value (the OTP string). Prefer toPass when several checks must hold together — for example "mail arrived and subject matches and OTP is six digits":

await expect(async () => {
  const res = await fetch(`https://otpbox.app/api/inbox/${token}/latest`);
  expect(res.ok).toBeTruthy();
  const data = (await res.json()) as { otp?: string; subject?: string };
  expect(data.subject ?? "").toMatch(/verify|code|confirm/i);
  expect(data.otp).toMatch(/^\d{4,8}$/);
}).toPass({
  timeout: 60_000,
  intervals: [2_000, 3_000],
});

For the thin path — get the code, fill it, assert the dashboard — expect.poll stays clearer.

Checklist

  • Create the inbox once; poll /latest many times
  • Prefer expect.poll over page.waitForTimeout for inbox readiness
  • Raise poll timeout and slow intervals for email; keep locator asserts for UI
  • Set test.setTimeout above the poll deadline
  • Log the disposable email on failure for provider traces
  • Isolate parallel workers with one inbox per test

Related reading

Resources


Fixed sleeps guess. expect.poll asks. For email OTP in Playwright, create a unique *@box.otpbox.app inbox, trigger the send, poll /latest with a CI-sized deadline, then fill the code — and leave waitForTimeout for local debugging, not the pipeline.