Why the auto-waiting does not help
Playwright's actionability checks are about the element, and they are good ones. What none of them can observe is whether the framework has attached behaviour to it. React hydration walks the server-rendered tree, attaches listeners, and only then is the button functional. Between first paint and the end of hydration there is a window — usually tens of milliseconds, occasionally much longer — where the page looks complete and interactive and is not.
The one-in-twenty rate is the signature. Locally, hydration completes before the test gets there almost every time. On a loaded CI machine with less CPU, the window widens and the test occasionally arrives inside it. Anything that changes machine load changes the failure rate, which is why the flake seems to move around and why it usually gets worse as the suite grows.
This is a genuine race condition, not a slow test. That distinction drives everything about the fix.
The fixes that do not work
waitForTimeout(2000) makes the failure rarer without removing it. There is no duration that is correct: too short and it still fails under load, too long and you have added two seconds to every test in a suite that runs thousands of times. It also encodes a guess about the slowest machine you have seen so far, which the next CI change invalidates.
waitForLoadState('networkidle') is tempting and unreliable. It waits for network quiet, which correlates with hydration only loosely — an app with polling, analytics beacons, or an open WebSocket may never reach networkidle, and an app that hydrates from an inline payload reaches it long before hydration finishes. Playwright's own documentation discourages it for this reason.
Retrying the click hides the bug and, worse, can double-submit once hydration lands between the two attempts.
Wait for a signal the application actually owns
The reliable fix is for the app to say when it is ready, and for the test to wait on that statement. The cleanest version is a data attribute set after hydration:
useEffect(() => {
document.documentElement.dataset.hydrated = 'true';
}, []);
await page.waitForSelector('html[data-hydrated="true"]');
await page.getByRole('button', { name: 'Add to basket' }).click();
This is not test-only code leaking into production in any meaningful sense — it is one attribute, it is useful for real user monitoring too, and it makes an otherwise invisible lifecycle boundary observable. That is a fair trade and worth defending if challenged.
A weaker but zero-code alternative is to wait for something that only exists post-hydration — a client-rendered element, or a control whose disabled attribute is removed by the framework. It works, but it couples the test to an implementation detail that a refactor will silently break, which is how you get a flake back six months later.
The better answer: make the app honest
The strongest response reframes the problem. A real user can also click during that window, and they get exactly the same nothing. The test found a genuine defect and the team decided it was a test problem.
If the control is not functional, it should not present as functional. Render it disabled until hydrated, or have the server-rendered version be a real <form> that works without JavaScript so the click does something either way. Progressive enhancement solves this at the root: the button works before hydration, so the race stops mattering for users and for the test simultaneously.
That framing is usually what an interviewer is listening for, because it distinguishes someone who fixes tests from someone who reads tests as evidence about the product.
Prove the fix rather than assume it
A one-in-twenty flake cannot be confirmed fixed by running the test once. Playwright can repeat it:
npx playwright test button.spec.ts --repeat-each=100 --workers=4
Run it under load — workers in parallel, ideally on the same class of machine CI uses — and record the failure rate before and after. Going from five failures in a hundred to zero in several hundred is evidence. A single green run is not, and treating it as such is how flaky tests get closed twice.
The habit worth proposing alongside the fix is tracking flake rate per test over time, so that a test degrading from 0% to 2% is visible as a trend rather than discovered when someone finally gets annoyed enough to investigate.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).