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.
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:
- Submit signup with a throwaway address
await page.waitForTimeout(10_000)- Scrape an inbox or assume the code is there
- 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 pollintervals— 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/linkand 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:
expect.polltimeout— how long the inbox poll may run (use ~60s for CI email).test.setTimeout/ configtimeout— 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.- Default expect timeout (5s) — applies to locator assertions like
toBeVisible, not to a poll that sets its owntimeout.
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_000for snappy staging;60_000when 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
/latestmany times - Prefer
expect.polloverpage.waitForTimeoutfor inbox readiness - Raise poll
timeoutand slowintervalsfor email; keep locator asserts for UI - Set
test.setTimeoutabove the poll deadline - Log the disposable
emailon failure for provider traces - Isolate parallel workers with one inbox per test
Related reading
- Ultimate Guide to Email OTP Testing with Playwright
- 7 Mistakes That Make Email OTP Tests Flake in CI
- Mailpit vs OTPBox for CI Verification Codes
- How to Test Email OTP Verification in CI
Resources
- OTPBox — create a disposable inbox in the browser
- OTPBox API —
POST /api/inbox,GET .../latest - Playwright assertions —
expect.poll,expect.toPass, auto-retrying matchers
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.