Playwright Tests Flaky? Fix Random Failures & Stale Sessions

Clay theater-mask mascot holding a red error sign for an article about flaky Playwright tests

The test fails, the rerun passes, and nobody changes a line of code. When your Playwright tests are flaky, keep the failed attempt before touching the timeout: it contains the evidence that the successful rerun erased.

A flaky Playwright test produces inconsistent outcomes on the same code revision because some relevant condition, such as readiness or shared data, changes between attempts. The repair is to identify that condition, control or await it, and repeat the test without retries.

Start with the trace, distinguish a click from its completed outcome, then check test data and authentication. For a task that depends on your everyday signed-in browser, ego(lite) provides a separate inspection path described below. Keep the automated regression itself tied to a controlled test account.

What does the failure tell you?

The useful question is which assumption first stopped being true. A timeout is the place the test gave up, which can be several steps after the cause.

Use the failing action and its call log to choose your next check. These are diagnostic hypotheses, not proof that every matching symptom has the same cause.

SignalEvidence to inspectLikely repair to investigate
Click waits or times outLocator matches, overlay, disabled state, animationScope the target and await the actual readiness condition.
Click succeeds, expected result is missingRequest, response, UI update, hydrationWait for the outcome and fix any application readiness bug.
Passes alone, fails with other testsAccount, record IDs, cleanup, worker countGive each test or worker the backend state it needs.
Redirects to login partway throughFinal URL, session age, auth responseRefresh valid test authentication and confirm the account.
Browser closes or crashesRunner logs, process exit, memory pressureInvestigate resources before increasing a locator timeout.

Record the commit, project/browser, test name, worker, retry index and test-data identifier alongside the error. If you add console and network logging, distinguish a transport failure from an HTTP error response. A server returning an error still returned a response. Redact headers, query secrets and personal data from retained artifacts.

What does Playwright auto-waiting actually guarantee?

Playwright auto-waiting checks whether an action can proceed. For a click, its actionability checks include a unique target that is visible, stable, enabled and able to receive events. They don't establish that the application's save operation has completed.

Compare the two waiting strategies before adding another sleep. The right-hand approach expresses a condition the application must satisfy.

Timing guessObservable condition
Sleep after clicking SaveWait for the relevant response and the saved-state UI.
Read isVisible() once and assert the booleanUse await expect(locator).toBeVisible() to retry the observation.
Wait for all network traffic to stopAssert the specific result that makes the next step valid.

Playwright's web-first assertions retry while the expected condition is unmet. An assertion around an already-read boolean can't repeat the original page observation. That distinction matters when the result appears just after the check.

What happened in a controlled waiting experiment?

Our October 19, 2026 investigation used the real Microsoft Playwright GitHub issue list: filter open issues by oldest creation date, collect ten records, open the first issue and verify its identity and state. The unmodified live workflow passed. We then injected visibility delays into the already-loaded results region in the test browser, leaving its data, links and server state unchanged.

Codex beside a headed Chrome window inspecting the Microsoft Playwright GitHub issue list
The read-only investigation used a headed browser against the real GitHub issue list. The injected waiting experiment and its final identity checks were recorded separately. View full-size image

The environment was Node 26.8.1, Playwright Test 1.64.1 and bundled Chromium 156.0.8078.4 on macOS, with a 1280 × 800 viewport and en-US locale. Each trial used a fresh context, one worker and zero test retries. Both variants received the same frozen delay sequence: 0, 100, 500, 1500, 0, 500, 100, 1500, 500, 0 milliseconds, with the first-running variant alternating between pairs.

The waiting check was the deliberate difference between the two versions.

// Timing-dependent baseline
await page.waitForTimeout(250);
expect(await region.isVisible()).toBe(true);

// Corrected version of the same visibility check
await expect(region).toBeVisible({ timeout: 10_000 });

The one-shot check passed only when the region returned before it ran. The awaited assertion continued checking for the required state. The recorded outcomes were:

