iTestBDD

Step Retry Logic Masks Real Flakiness in Playwright

Playwright's auto-waiting and built-in retry logic are genuinely useful — until they aren't. Most teams enable retries: 2 in playwright.config.ts, watch their green pass rate climb, and call it stability. What they've actually done is teach the suite to swallow intermittent failures quietly. The test passes on the third attempt; the underlying race condition ships to production.

The specific failure mode this article addresses lives in Playwright hooks — beforeEach, afterEach, and their fixture equivalents — where retry semantics interact with setup and teardown state in ways that aren't obvious from the docs. When a step inside a hook retries, it may re-execute side effects: seeding a database twice, issuing a second login token, or leaving a browser context in a half-initialized state. The test outcome becomes a function of retry count, not application behavior.

By the end, you'll be able to distinguish retry-masked flakiness from genuine intermittent failures, instrument your hooks to surface the difference, and configure Playwright so retries expose problems rather than hide them. This matters now because Playwright 1.40+ changed how fixture teardown interacts with testInfo.retry, and teams upgrading from 1.3x are seeing new categories of ghost passes.

Trading Strategy Mechanics Explained

Learn how trading strategies, execution, market regimes, and risk work—without signals or hype.

Learn more

What Playwright Retry Actually Does Inside Hooks

When retries is set in playwright.config.ts, Playwright re-runs the entire test — including all beforeEach hooks and fixture setup — not just the failing assertion. This is fundamentally different from Selenium's retry-analyzer pattern, where you typically wrap a single assertion or action. In Playwright, the retry boundary is the test function itself, which means every side effect in your hooks runs again on each attempt.

Fixture teardown is where this gets dangerous. If your afterEach hook calls a cleanup endpoint and the test retried after that endpoint was already hit, you're now issuing a second DELETE against a resource that no longer exists. Playwright 1.40 introduced testInfo.retry as a first-class value in fixture scope, but most hook implementations don't branch on it. The result: hooks that were written assuming a single execution silently misbehave on retry, and the test still passes because the application happens to tolerate the double-hit. That tolerance is not a feature — it's a latent bug waiting for a load spike to surface it.

Instrumenting Hooks to Expose Retry-Masked Failures

The first step is making retries visible. Add a structured log line at the top of every hook that includes testInfo.retry. When you pipe this to OpenTelemetry or even a plain JSON log aggregator in Grafana, you can query for tests where retry > 0 and status === 'passed' — those are your masked failures.

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  retries: process.env.CI ? 2 : 0,
  use: { trace: 'on-first-retry' },
});
// fixtures/base.ts
import { test as base } from '@playwright/test';

export const test = base.extend({
  instrumentedPage: async ({ page }, use, testInfo) => {
    if (testInfo.retry > 0) {
      console.warn(JSON.stringify({
        event: 'hook_retry',
        test: testInfo.title,
        attempt: testInfo.retry,
        timestamp: Date.now(),
      }));
    }
    // Guard side-effectful setup against double-execution
    if (testInfo.retry === 0) {
      await page.goto('/app/reset-state');
    }
    await use(page);
    // Teardown only if we're on the final attempt
    if (testInfo.retry === testInfo.project.retries) {
      await page.request.delete('/api/test-session');
    }
  },
});

The testInfo.retry === 0 guard on state-seeding is the critical line. Without it, a flaky network response in your app's login flow causes the seed endpoint to fire twice, leaving the database in an unexpected state that makes the next test flaky — a cascade that looks random in CI. One team reduced their "unexplained" flaky rate from ~14% to under 2% in a single sprint just by adding this guard across their fixture library. Run time didn't change; signal quality did.

Correlating Retries in BDD Hook Wrappers

If you're running Playwright inside a BDD layer — Cucumber-JS 9 with a Playwright world, or a Playwright-backed BDD framework — the retry boundary shifts. Cucumber's BeforeAll/AfterAll hooks don't receive Playwright's testInfo at all. You need to thread retry context manually or accept that your Gherkin Before hooks are effectively retry-blind.

# features/checkout.feature
@retry-sensitive
Scenario: Complete checkout with payment retry
  Given a cart with 3 items
  When the user submits payment
  Then the order confirmation page loads
