Start at the failed attempt
A flaky test passed in the end, so its last attempt shows nothing wrong. What you need is in the attempt before it: the error, the line it failed on, and what the page looked like.
Record that for failing attempts in playwright.config.ts:
use: {
screenshot: "only-on-failure",
trace: "retain-on-failure",
},Inside defineConfig. trace: "on-first-retry" records less, but belongs to the retry rather than to the failure.
With a trace, the Trace Viewer shows each action with the page before and after it, the network requests and the console.
In Squally each attempt of a test in a run has its own tab - Attempt 1, Retry 1 and so on. An attempt shows its error with Playwright's code frame and, where they were recorded, its steps, screenshot, browser console and test output, and a link that opens its trace in the Trace Viewer.
A run's Errors tab bundles its errors by category - Assertion, Timeout, Element not found, Network, Other - and the Error explorer follows one error across runs and tests.

Fixed waits → web-first assertions
page.waitForTimeout() pauses for a fixed time: long enough on a fast machine, too short on a loaded CI runner. Playwright's API reference calls tests that wait for time inherently flaky (Page).
// Flaky: hopes two seconds are enough
await page.getByRole("button", { name: "Add to cart" }).click();
await page.waitForTimeout(2000);
expect(await page.getByTestId("cart-count").textContent()).toBe("1");
// Waits until the text is there, up to the expect timeout
await page.getByRole("button", { name: "Add to cart" }).click();
await expect(page.getByTestId("cart-count")).toHaveText("1");
Web-first assertions such as toHaveText, toBeVisible and toHaveURL retry until the condition holds or the timeout runs out (Assertions). Actions such as click already wait for the element to be visible, stable and enabled (Auto-waiting).
When the page waits for the server, wait for that response: page.waitForResponse() waits for the request you name, not for a guess at how long it takes.
A missing await
Without await, a Playwright call returns a promise and the test moves on. The assertion may run before the click it depends on, or after the test has ended.
// Flaky: the assertion is not awaited
expect(page.getByText("Saved")).toBeVisible();
// Correct
await expect(page.getByText("Saved")).toBeVisible();
A linter catches this before it flakes: Playwright's best practices recommend the @typescript-eslint/no-floating-promises rule (Best practices).
Fragile selectors
A selector that depends on position, generated class names or markup breaks when the page renders in a different order, or a moment later.
// Fragile: the second button in a div with a generated class
await page.locator("div.css-1x2y3 > button:nth-child(2)").click();
// What the user sees
await page.getByRole("button", { name: "Checkout" }).click();
Role, label and text locators describe the page the way a user does (Locators). Locators are strict: an action on a locator that matches more than one element throws instead of picking one, so an ambiguous selector fails every time rather than now and then.
Parallel workers colliding
Playwright runs test files in parallel worker processes (Parallelism). Two workers that sign in to the same account, write the same file or change the same record get in each other's way - depending on timing, which is what makes it flaky.
testInfo.parallelIndex numbers the workers from 0; use it to give each worker its own account or data set. Where tests must share something, test.describe.configure({ mode: "serial" }) runs a group in order, one test after the other, at the cost of speed.
Slow third parties → mock
Payment providers, maps and feature-flag services answer slowly at times, or limit how often a CI runner may call them. A test that depends on them flakes with them.
await page.route("**/maps.example.com/**", (route) =>
route.fulfill({ json: { eta: "2 days" } }),
);
Mock APIs covers page.route and replaying recorded responses from a HAR file. Playwright's best practices advise against testing third-party dependencies you do not control.
Confirm the fix over the next runs
One green run proves little for a test that failed one run in ten. Before merging, repeat it: npx playwright test tests/cart.spec.ts --repeat-each=20 --retries=0. Without retries a failure shows as one; twenty green runs in a row are evidence, not proof.
After merging, watch the next CI runs: the test should stop needing retries.
In Squally, a test's page shows its Flaky rate and Failure rate for the period, its runs per day and every run with its result, filterable by Passed, Flaky and Failed; the Test explorer's Last 10 runs show the latest results at a glance.
A run's Compare tab sorts its tests against another run into groups such as Newly failing, Still failing, Newly flaky, Still flaky and Fixed.
Which test to fix first: What flaky tests cost. How to find them: How to detect flaky tests in Playwright. In the docs: Common causes and Debugging basics.
Questions
Should I raise the number of retries instead?
As a stopgap at most. More retries keep the run green and spend more CI time on every flaky failure; the cause stays in the test.
Is waitForTimeout ever fine?
For a quick look while debugging, yes. In a committed test, wait for something the page does - an element, a URL, a response - not for a number of milliseconds.
How do I reproduce a flaky test locally?
Run it many times with the settings CI uses: --repeat-each=50 --retries=0, the same browser, headless if CI runs headless, and several workers if CI uses them. Timing problems often need the load of parallel workers to show.
Can a coding agent fix a flaky test?
It can help, given the failure. On an attempt's page, Squally's Fix panel builds a prompt with the error, the code frame and the page snapshot to paste into Claude Code, Cursor or Codex with your repository open; Squally sends nothing itself. With the Squally MCP server set up, the agent can read runs, tests and errors on its own. The MCP server only reads, cannot see screenshots or traces, and needs an organization read key, which comes with the Standard and Pro plans.
Keep reading
Flaky Playwright tests: the overview
How to detect flaky tests in Playwright
Retries, the final attempt, flaky versus failed, and which runs to leave out when you count.
Read the guide →Playwright retries without hiding flaky tests
How to set retries per config, project and describe block, and how to keep them from hiding problems.
Read the guide →What flaky tests cost: CI time lost to retries
Where retries and failed runs spend CI time, two honest ways to count it, and a worked example in minutes.
Read the guide →EU-hosted Playwright reporting and GDPR
What test results contain, where they are stored, the DPA, masking, retention and how to send less.
Read the guide →