Frozen wait policyPassedFailed
250 ms sleep, then one visibility read5 of 105 of 10
Awaited visibility assertion, 10 s limit10 of 100 of 10

The five baseline failures occurred with the 500 ms and 1500 ms injections. The harness still restored the region and checked the list and issue detail after every attempt, retaining the original assertion failure. These counts demonstrate controlled waiting behavior, not GitHub's natural failure rate or a guarantee about CI. They also don't test the separate save-and-persistence pattern below.

This adaptation pattern assumes your application has a named settings form and a save endpoint. Replace those contracts with your own. It illustrates event ordering, rather than reporting an executed test against a particular site.

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

test('saves the display name', async ({ page }) => {
  await page.goto('/settings');
  const form = page.getByRole('form', { name: 'Profile settings' });
  await form.getByLabel('Display name').fill('QA Example');

  // Subscribe before the click can trigger the response.
  const savedResponse = page.waitForResponse(response => {
    const path = new URL(response.url()).pathname;
    return path === '/api/profile'
      && response.request().method() === 'PATCH';
  });
  await form.getByRole('button', { name: 'Save', exact: true }).click();
  expect((await savedResponse).ok()).toBeTruthy();
  await expect(form.getByRole('status')).toHaveText('Saved');
  await page.reload();
  await expect(form.getByLabel('Display name')).toHaveValue('QA Example');
});

The response check exposes a server error, the status assertion waits for UI feedback, and the reload checks persistence. If the page enables a control before hydration attaches its event handler, fix that readiness contract in the app. Playwright's hydration guidance explains why a fast interaction can reveal this gap.

How do you remove selector and event races?

A stable locator identifies the intended control when the test needs it. Prefer a meaningful role and accessible name, then scope repeated labels to their dialog, form or record. A deliberate test ID is useful when no reliable user-facing identity exists.

Don't turn a strictness error into an arbitrary choice with first() or nth(). If there are two Save buttons, establish which form owns the action. Playwright locators resolve an element again when used, so keeping a locator across a rerender differs from keeping an old element handle. See the Playwright locator guide for the full targeting rules.

The other race is subscribing too late. Arm a response, popup or download wait before the action that emits it. Await each dependent operation.

A broad response predicate can match a background refresh. Narrow it to the method and endpoint, and include a non-sensitive record identity where the app allows it. A successful request for the wrong record doesn't validate your workflow. The official network guide shows the event-before-action pattern.

Why can isolated tests still collide?

Playwright Test's default browser contexts isolate cookies and browser storage between tests. They don't create separate copies of your database. Two contexts signed into the same account can still overwrite the same preferences or delete each other's records.

Use unique test data and cleanup scoped to that data. For tests that change server state, allocate separate accounts per worker when the account is the unit of isolation. A worker's account can still require fresh per-test records. Avoid a global cleanup that deletes data another worker just created.

Run the suspect test with one worker, then repeat it alongside its usual neighbors at the intended parallelism. A pass with one worker narrows the search to contention, ordering or resource load. It doesn't prove which one caused the failure.

The browser-context isolation model and per-worker authentication pattern cover different layers. Keep both layers explicit.

Is the test flaky, or is its login state stale?

An expired authentication file can make a valid locator look broken: the test is searching a login screen for a dashboard control. Inspect the URL and account identity before changing that locator.

Generate authenticated state in setup after the login flow has reached its final URL and a meaningful signed-in marker. Recreate it when it expires or is revoked. When a test specifically verifies login, start clean so persisted state can't bypass the behavior being tested.

Storage state isn't a complete browser profile. Playwright's authentication documentation describes what it saves and the extra handling needed for sessionStorage. Don't assume it carries extensions, every authentication mechanism or a server-side session that has already been invalidated.

Keep state files out of version control and published artifacts. Use a dedicated test identity rather than importing a personal account into CI.

Why does the same test fail randomly in CI?

CI can expose an assumption your laptop happened to satisfy. Check the browser/package pairing, installed OS dependencies, available CPU and memory, viewport, locale, timezone and the application's readiness before the suite starts.