// support/hooks.ts (Cucumber-JS 9 + Playwright)
import { Before, After, ITestCaseHookParameter } from '@cucumber/cucumber';
import { PlaywrightWorld } from './world';

Before({ tags: '@retry-sensitive' }, async function (
  this: PlaywrightWorld,
  { pickle }: ITestCaseHookParameter
) {
  // Cucumber doesn't expose retry count natively — track it yourself
  const attemptKey = `attempt:${pickle.id}`;
  const attempt = (global.__retryMap?.[attemptKey] ?? 0);
  if (attempt > 0) {
    console.warn(`[retry] ${pickle.name} attempt ${attempt} — skipping seed`);
  } else {
    await this.seedCheckoutState();
  }
  global.__retryMap = { ...global.__retryMap, [attemptKey]: attempt + 1 };
});

This is inelegant, but it's honest. Cucumber-JS 9 doesn't propagate retry counts into hook parameters — that's a known gap. If you're building a scalable BDD framework from scratch, consider Playwright Test's native fixture model over Cucumber's hook system specifically because of this retry-visibility gap. The trade-off: you lose Gherkin's living-documentation value. That's a real cost, not a trivial one.

Two Mistakes Senior Engineers Make With Playwright Retries

Setting retries globally without tagging retry-unsafe tests. A global retries: 2 in playwright.config.ts applies to every test, including ones that mutate shared state — order creation, user registration, Stripe webhooks. These tests are not safe to retry without explicit cleanup, and most teams don't have that cleanup. The fix is to set retries: 0 globally and opt in per-project or per-tag for read-only or idempotent scenarios. Playwright supports test.describe.configure({ retries: 2 }) at the suite level, which gives you the granularity you need.

Treating trace: 'on-first-retry' as sufficient observability. Traces tell you what happened during a single test run. They don't tell you that a test passed on attempt 3 while failing on attempts 1 and 2 due to a race in your beforeEach fixture. You need aggregated retry metrics — not per-run artifacts — to see patterns. Pipe testInfo.retry and testInfo.status into a structured log sink (Grafana Loki, Datadog, even a plain JSONL file parsed in CI) and alert when passed_on_retry_rate > 5% for any test file. That threshold is the canary, not the trace viewer. Also see how parallel BDD runs compound this problem when scenarios share external state.

What Most Teams Get Wrong About Retry as a Stability Strategy

Retries are not a flakiness fix — they're a flakiness deferral. The dominant mental model is: "We retry because the environment is noisy." That's sometimes true in CI with shared Kubernetes runners and network blips. But the more common cause is application-level race conditions that retries accidentally resolve — a debounce that fires correctly on the second attempt, an animation that completes between retry 1 and retry 2. Retries don't surface these; they bury them until a performance regression makes the timing worse and the test starts failing consistently. At that point, the root cause is months old and the trace is gone.

Auto-waiting is not the same as retry safety. Playwright's locator auto-wait handles element readiness — it does not handle application state readiness. A page.click() will wait for the button to be visible and enabled, but it won't wait for the backend session to be fully hydrated after a login hook. Teams that migrate from Selenium to Playwright often remove explicit waits and assume auto-waiting covers the same ground. It doesn't. If your beforeEach logs in via API and sets a cookie, the page's JavaScript session state may not be synchronized when the first step executes. That's not a Playwright bug — it's a misunderstanding of what the tool guarantees. Add an explicit page.waitForFunction or a lightweight health-check request in your fixture before yielding to the test.

If you implement retry guards in your fixture layer, the next metric worth tracking is pass-on-first-attempt rate broken down by hook type — it's a more honest stability signal than overall pass rate. From there, correlate against deployment frequency: a drop in first-attempt pass rate that tracks with deploy cadence usually points to environment drift, not test code. That's a different fix entirely, and worth distinguishing early.

Note: This article is for informational purposes only and is not a substitute for professional advice. If you need guidance on specific situations described in this article, consider consulting a qualified professional.

Understanding how systems actually work is the first step toward navigating them effectively.

Browse all articles