When I review a flaky Playwright test, I usually find that the real problem is not the failure itself, it is that the test has no clear recovery policy. It does not know whether the app is still legitimately working toward a ready state, whether the failure is a transient environment problem, or whether the assertion has already exposed a real defect.

That is why I think about Playwright retry strategy as a decision framework, not a switch. The goal is not to make every test green. The goal is to preserve signal, reduce noise, and keep the suite honest about the state of the product.

Playwright gives us a few different mechanisms that can look similar from a distance, but solve different problems in practice. There are explicit waits, auto-waiting, assertion retries, test retries in Playwright, timeouts, and the option to fail fast when something is clearly broken. The mistake I see most often is applying one blanket recovery approach to every kind of failure. That hides the important distinctions.

A good test does not just ask, “Did it pass?” It asks, “What kind of failure just happened, and what should the test do next?”

The three recovery choices I use

When a Playwright step is not behaving as expected, I usually classify the situation into one of three buckets:

  1. Wait when the state is expected, but not yet ready.
  2. Retry when the failure is plausibly transient, and a fresh attempt is meaningful.
  3. Fail fast when the condition indicates a real product issue, broken test design, or invalid test data.

This is not just semantics. Each choice affects how much time the suite spends, how much signal it preserves, and how easy it is to debug later.

1. Wait when readiness is part of the user experience

Waiting is appropriate when the application is doing something the user would reasonably wait for too. A page is loading, a modal is animating into place, a network request is in flight, or a button becomes enabled after client-side validation. In these cases, the test should wait for the application to reach the next meaningful state.

In Playwright, this often means leaning on locators and assertions that already include waiting behavior, rather than adding arbitrary sleeps.

typescript

await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Profile saved')).toBeVisible();

That is preferable to waitForTimeout, because the test waits for a condition, not for a guess.

I also use explicit waiting when the page transitions are part of the product contract.

typescript

await page.goto('/dashboard');
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

This kind of waiting is not a workaround. It is a statement about expected behavior.

2. Retry when the failure is transient and the next attempt can legitimately succeed

Retry belongs in a narrower category. I only want it when the failure might have come from something outside the immediate correctness of the test, and when a second attempt is meaningful.

Examples include:

  • a temporary network hiccup in CI,
  • a browser context crash,
  • a short-lived race during environment startup,
  • a third-party dependency that occasionally responds slowly.

This is where test retries in Playwright can help, but only if I treat them as a controlled exception, not a default safety net.

Playwright supports retries at the test runner level. That can be useful in CI for detecting and smoothing over genuine flakes while still surfacing them in reports.

import { defineConfig } from '@playwright/test';

export default defineConfig({ retries: process.env.CI ? 2 : 0, });

A retry is justified only when the second attempt still exercises the same product behavior and can provide a useful result. If a retry simply repeats a broken precondition, it adds time and hides the underlying issue.

3. Fail fast when the failure is deterministic or indicates bad test design

Fail fast is often the most underused option. If an assertion fails because the app is in the wrong state, the selector is wrong, the data setup is invalid, or the test is asserting on something that should not be flaky, I want the test to stop immediately.

Fail fast is also the right answer when continuing would produce misleading output. If login failed, there is no value in spending two more minutes trying to interact with authenticated pages. If a required API call returned a 500, there is no point in pretending the rest of the flow is still meaningful.

The discipline here is to avoid “recovering” from defects that should be investigated.

My decision framework for Playwright reliability

When I am deciding whether a test should wait, retry, or fail fast, I ask a small set of questions.

1. Is the condition expected to change by itself?

If the answer is yes, I usually wait. The app is in a transition state, and the test needs to observe the eventual state.

A spinner disappearing, a toast appearing, or a button becoming enabled are all normal eventual states. They are not failures.

If the answer is no, I do not wait longer just because the test is uncomfortable. A missing element, a wrong label, or an invalid response usually means the app or the test is wrong.