Begin with conservative worker concurrency on a constrained runner, then increase it while watching duration and failure evidence. More workers can amplify both backend collisions and resource contention. Playwright's CI guide recommends a low worker count for stability and shows the browser dependency installation path.

Control third-party services when they aren't the subject of the test. Keep separate integration coverage for the real service, and label mocked coverage so a stubbed success can't be mistaken for a working integration. A full environment comparison belongs in the local-versus-production Playwright guide.

How should you use retries, traces and video?

A retry tells you the outcome changed. It doesn't repair the condition that changed it. Playwright classifies a test that fails initially and passes on retry as flaky, separately from a first-attempt pass.

For an active investigation, capture a trace on every attempt with retries disabled. Once the cause is understood, use a smaller ongoing artifact policy. This example keeps a trace and video for failed attempts, including the evidence of an initial failure that later passes.

// playwright.config.ts: example ongoing policy
import { defineConfig } from '@playwright/test';

export default defineConfig({
  retries: process.env.CI ? 1 : 0,
  use: {
    trace: 'retain-on-failure',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
});

Open the failed trace and inspect the action log, DOM snapshots and network around the first divergence. Video helps explain visible transitions, but it can't replace the request and assertion evidence. Upload the report and artifacts even when the CI job fails, with access and retention appropriate to the data. Start with the official Trace Viewer guide.

With an on-first-retry trace policy, the first failure itself might have no trace. That tradeoff is reasonable for storage, but easy to overlook during an investigation.

The retained trace from the 500 ms injected-delay baseline makes the failed assumption visible: after a 250 ms sleep, the results region was still hidden. The assertion read false when the test expected true. The region's later restoration doesn't turn that original check into a pass.

Playwright Trace Viewer showing the hidden GitHub results region and the failed baseline visibility assertion
Native Trace Viewer for pair 03, baseline, 500 ms injected visibility delay. The selected action and Errors panel belong to the retained failed attempt. View full-size image
Detail of the same Playwright error: expected true, received false
A detail crop of the same trace makes the one-shot assertion result readable. The failure concerns visibility under a controlled delay. View full-size image

How do you prove the repair holds?

A repair needs to survive the condition that exposed it. Repeat the narrow test first, then repeat the surrounding group with realistic concurrency. These commands are a suggested investigation budget, not a claim of statistical certainty.

npx playwright test tests/settings.spec.ts \
  --repeat-each=20 --retries=0 --workers=1 --trace=on

npx playwright test tests/settings.spec.ts tests/account.spec.ts \
  --repeat-each=20 --retries=0 --workers=4 --trace=retain-on-failure

Choose worker counts your runner can actually support. Include a negative check in an owned test environment: delay or fail the save response and confirm the test fails for the expected reason. An assertion that stays green when persistence breaks isn't useful regression coverage.

In the controlled GitHub investigation, the final identity check was tested separately by expecting issue number -1 while the opened record was issue #1086, [Feature] create context in fullscreen mode. That negative control failed at the intended assertion and was excluded from the twenty ordinary trials. The subsequent unmodified verification passed. This checked that a wrong record could still be rejected after fixing the wait.

GitHub issue 1086 titled create context in fullscreen mode with an Open state badge
The captured source record identifies issue #1086 and its Open state on October 19, 2026. Identity and state are separate checks from results-region visibility. View full-size image

Keep the following acceptance criteria together. Passing repetitions reduces uncertainty in the exercised configuration; it doesn't establish that the test can never fail.

CheckEvidence to retain
Original trigger coveredThe relevant delay, session state or parallel collision is exercised again.
Failure still detectableA deliberately broken outcome fails the unchanged final assertion.
Retry dependence removedRepeated runs pass with retries disabled in the recorded environment.
Monitoring has an ownerTrack initial failures / initial executions, retry recoveries and failure signatures per test and project.

Give any quarantined test a tracked issue, owner and review date. A skipped test isn't a reliability improvement. For longer-term suite ownership and refactors, use the Playwright test maintenance guide.

Where does a visible logged-in browser help?

Sometimes the immediate question is whether a task works in the account and browser environment you use every day. The ego(lite) quick start describes migrating Chromium browser login state, Profile data and extensions. Its Space documentation describes agent work in a separate visible task workspace with human help for authentication.

Our separate October 19 Airbnb inspection used an existing authenticated session in ego(lite). Codex operated a named Space to search London for two adults, December 8–10, 2026, apply the Entire home filter, inspect five listings and verify the first listing's details. The running browser and helper reported ego(lite) 0.6.0.12 with Chromium 154.0.8037.44. This was a local signed-in inspection, not a repeat of the GitHub experiment.

Codex controlling Airbnb in an ego(lite) Space with the blue task treatment and Agent is in control, Take over and Stop controls
Codex and the live Airbnb Space in one desktop capture. The agent has opened the filters; Any is still selected at this point. The Entire home selection was verified later in the run. View full-size image

The run recorded an authenticated session, then paused for operator review and resumed in the same Space after confirmation. A fresh observation checked the selected stay again. No authentication challenge occurred. The supplied image shows agent control; the review handoff is documented in the event log rather than a screenshot of the handoff itself.

The useful finding was record identity after a reload. All five listings were relocated by listing ID with unchanged card text, but their positions changed. Use the captured IDs and stay conditions to verify the result, rather than treating the second row as a stable target. The table records this one observation, not current availability or a price ranking.

Observed listingTwo-night total shownPosition before / after reload
Premium Studio 12 @ The Orange Door Little Venice¥68,181 JPY1 / 1
Mid Century Flat in London 1 Min From Station¥50,926 JPY2 / 3
Studio, West Kensington + Hammersmith¥52,609 JPY3 / 2
Stylish Garden Studio - Canada Water¥40,404 JPY4 / 5
Hidden gem in Kensington, Central London¥80,598 JPY5 / 15

The first listing's detail panel matched the card's ¥68,181 JPY total for the selected two-night stay. The source displayed Japanese yen despite the London destination. We kept that currency and the displayed total basis, without converting it to a nightly or independently calculated all-in price. No reservation was submitted.

Same-run Airbnb detail panel showing December 8 to 10, 2026, two guests and a total of 68,181 Japanese yen
Same-run detail: December 8–10, two guests and ¥68,181 JPY total. The Reserve button was not used. View full-size image

A Space separates task work, but it doesn't erase the consequences of acting in an authenticated account. Keep the task read-only, and require explicit authorization before sending, deleting, changing permissions or submitting a transaction. Session reuse can't revive revoked access.

The official privacy policy distinguishes locally stored browser activity from data processed by external AI tools and other services. Signed-in page content can be sensitive even when passwords and session tokens aren't exported through the browser interface. Review the connected agent's data handling and share only the pages needed for the task.

For CI regression gates, keep Playwright's controlled fixtures, assertions and trace evidence. For local signed-in investigation, the value to test is whether visible review and a clear takeover point make the failure easier to diagnose.

What else should you check?

Does a higher timeout fix a flaky test?

A higher timeout helps when a legitimate operation needs a justified larger budget. It won't fix a wrong target, expired login or shared-data collision. Set the budget on the relevant operation after inspecting evidence.

Should you force the click?

A forced click bypasses some actionability protections. Use it only when the test intentionally needs that behavior and separately verifies the outcome. It isn't a general remedy for overlays or disabled controls.

Is networkidle a reliable application-ready signal?

Network silence doesn't prove that the intended record or UI state is ready. Prefer a specific UI assertion or relevant response. Background activity can also keep a page busy after the state you need is already available.

Can an AI agent repair the test automatically?

An agent can propose a repair, but review the diff and preserve the assertion that detects the product defect. Reject a change that removes verification, skips the test or silently substitutes a different record.

The next green build should explain why the original failure is gone. If your next task needs an everyday signed-in browser, review ego(lite) and Playwright's different roles, then follow the ego(lite) setup path for a bounded local trial.

Keep reading