How to fix flaky Playwright assertions caused by immediate `isVisible()` checks?

Playwright

locator.isVisible() is a point-in-time query that returns the current visibility state immediately without any retrying. When used as a test assertion — especially after an action that triggers asynchronous rendering — it will return false if called before the element has had time to appear. Tests that use isVisible() to check state after async operations are structurally flaky: they pass when the render is fast enough and fail when it isn't.

Common mistake

test('shows success toast after submit', async ({ page }) => {
  await page.goto('https://app.example.com/settings');
  await page.getByRole('button', { name: 'Save' }).click();

  // isVisible() checks right now — toast may not have rendered yet
  const toastVisible = await page.getByRole('alert').isVisible();
  expect(toastVisible).toBe(true); // Flaky — passes only when render is fast
});

Also flaky in conditional logic:

// Timing-dependent branch — unreliable as a test signal
if (await page.getByText('Loading').isVisible()) {
  await page.waitForTimeout(2000); // Even worse
}

The fix

Replace isVisible() assertions with expect(locator).toBeVisible(), which retries automatically:

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

test('shows success toast after save', async ({ page }) => {
  await page.goto('https://app.example.com/settings');

  await page.getByLabel('Display name').fill('Ada Lovelace');
  await page.getByRole('button', { name: 'Save' }).click();

  // toBeVisible retries until element appears or timeout expires
  await expect(page.getByRole('alert')).toBeVisible({ timeout: 5000 });
  await expect(page.getByRole('alert')).toContainText('Settings saved');
});

test('hides loading spinner after data loads', async ({ page }) => {
  await page.goto('https://app.example.com/dashboard');

  // toBeHidden retries until element disappears
  await expect(page.getByTestId('loading-spinner')).toBeHidden({ timeout: 10000 });
  await expect(page.getByTestId('stats-panel')).toBeVisible();
});

isVisible() is appropriate for non-assertion reads — for example, skipping a step when an optional element might or might not be present:

test('dismisses cookie banner if present', async ({ page }) => {
  await page.goto('https://app.example.com');

  // Conditional action — isVisible() is fine here because we handle both branches
  const cookieBanner = page.getByRole('dialog', { name: /cookie/i });
  if (await cookieBanner.isVisible()) {
    await cookieBanner.getByRole('button', { name: 'Accept' }).click();
    await expect(cookieBanner).toBeHidden();
  }

  // Continue test regardless
  await page.getByRole('link', { name: 'Dashboard' }).click();
});

Why it works

expect(locator).toBeVisible() uses Playwright's internal polling mechanism — it checks the condition every ~100ms and keeps retrying until the assertion passes or the timeout is exceeded. This matches how browsers actually work: elements appear asynchronously after events, API responses, or animations. isVisible() makes a single synchronous DOM check, which is appropriate when you want a snapshot of current state but not when you need the test to wait for state to change.

Tips

  • The rule of thumb: if you find yourself writing expect(await locator.isVisible()).toBe(true), replace it with await expect(locator).toBeVisible().
  • isVisible() is genuinely useful in test.describe.only debugging sessions where you want to log the current page state without making the test conditional on it.
  • locator.waitFor({ state: 'visible' }) is a programmatic alternative to expect(locator).toBeVisible() when you want to wait without adding an assertion to the test report.
  • Flaky isVisible() checks in legacy test suites can be identified by searching for the pattern await .isVisible() followed by expect(...).toBe(true) — both should be migrated to the assertion form.