2. Would a second attempt be independent of the first?

If a retry only repeats the same broken setup, it is not a meaningful retry. A useful retry should get a fresh browser context, a clean test state, or a new network attempt.

That is why test retries at the runner level are more defensible than wrapping every assertion in a custom retry loop. The runner can recreate enough context to make a second pass meaningful.

3. Is the failure inside the product, or inside the test harness?

If a locator is brittle, if the test depends on exact timing, or if it assumes state that was never established, the test needs repair, not retries.

A failure in the test harness often looks like:

  • a selector that matches too broadly,
  • a test relying on DOM order that changes,
  • a missing network mock,
  • shared state between tests,
  • assumptions about fixture data that are not true.

These failures should generally fail fast, because retrying them only delays the inevitable.

4. Will waiting increase clarity, or just hide slowness?

Waiting is useful when it models user-visible readiness. It is harmful when it masks poor performance or coordination issues.

If I find myself adding long timeouts to make a test pass, I treat that as a smell. The test may be telling me that the app is too slow, the setup is too heavy, or the assertion is placed too early.

How Playwright already helps before I add any retries

A lot of teams reach for retry logic before using Playwright’s built-in waiting behavior well. I try to exhaust the native tools first.

Playwright’s locator model waits for elements to be actionable in many interactions, and its expect assertions retry for a period of time before failing. That means many UI synchronization problems are already solved without custom code.

For example, this is usually enough:

typescript

const submit = page.getByRole('button', { name: 'Submit' });
await expect(submit).toBeEnabled();
await submit.click();

Instead of writing:

typescript

await page.waitForTimeout(2000);
await page.click('button.submit');

The first version expresses intent. The second version guesses.

For more on the tool itself, I keep the Playwright docs open when I am tuning suite behavior.

Where I draw the line on retries

I have learned to be skeptical of retries for one simple reason, they can turn a test suite into a lie detector with bad sensitivity. If retries are too generous, flaky tests disappear from sight instead of getting fixed.

I usually allow retries for:

  • CI-only infrastructure instability,
  • rare browser process failures,
  • a known flaky third-party dependency that I cannot stub yet,
  • short-lived deployment timing issues in ephemeral environments.

I usually do not allow retries for:

  • broken locators,
  • assertions on unstable UI text that is not part of the contract,
  • race conditions in test setup,
  • tests that depend on shared mutable state,
  • business logic bugs.

A retry policy should be narrow enough that a successful retry still means something. If the first run fails and the second run passes, that should tell me, “Something transient happened,” not, “The suite is fixed now.”

A simple classification I use in code review

When I review a test, I look at the failure source and classify it like this:

Wait

Use when the test is observing a legitimate transition.

Examples:

  • waiting for a save confirmation,
  • waiting for a list to populate after a fetch,
  • waiting for navigation after click,
  • waiting for a disabled control to become enabled.

Retry

Use when the operation can succeed on a fresh attempt, and a retry recreates the necessary conditions.

Examples:

  • flaky CI browsers,
  • temporary service unavailability,
  • unstable environment bootstrapping,
  • a one-off network failure that leaves the app untouched.

Fail fast

Use when the failure is definitive.

Examples:

  • wrong selector,
  • missing test data,
  • 4xx or 5xx response from a critical API,
  • impossible UI state,
  • assertion proving the product is wrong.

If a failure is telling you the truth, do not negotiate with it.

Common flaky Playwright tests and how I handle them

1. Element is not visible yet

If the element is expected to appear after a transition, I wait using a locator assertion.

typescript

await expect(page.getByRole('dialog', { name: 'Invite user' })).toBeVisible();

If the element should already exist but does not, I fail fast. That points to a product or test setup issue.

2. Click sometimes does nothing

This is often not a timing issue, it is a signal that the element is not ready, covered, disabled, or unstable in the DOM.

