A Playwright test clicks a button that is visibly on screen, and roughly one run in twenty nothing happens. The button is server-rendered but the app hydrates after load. Why does the click get lost, and how do you fix the flake properly?
Playwright actionability checks pass on server-rendered markup because the element is attached, visible, stable and enabled - none of which imply React has attached its listener. The click lands on inert HTML, so this is a race with hydration, not a timing problem. Wait on a readiness signal the app exposes deliberately.
What the interviewer is scoring
- Whether the candidate knows what Playwright's actionability checks do and do not cover
- That the gap is identified as a listener not yet attached, rather than the element not being ready
- Does the answer reject a fixed sleep and explain why it fails under load rather than merely being slow
- Whether the proposed wait keys on an application-owned readiness signal instead of a DOM coincidence
- That the candidate considers disabling the control until hydrated, making the app honest rather than the test clever
- Whether they recognise this as a real user-facing bug and not only a test problem
- Does the answer mention that one in twenty is a rate to measure, so the fix can be shown to work
Answer
Short answer
Playwright waits for the element to be attached, visible, stable, enabled and able to receive events. Server-rendered HTML satisfies every one of those before any JavaScript has run. The button is genuinely there and genuinely clickable — it simply has no onClick listener yet, so the event dispatches into markup that does nothing with it. Nothing throws, nothing retries, and the test proceeds to fail on the assertion after it.
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:
// App root, after hydration completes
useEffect(() => {
document.documentElement.dataset.hydrated = 'true';
}, []);
// Test
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.
Likely follow-ups
- Why is waitForTimeout the wrong fix even if you set it to five seconds?
- What is wrong with waiting for networkidle here?
- How would you expose a hydration-complete signal without shipping test-only code to production?
- A real user clicks that fast too. What does the product do about it?
- How do you prove the flake is gone rather than rarer?
Related questions
- What does auto-waiting do in Playwright and Cypress, how do the two differ architecturally, and what can neither of them test?mediumAlso on playwright and e2e-testing6 min
- How do you get a browser test into the state it needs to start, without clicking through the UI to get there?mediumAlso on playwright6 min
- A modal passed design and QA review, but keyboard users report they can tab out of it into the page behind, and once they do they cannot get back or close it. Diagnose it and tell me what a correct dialog does.hardSame kind of round: scenario4 min
- How do you tell whether a value escapes to the heap, and how would you find the allocations that are costing you?hardSame kind of round: concept6 min