I was in the middle of a nightly smoke run when Playwright threw “Element is not visible” on a button that had just slid into view after a CSS transition. The test stopped, CI flaked, and the team started a three‑hour hunt. Turns out the page was fine; the element existed but was still hidden behind a loading overlay that never got dismissed in the headless browser. I spent the next day rewiring the whole waiting logic and the flaky rate dropped from 70 % to under 5 %. If you’ve ever stared at that same error, keep reading—you’ll see why the usual waitForSelector tricks aren’t enough and how to make your Playwright suite behave like a well‑tuned production service.
- “Element not visible” means the DOM node is there but fails Playwright’s visibility checks.
- Prefer assertion‑based waiting (`expect(locator).toBeVisible()`) over raw `waitForSelector` or fixed timeouts.
- Calibrate timeouts using real load‑time metrics; 2‑3× the 95th percentile is a good rule of thumb.
- Design locators as single‑step, resilient queries; avoid deep chaining that breaks on minor UI changes.
- Wrap waits in reusable utilities and enable test retries with proper tracing.
Before you start: Playwright 1.45+, Node 20+, basic familiarity with the Playwright test runner, and access to a CI environment that can generate traces.
Debugging “Element Not Visible” in Playwright
The “element not visible” error in Playwright means the element exists in the DOM but fails visibility criteria (e.g., zero‑size, hidden, obscured). Fix it by replacing page.waitForSelector() and page.waitForTimeout() with assertion‑based waiting like expect(locator).toBeVisible(), which has built‑in retry logic. Configure timeouts based on actual application performance metrics, not arbitrary values.
What the error REALLY means (vs. “not found”)
Playwright distinguishes three visibility states:
| State | Playwright check | When it happens |
|---|---|---|
| Not in DOM | page.locator('selector').count() === 0 | The selector never matched. |
| Hidden | !await locator.isVisible() | Element or an ancestor has display:none, visibility:hidden, or zero opacity. |
| Covered | await locator.isHidden() returns false but toBeVisible still fails | Another element overlaps it or it’s outside the viewport. |
The not visible message surfaces only after the locator has been found. That’s why a flaky test can still pass waitForSelector—the node is there, but the UI isn’t ready for interaction.
Common culprits: overlay, animation, lazy loading
- Modal or loading overlay – A full‑screen spinner stays in the DOM with
z-index> the target button. In headless mode the overlay may never be removed because arequestAnimationFramecallback never fires. - CSS transition – A button slides from
transform: translateX(-100%)to0. Playwright polls every 500 ms; if the transition duration is 800 ms you’ll hit the timeout. - Lazy‑loaded component – Infinite‑scroll lists or on‑demand widgets inject markup after an XHR resolves. The network request finishes, but the component’s internal state flags (
data-loaded) are still false.
My take: Most teams chase the “locate‑then‑click” pattern and forget that UI state is a first‑class citizen. Treating visibility as a pure DOM check is a recipe for flakiness.
—
The Core Waiting Strategies: waitFor vs. waitForSelector vs. Expectations
Command‑based waiting vs. auto‑waiting (and when to use each)
| Approach | How it works | Pros | Cons |
|---|---|---|---|
page.waitForSelector(selector, { state: 'visible' }) | Polls the DOM until the selector matches and is visible. | Simple one‑liner. | No built‑in retry after the promise resolves; you lose the richer error context. |
locator.waitFor({ state: 'visible' }) | Same as above but scoped to a Locator object. | Can chain with other locator methods. | Still a raw command; you have to manage timeout manually. |
expect(locator).toBeVisible({ timeout: 10000 }) | Assertion that auto‑retries every 100 ms until the condition is true or timeout. | Gives a diff snapshot in the trace, clear error, integrates with test retries. | Must be inside a test block that imports expect. |
Playwright’s auto‑waiting covers navigation, clicks, and file uploads automatically. When you add an explicit assertion, you get the same retry loop plus an expressive failure message that includes the DOM snapshot at the moment of timeout.
Leveraging expect assertions for superior state validation
// playwright-test@1.45
import { test, expect } from '@playwright/test';
test('submit button becomes clickable', async ({ page }) => {
await page.goto('https://example.com/form');
const submit = page.locator('button[type=submit]');
// Wait for the spinner to disappear first
await expect(page.locator('#spinner')).toBeHidden();
// Now assert visibility + enable state
await expect(submit).toBeVisible({ timeout: 8000 });
await expect(submit).toBeEnabled();
await submit.click();
});
The expect calls are tiny, but they replace two lines of manual polling and a waitForTimeout. The trace generated by Playwright will highlight exactly which check failed.
Tip: Chain multiple expectations when a UI element passes through several transient states—for example, hidden → visible → stable.
—
Smarter Timeout Configuration & Error Handling (2024/2025 Best Practices)
Project‑wide vs. test‑specific timeouts
Playwright lets you set defaults in playwright.config.ts:
// playwright.config.ts (Playwright 1.45)
import type { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
timeout: 30_000, // per‑test timeout
expect: {
timeout: 10_000, // default for expect()
},
use: {
navigationTimeout: 15_000,
actionTimeout: 5_000,
},
};
export default config;
For a critical checkout flow where the 99th percentile load time is 4 s, you might bump expect.timeout to 12 s only for that spec:
test('checkout completes', async ({ page }) => {
await page.goto('/checkout');
const next = page.locator('button[data-test=next]');
await expect(next).toBeVisible({ timeout: 12_000 });
});
Keep the global defaults tight to surface genuine regressions; override locally when you have concrete performance data.
Creating reusable, robust wait utilities with retry logic
A tiny wrapper around expect makes the pattern reusable:
// utils/wait.ts (Playwright 1.45)
import { Locator, expect } from '@playwright/test';
export async function waitForVisible(
locator: Locator,
opts?: { timeout?: number }
): Promise<void> {
const timeout = opts?.timeout ?? 8_000;
await expect(locator).toBeVisible({ timeout });
}
Usage:
await waitForVisible(page.locator('#profile-picture'), { timeout: 12_000 });
If you need to poll a non‑DOM condition (e.g., a JS variable), embed a custom retry:
export async function waitForCondition(
page: Page,
predicate: () => Promise<boolean>,
timeout = 5_000
) {
const start = Date.now();
while (Date.now() - start < timeout) {
if (await predicate()) return;
await page.waitForTimeout(250);
}
throw new Error('Condition not met within timeout');
}
My take: Centralising waits eliminates “copy‑paste” bugs where a team member forgets to adjust the timeout after a UI change.
—
Architectural Trade‑offs: Locators, Page Objects, and Fixtures
How your locator strategy impacts stability
Playwright offers three ways to locate elements:
- Single‑step CSS/XPath –
page.locator('button[data-action=save]') - Chained locators –
page.locator('#form').locator('button.save') - Text‑based locators –
page.getByRole('button', { name: /save/i })
The docs showcase chaining for readability, but in production I’ve seen churn when the parent selector changes (e.g., a redesign adds a wrapper Rule of thumb: Prefer a single‑step selector that uniquely identifies the target. If you must filter by hierarchy, use Playwright’s built‑in filtering options ( A Page Object Model (POM) in Playwright looks like: Notice the Tip: Export the — Third‑party embeds often swallow events or block the main thread. The most reliable trick is to wait for the iframe’s content to signal readiness via a custom data attribute: If the widget does not expose a flag, fall back to a network idle wait inside the frame: A recent SaaS company measured 10 k+ Playwright runs and discovered that 70 % of “not visible” failures came from hard‑coded The trace viewer then shows the exact DOM snapshot at the moment of the failed visibility check, cutting debug time from hours to minutes. Warning: Retries can mask genuine performance regressions. Pair them with a metric‑driven alert that flags when the average retry count spikes. When a new UI version ships behind a feature flag, the same test suite runs against two different DOM trees. Hard‑coded selectors break on the canary. The solution is to parameterise locators by feature flag: Run the test matrix in your CI pipeline and aggregate flakiness metrics per version. Over time you’ll see whether the new UI is truly ready for full rollout. — Sometimes visibility isn’t enough. Imagine a data table that renders rows asynchronously and adds a Call it in a test: Playwright can emit tracing JSON that you can ship to an APM (e.g., Datadog or New Relic). By tagging each trace with the test name, you can correlate a flare in visibility wait time with a backend latency spike. Upload the zip to your APM and create a dashboard that shows “average time spent in — Why it happens: The assertion timed out because the element was either hidden behind an overlay or never became visible within the default timeout. Fix: Why it happens: A single‑page app performed a client‑side route change without a full navigation event, so Playwright’s Fix: Use Why it happens: The element was found, then a re‑render removed it before the assertion ran—common with React’s strict mode or rapid list updates. Fix: Re‑query the locator right before the final interaction, or wait for a stable attribute instead of the element itself. Why it happens: The button stays disabled because an async request failed silently, leaving the UI in a pending state. Fix: Add a network‑idle wait or check the request’s status code. Why it happens: A sticky header or a toast notification covers the target element at the moment of interaction. Fix: Scroll the element into view or dismiss the covering UI first. — `locator.waitFor()` is a command that actively polls the DOM. `expect(locator).toBeVisible()` is an assertion that leverages Playwright’s built‑in auto‑retry and provides richer error messages. The assertion‑based approach is now the recommended best practice for clarity and reliability. Base timeouts on your Service Level Objectives (SLOs), not guesses. If your page’s 99th percentile load time is 5 seconds, set action timeouts to 2‑3× that (10‑15s). Use different timeout values for `navigationTimeout`, `actionTimeout`, and `expect` timeout based on the operation. This often indicates a race condition or unstable UI state. Instead of waiting for visibility, wait for a more stable state using a custom condition, like `expect(locator).toHaveAttribute(‘data-loaded’, ‘true’)` or combine checks: `await expect(locator).toBeVisible(); await expect(locator).toBeStable();`. — If you’ve tried any of these patterns or have a different “element not visible” story, drop a comment below. I love swapping war stories and learning new tricks from the community.filter) instead of nesting locators.// Bad: deep chain
const saveBtn = page.locator('#main').locator('#form').locator('button.save');
// Good: single‑step with role
const saveBtn = page.getByRole('button', { name: /save/i });Building resilient pages and components for complex SPAs
// pages/DashboardPage.ts (Playwright 1.45)
import { expect, Locator, Page } from '@playwright/test';
export class DashboardPage {
readonly page: Page;
readonly notificationBadge: Locator;
constructor(page: Page) {
this.page = page;
this.notificationBadge = page.locator('span[data-test=badge]');
}
async waitForReady() {
await expect(this.page.locator('#loading')).toBeHidden();
await expect(this.notificationBadge).toBeVisible();
}
}waitForReady method: it encapsulates all the “element not visible” guardrails. Test files now read like business intent:test('shows notification after new message', async ({ page }) => {
const dash = new DashboardPage(page);
await dash.waitForReady();
await expect(dash.notificationBadge).toHaveText('1');
});waitForReady into a fixture if many tests share the same page. That way a change in the UI only touches one place.Production‑Proven Patterns & Gotchas
Dealing with third‑party widgets and iframes
const widget = page.frameLocator('iframe[src*="chat-widget"]');
await expect(widget.locator('[data-state=loaded]')).toBeVisible();await widget.waitForLoadState('networkidle');
await expect(widget.locator('#start')).toBeEnabled();Handling non‑deterministic UI in CI/CD pipelines
waitForTimeout. The cure was two‑fold:
expect assertions.// playwright.config.ts
retries: process.env.CI ? 2 : 0,
use: {
trace: 'on-first-retry',
}Integration with progressive delivery & canary releases
function getSaveButton(page: Page, version: 'legacy' | 'new') {
return version === 'new'
? page.getByRole('button', { name: /publish/i })
: page.locator('button.save-legacy');
}Advanced Techniques: Custom Waiting Logic and Monitoring
Writing custom wait functions for bespoke UI states
data-loaded="true" attribute once the rendering pipeline finishes. A custom wait looks like:export async function waitForTableStable(page: Page, selector: string) {
const table = page.locator(selector);
await expect(table).toHaveAttribute('data-loaded', 'true', { timeout: 12_000 });
// Ensure the row count stabilises for 500 ms
let previous = -1;
for (let i = 0; i < 10; i++) {
const count = await table.locator('tr').count();
if (count === previous) break;
previous = count;
await page.waitForTimeout(500);
}
}await waitForTableStable(page, '#orders');
await expect(page.locator('#orders tr')).toHaveCount(42);Integrating with APM tools for performance correlation
test('search results load fast', async ({ page }, testInfo) => {
await page.tracing.start({ screenshots: true, snapshots: true });
await page.goto('/search?q=playwright');
await expect(page.locator('.result')).toBeVisible({ timeout: 8_000 });
await page.tracing.stop({ path: `traces/${testInfo.title}.zip` });
});toBeVisible”. When the metric crosses a threshold, the CI can automatically flag the build for investigation.Common Errors & Fixes
Error:
Error: Expect.await(locator).toBeVisible() timed out after 5000ms
await expect(page.locator('#overlay')).toBeHidden();
await expect(page.locator('#target')).toBeVisible({ timeout: 12_000 });Error:
Timeout 30000ms exceeded while waiting for navigationpage.goto never resolved.page.waitForURL or an explicit selector that signals route completion.await page.goto('/dashboard');
await page.waitForURL('**/dashboard');
await expect(page.locator('h1')).toContainText('Dashboard');Error:
LocatorError: element is detached from DOMconst button = page.locator('button[data-test=save]');
await expect(button).toBeVisible(); // Auto‑retries and re‑queries
await button.click(); // Safe because locator refreshesError:
AssertionError: Expect.poll timed out waiting for condition 'toBeEnabled'await page.waitForResponse(resp => resp.url().includes('/init') && resp.status() === 200);
await expect(page.locator('#submit')).toBeEnabled();Error:
Error: Element is obscured by another elementawait page.locator('#toast-close').click({ force: true });
await page.locator('#action-btn').scrollIntoViewIfNeeded();
await expect(page.locator('#action-btn')).toBeVisible();Frequently asked questions
What’s the difference between `locator.waitFor()` and `expect(locator).toBeVisible()`?
How long should my test timeouts be set to?
Why does my element become visible and then immediately disappear, causing a failure?