I prefer to inspect the actionability problem rather than retrying blindly. If Playwright cannot click an element consistently, I want to know why.

3. Assertion passes locally but flakes in CI

This often means the test relies on local machine timing, shared resources, or a side effect that is slower in CI. I do not immediately add retries. I first ask whether the test needs better synchronization, stronger setup isolation, or a more deterministic assertion.

If the issue is known to be transient in CI infrastructure, a small retry count may be acceptable while I fix the root cause.

4. Network-dependent flow intermittently fails

Here I decide whether the app itself is supposed to handle the transient failure. If the product has its own retry behavior, the test should observe that behavior rather than simulate its own. If the test is exercising a flaky dependency that belongs outside the product, I may mock it or fail fast on unexpected response shapes.

What I do in CI versus local runs

My Playwright retry strategy is usually stricter locally than in CI.

Locally, I want a failure immediately. I am debugging, and retries slow me down. In CI, I may allow a small number of retries to reduce noise from transient infrastructure failures and to capture more useful artifacts when the first run fails.

A common pattern is:

export default defineConfig({
  retries: process.env.CI ? 2 : 0,
  reporter: process.env.CI ? [['html'], ['line']] : 'list',
});

Even then, I try to make the CI retry policy visible in reporting. If a test only passes on retry, I want that fact surfaced, not hidden.

In continuous integration systems, retries should be treated like a diagnostic aid, not a substitute for fixing determinism. The general CI concept is straightforward, but the test policy around it needs discipline, especially in large suites (continuous integration).

How I reduce the need for retries in the first place

The best retry strategy is often the one you do not need.

Prefer locator-based assertions over sleeps

Playwright’s locator and expect APIs already wait for conditions. That removes a lot of artificial timing problems.

Isolate test data

Each test should create or reserve its own data, or use a fixture that is safely disposable. Shared state creates failures that look random but are actually deterministic collisions.

Mock what you do not own

If a test depends on a third-party service, I usually ask whether that dependency belongs in the scope of the UI test. Often it does not. Mocking the boundary gives me more deterministic coverage without pretending the third party is stable.

Keep assertions close to user outcomes

I try to assert on states that matter to the user, not on incidental DOM structure. The more brittle the assertion, the more likely I am to see failures that tempt me into retries.

Use one reason to fail

If a test can fail in five different places after a single root cause, it becomes harder to diagnose and more tempting to retry. Smaller tests, clearer setup, and focused assertions make failure useful.

A practical decision tree I use mentally

When a Playwright test fails, I ask:

  1. Is the app still legitimately transitioning? If yes, wait.
  2. Did the failure come from a transient infrastructure or network problem? If yes, consider a narrow retry.
  3. Is the failure deterministic, structural, or assertion-based? If yes, fail fast.
  4. Would another attempt be meaningfully independent? If no, do not retry.
  5. Am I hiding a design issue with a timeout? If yes, fix the test or the product.

That sequence keeps me from using retries as a universal bandage.

A few rules that have saved me time

  • Never add retries to a test before understanding the failure mode.
  • Never use waits to cover up uncertain locators.
  • Never retry setup that should be deterministic.
  • Never let a retry policy make a broken build look healthy.
  • Always investigate a test that only passes on the second try.

These sound simple, but they are the difference between a reliable suite and a noisy one.

Final thoughts on fail fast vs wait

My default is not “retry more”. My default is “make the test explain itself.” If the app is still working toward readiness, I wait for the correct state. If the failure is transient and a fresh attempt is meaningful, I allow a narrow retry. If the failure is real, I fail fast and fix the cause.

That is the core of a sane Playwright retry strategy. It keeps flaky Playwright tests from drowning out real regressions, and it forces me to be honest about what the suite actually knows.

If you are tuning a Playwright suite right now, I would start by asking one question for every flaky test: is this a wait problem, a retry problem, or a design problem? The answer is usually clearer than it first looks.