Waiting causes more flaky tests than everything else combined. If you fix how your suite waits, you fix most of your instability.
The confusing part is that all three big frameworks solve it differently, and advice written for one is often wrong for another. This page puts them side by side.
The rule that applies to all three
Before any syntax, here is the principle. It does not change between tools:
Wait for a condition, never for an amount of time.
A fixed pause is a guess about how long something takes, and you lose that guess in both directions. Too short, and the test fails on a slow build server for no real reason. Too long, and every run pays for it forever. One wasted second across eighty tests is more than a minute added to every single run.
Everything below is each tool's way of expressing "wait for the condition".
Playwright: waiting is mostly automatic
Playwright does the most for you, and the correct code is usually the shortest.
Actions wait by themselves. Before clicking, Playwright checks that the element exists, is visible, is stable, and is enabled. It retries these checks until the timeout:
await page.getByRole('button', { name: 'Save' }).click();
You do not write "wait for the button, then click it". The click is the wait.
Assertions retry. This is the one to internalise:
await expect(page.getByTestId('toast')).toContainText('Contact created');
That keeps checking until the toast appears or time runs out.
The trap. The retrying only happens when the locator is inside expect. Pull the value into a variable first and you lose it:
// Unstable: reads once, no retry
const text = await page.getByTestId('cart-count').textContent();
expect(text).toBe('1');
// Stable: retries until the value updates
await expect(page.getByTestId('cart-count')).toHaveText('1');
These two look almost identical. Only one of them waits. If you review Playwright code, this is the first thing to look for.
When you still wait manually: waiting for a network request.
const savePromise = page.waitForResponse('**/api/contacts');
await page.getByTestId('save-contact').click();
await savePromise;
Note the order. Start waiting before the click, or the response may arrive before you begin waiting and the test hangs until it times out.
Cypress: waiting comes from retry-ability
Cypress retries commands too, but with one rule that explains most confusing failures.
Cypress retries the last command before your assertion. Not the whole chain:
cy.get('[data-testid="contact-row"]').should('have.length', 1);
Cypress runs cy.get, checks the count, and if it is wrong it runs cy.get again. The search and the check repeat together.
The trap: actions are never retried. This looks reasonable and fails randomly:
cy.get('[data-testid="save"]').click().should('be.disabled'); // risky
Cypress will not click again to satisfy the assertion. If the button needs 50 milliseconds to become disabled, the check has already failed. Split it:
cy.get('[data-testid="save"]').click();
cy.get('[data-testid="save"]').should('be.disabled'); // this retries
The other trap: two different things are called cy.wait.
cy.wait(1000)waits a fixed time. This is the one to avoid.cy.wait('@saveContact')waits for a named network request. This is good practice.
cy.intercept('POST', '/api/contacts').as('save');
cy.get('[data-testid="save-contact"]').click();
cy.wait('@save');
When someone says "never use cy.wait", they mean the number version. Waiting on a named route waits for a real event.
Selenium: you do the waiting yourself
Selenium does the least for you, which is why Selenium suites are the most likely to be full of sleeps. It is also why Selenium questions come up so often in interviews: nothing is hidden.
The wrong way, which you will see everywhere:
await driver.sleep(1000); // a guess
The right way is an explicit wait:
const toast = await driver.wait(
until.elementLocated(By.css('[data-testid="toast"]')),
5000
);
driver.wait takes a condition and a maximum time. It polls every few hundred milliseconds and returns the moment the condition holds. Useful conditions:
until.elementLocated(By.css('...')) // exists in the page
until.elementIsVisible(element) // the user can see it
until.elementIsEnabled(element) // it can be clicked
until.urlContains('/dashboard') // navigation finished
Custom conditions are the feature most people never learn, and they solve most remaining problems. Pass your own function; return null to keep waiting, a value to finish:
const rows = await driver.wait(async () => {
const found = await driver.findElements(By.css('[data-testid="contact-row"]'));
return found.length === 1 ? found : null;
}, 5000);
That waits until a filtered table has exactly one row.
The Selenium trap that costs teams hours
Selenium has a second kind of wait, and mixing the two is a genuine problem.
An implicit wait is a global setting. Once set, every element lookup retries for that long:
await driver.manage().setTimeouts({ implicit: 10000 });
It looks convenient. The problem appears when a project has both kinds. Your explicit wait polls for the element every half second, but each of those checks now goes through the implicit wait, which retries for 10 seconds before answering. The waits multiply instead of adding. A test you expected to fail after 5 seconds takes a minute, and timeouts happen in places that make no sense.
I joined a team whose suite took 40 minutes and nobody knew why. An implicit wait of 30 seconds had been set in a base class three years earlier. Removing that one line brought the suite to 11 minutes and fixed most of the flakiness at the same time.
The rule: choose explicit waits, and leave the implicit wait at zero.
Side by side
| Situation | Playwright | Cypress | Selenium |
|---|---|---|---|
| Wait before clicking | automatic | automatic | driver.wait(until.elementIsEnabled(...)) |
| Wait for text to appear | await expect(l).toHaveText('x') |
.should('have.text','x') |
driver.wait(until.elementTextIs(el,'x')) |
| Wait for element to vanish | await expect(l).toBeHidden() |
.should('not.exist') |
driver.wait(until.stalenessOf(el)) |
| Wait for a request | page.waitForResponse(url) |
cy.wait('@alias') |
not built in |
| Wait for a custom condition | expect.poll(...) |
.should(cb) |
driver.wait(async () => ...) |
| Fixed sleep (avoid) | page.waitForTimeout(n) |
cy.wait(n) |
driver.sleep(n) |
How to find the waiting problems in your suite
Three checks you can run this afternoon.
1. Search for the sleep. Look for waitForTimeout, cy.wait( followed by a digit, and driver.sleep. Every result is a small bug. Replace each with a condition.
2. Search for a global implicit wait if you use Selenium. One line in a base class can be slowing your whole suite.
3. Run your suite with the network throttled. In your browser dev tools, set the network to a slow speed and run again. Tests that only pass on a fast connection will fail immediately, which tells you exactly where the hidden timing assumptions are. The build server is usually slower than your laptop, so this simulates it well.
The thing that does not change
Whichever tool you use, a test that waits correctly has the same shape: do the thing, then assert the result you expect, and let the framework handle the time in between.
If your test says "click, sleep, check", it is fragile in every framework. If it says "click, check", it is stable in every framework.