Stop Sleeping in Your Cypress Tests: How Retry-ability Works
Fixed cy.wait() calls make Cypress suites slow and still flaky. Retry-ability, network aliases, and retrying assertions replace guesswork with deterministic waiting.
08 Sept 2025, 13:47 UTC

A settings page takes a beat to confirm a save. Your Cypress test clicks Save, checks for the "Settings saved" banner, and — on a slow CI runner — fails one run in ten. The usual fix is to drop a cy.wait(2000) before the check. Now every run pays two extra seconds, and the test still flakes whenever the backend takes 2.5 seconds.
The takeaway up front: Cypress commands and the assertions chained to them retry automatically until they pass or a timeout expires. Build tests around that behavior — wait on intercepted network calls and use retrying assertions — and most fixed sleeps in your suite can be deleted.
What retry-ability actually does
When you write cy.get('[data-cy="banner"]'), Cypress does not query the DOM once and give up. It re-queries until the element exists or the command's timeout runs out — 4000 ms by default via defaultCommandTimeout, though it is worth confirming the default for the Cypress release you run. The same loop covers chained assertions: .should('have.text', 'Saved') keeps re-checking until the text matches or time expires.
This is why Cypress tests rarely need explicit sleeps. Animations finishing, fetches resolving, and async rendering are all just "eventual consistency within one command," and the retry loop absorbs them. You can watch it happen: the command log shows an assertion ticking through retries before it passes.
A worked example: wait for the save request, then the banner
Suppose your app has a settings form that sends a PUT to /api/settings and renders a confirmation banner when the response lands. cy.intercept() (available in Cypress 6 and later; it replaced the older cy.route() API, so older snippets circulating online may not run) can spy on matching requests and give the route an alias — a named handle your test can wait on:
// cypress/e2e/settings.cy.js — selectors are placeholders; match your app
it('shows a confirmation banner after saving', () => {
cy.intercept('PUT', '/api/settings').as('saveSettings')
cy.visit('/settings')
cy.get('[data-cy="display-name"]').type('Ada')
cy.contains('button', 'Save').click()
// Proceed only once the request has actually completed
cy.wait('@saveSettings')
.its('response.statusCode').should('eq', 200)
// Retrying assertion: passes whenever the banner renders
cy.contains('[data-cy="save-banner"]', 'Settings saved')
.should('be.visible')
})
Run it from your project root with npx cypress open (interactive) or npx cypress run (headless, for CI) — no special permissions beyond a normal dev environment. What to expect: cy.wait('@saveSettings') pauses until a matching request completes, the chained check asserts the response status, and the final cy.contains(...).should('be.visible') retries until the banner shows. If the app never sends the request, cy.wait fails after its timeout — which is the failure mode you want, and it arrives faster than a fixed wait would.
Compare the two styles:
| Approach | What happens | Cost |
|---|---|---|
cy.wait(2000) before the check | Every run pays the full two seconds; still a guess if the backend is slower | Slow, and can stay flaky |
Alias wait + retrying .should() | Proceeds as soon as the request and banner are actually observed | Fast when healthy, deterministic when broken |
This is a pattern to reproduce, not a benchmarked claim: build a minimal page that delays the banner by a second or two, run both versions, and compare the command logs yourself.
The .then() trap
Retry-ability is a property of commands and chained assertions, not of arbitrary JavaScript. Code inside a .then() callback runs exactly once, when the previous command yields. If the DOM updates a moment later, your check has already failed:
// Runs once — no retries, so it flakes on slow renders
cy.get('[data-cy="status"]').then(($el) => {
expect($el.text()).to.eq('Saved')
})
// Retries until the text matches or the timeout expires
cy.get('[data-cy="status"]').should('have.text', 'Saved')
Moving assertions out of .then() and into .should() chains is one of the highest-value refactors in a flaky Cypress suite. If you need custom logic, the callback form .should(($el) => ...) also re-runs on retries, unlike .then().
One more limit worth knowing: retries only cover eventual consistency within a single command's timeout. If the banner never appears, the test fails after the full timeout — so overly generous timeouts mask real regressions and inflate CI duration. Keep defaultCommandTimeout conservative; a genuine failure should surface in seconds, not minutes.
Audit your suite this week
- Search your spec files for
cy.wait(followed by a number, plus any sleep helpers. - For each hit, ask what you are really waiting for: a network call → an intercepted alias with
cy.wait('@alias'); a UI change → a retrying.should(). - Verify the pattern in a scratch project: delay the banner artificially and watch the retries in the command log, then break the build so the banner never renders and confirm the failure mode and timing.
- Leave the command timeout near its default so real failures stay loud and fast.
Exact defaults and API names shift between Cypress releases, so check the retry-ability and cy.intercept documentation for the version your team runs before standardizing on the snippets above.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.