The main source of E2E instability is wrong waiting. The interface updates asynchronously: data arrives over the network, elements appear after animation, buttons become enabled not at once. A naive test tries to guess this moment with a two-second pause, and on a fast machine it is excessive, while on a loaded CI it is too short. Playwright solves this differently: it waits not for time but for the condition under which an action even makes sense.
The mechanism is called auto-waiting, and it is built into every action. Before a click Playwright automatically checks a set of actionability conditions: the element is single, visible, stable (stopped moving), able to receive events and enabled. Until these conditions are met, the action does not begin - and it is retried up to the timeout. This removes a whole class of races in which the test clicked an element not yet ready for interaction.
Locators and web-first assertions carry the same logic: they do not read state once but retry the check until the needed result appears. This is exactly why you do not need to manually wait "until it loads": the right locator and the right assertion will wait on their own. Waiting stops being a separate step of the scenario and becomes a property of every action and assertion.
The contrast is vivid on a pair of examples. The naive approach puts waitForTimeout(2000) and then reads textContent once - the test either drags for no reason or fails without waiting. The right approach writes expect(status).toHaveText('Done') - the check retries itself until the text appears and finishes exactly when the condition is met, not a second earlier or later. The first way guesses time, the second observes state.
It helps to see both approaches side by side once so the difference sinks into habit. Below is a fixed pause versus waiting for an observable state; you return to this comparison each time your hand reaches to insert waitForTimeout to "stabilize" a flaky scenario. A pause does not stabilize - it hides the cause and adds randomness.
networkidle deserves a separate warning. The temptation to wait for "silence on the network" as a sign of page readiness is understandable but deceptive: analytics, websockets and periodic polling keep the network constantly active, and such waiting either never comes or comes in the wrong place. networkidle is not a universal readiness signal - it is an outdated crutch giving false stability on some pages and eternal waiting on others.
Instead of network silence you wait for a concrete observable event. The needed interface element, a changed URL, a specific network response via waitForResponse, a domain state such as a success message that appeared. This ties the wait to what readiness really means in user terms, not to an indirect and capricious sign of network activity that has nothing to do with the business state.
The typical waiting failures are the main cause of flakiness in E2E. A fixed waitForTimeout pause that wastes time on a fast machine and does not wait long enough on a slow one. A one-off read of state right after an action, landing at the moment before the update. And networkidle as a universal readiness sign on a page with constant network activity. Wait for a condition - a visible UI, a URL, a response, a business event - not for a number of milliseconds.
// A fixed pause - guessing time
await page.waitForTimeout(2000)
expect(await status.textContent()).toBe('Done')
// An observable state - the check retries until the text appears
await expect(status).toHaveText('Done')