Most developers know Playwright "waits automatically." Few understand why it works — or why their tests still occasionally flake. This article goes deep on the internals so you can write tests that are truly rock-solid.
await page.fill('#email', '[email protected]');
await page.fill('#password', 'secret123');
await page.click('#submit');
await expect(page.locator('h1')).toHaveText('Welcome, Alice');
h1 assertion runs before the dashboard has loaded, sometimes the click fires before the button is fully interactive after a slow animation. You add a sleep(1000) and move on.| Check | Description |
|---|---|
| Attached | The element exists in the DOM (not detached) |
| Visible | The element has non-zero bounding box and is not visibility: hidden or opacity: 0 |
| Stable | The element's bounding box has not changed between two consecutive animation frames |
| Enabled | The element is not disabled (disabled attribute or aria-disabled) |
| Editable | The element is not readonly (for fill() actions) |
| Receives events | No other element is overlapping and intercepting pointer events at the target coordinates |
| Action | Attached | Visible | Stable | Enabled | Editable | Receives Events |
|---|---|---|---|---|---|---|
click() | Yes | Yes | Yes | Yes | — | Yes |
fill() | Yes | Yes | Yes | Yes | Yes | Yes |
check() | Yes | Yes | Yes | Yes | — | Yes |
hover() | Yes | Yes | Yes | — | — | Yes |
focus() | Yes | — | — | — | — | — |
selectOption() | Yes | Yes | Yes | Yes | — | Yes |
sleep() loop. Playwright executes JavaScript inside the browser page to evaluate each of these conditions at the frame level, then retries until they all pass or the timeout is reached.page.click(selector), here's what actually happens internally:Resolve selector → element found? → if not, retry after 100ms
// This runs inside the browser, not in your test code
function checkActionability(element) {
if (!element.isConnected) return { passed: false, reason: 'not attached' };
const box = element.getBoundingClientRect();
if (box.width === 0 || box.height === 0) return { passed: false, reason: 'not visible' };
const style = getComputedStyle(element);
if (style.visibility === 'hidden') return { passed: false, reason: 'visibility hidden' };
if (parseFloat(style.opacity) === 0) return { passed: false, reason: 'opacity 0' };
if (element.disabled) return { passed: false, reason: 'disabled' };
const centerX = box.x + box.width / 2;
const centerY = box.y + box.height / 2;
const topElement = document.elementFromPoint(centerX, centerY);
if (!element.contains(topElement)) return { passed: false, reason: 'element obscured' };
return { passed: true };
}
requestAnimationFrame), and checks if the bounding box has changed. If it has, the element is still animating — Playwright waits another frame and checks again.// Stability check pseudocode (runs inside browser)
const box1 = element.getBoundingClientRect();
await animationFrame(); // waits ~16ms
const box2 = element.getBoundingClientRect();
if (!rectsEqual(box1, box2)) {
return { passed: false, reason: 'element is moving' };
}
.click() on a DOM element from JavaScript, which bypasses event handling middleware.TimeoutError with a detailed message explaining which check failed and why:TimeoutError: locator.click: Timeout 30000ms exceeded.
=========================== logs ===========================
waiting for getByRole('button', { name: 'Submit' })
locator resolved to <button disabled id="submit">Submit</button>
element is not enabled - waiting...
// Per-action override
await page.getByRole('button', { name: 'Submit' }).click({ timeout: 5000 });
// Per-test override
test.setTimeout(60_000);
// playwright.config.ts — global overrides
export default defineConfig({
timeout: 30_000,
use: {
actionTimeout: 10_000,
navigationTimeout: 15_000,
}
});
expect() Assertions Auto-Wait Differentlyexpect() assertions also retry, but through a different mechanism called web-first assertions.// This retries for up to 5 seconds by default
await expect(page.locator('h1')).toHaveText('Welcome, Alice');
await expect(locator).toBeVisible();
await expect(locator).toBeHidden();
await expect(locator).toBeEnabled();
await expect(locator).toBeDisabled();
await expect(locator).toHaveText('Hello');
await expect(locator).toContainText('Hell');
await expect(locator).toHaveValue('[email protected]');
await expect(locator).toHaveAttribute('href', '/dashboard');
await expect(locator).toHaveCount(3);
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveTitle('Dashboard');
locator.textContent() does NOT auto-wait for a condition — it returns whatever value is there right now. Always prefer expect(locator).toHaveText() over manual assertion patterns:// Avoid — no retry, will flake
const text = await page.locator('h1').textContent();
expect(text).toBe('Welcome, Alice');
// Prefer — retries until the text matches or timeout
await expect(page.locator('h1')).toHaveText('Welcome, Alice');
// May click before API response populates the dropdown
await page.getByRole('button', { name: 'Load Options' }).click();
await page.getByRole('combobox').selectOption('Option A');
// Wait for the network response explicitly
const responsePromise = page.waitForResponse('**/api/options');
await page.getByRole('button', { name: 'Load Options' }).click();
await responsePromise;
await page.getByRole('combobox').selectOption('Option A');
await Promise.all([
page.waitForURL('/dashboard'),
page.getByRole('button', { name: 'Login' }).click(),
]);
await page.locator('.virtual-list-item').last().scrollIntoViewIfNeeded();
await expect(page.locator('table tbody tr')).toHaveCount(5);
const frame = page.frameLocator('iframe[name="payment"]');
await frame.getByLabel('Card number').fill('4242 4242 4242 4242');
// playwright.config.ts
use: {
launchOptions: {
slowMo: 500, // 500ms between each action
}
}
npx playwright test --trace on
npx playwright show-trace test-results/trace.zip
await page.pause(); // Opens Playwright Inspector
DEBUG environment variable to see every internal step:DEBUG=pw:api npx playwright test
textContent(), getAttribute(), or inputValue() unless you have no choice.getByRole, getByLabel, getByPlaceholder are more resilient and align with actionability semantics better than CSS selectors.page.waitForTimeout() — if you find yourself reaching for a fixed delay, that's a signal you need a proper waitForResponse, waitForURL, or expect() assertion instead.// Avoid — always either too slow or too fast
await page.waitForTimeout(2000);
// Prefer — completes as soon as the condition is met
await expect(page.locator('.spinner')).toBeHidden();
await expect(page.locator('.results')).toBeVisible();
waitForLoadState for navigation-heavy tests:await page.goto('/dashboard');
await page.waitForLoadState('networkidle'); // waits until no requests for 500ms
import { test, expect } from '@playwright/test';
test('user can log in and see their dashboard', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email address').fill('[email protected]');
await page.getByLabel('Password').fill('secret123');
await Promise.all([
page.waitForURL('/dashboard'),
page.getByRole('button', { name: 'Sign in' }).click(),
]);
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Welcome, Alice');
await expect(page.getByTestId('user-menu')).toContainText('Alice');
});
waitForTimeout, explicitly waiting for network responses when backend timing is the variable, and reading trace files when a test flakes in CI — are patterns we've adopted after debugging real failures on real pipeline runs. If you're testing a data-heavy frontend, the complexity in this article is not academic.This article was written by the Infoveave Engineering Team — building Unified Data Platform, agentic BI, and enterprise analytics infrastructure. Infoveave (by Noesys Software) helps organisations unify data, automate business processes, and act faster with AI-powered insights.
Ready to see Infoveave in action?