1) Cypress’ waiting model: auto-retry of commands and assertions
Cypress is designed to synchronize with your app by automatically retrying many commands and nearly all assertions until they pass or a timeout is reached. This is different from “sleeping” for a fixed amount of time. Instead of guessing how long the UI will take, you express what “ready” looks like (a DOM state or a network completion), and Cypress keeps checking until it becomes true.
Key idea: most DOM querying commands (like cy.get()) and assertions (like .should()) are retried. Cypress repeatedly queries the DOM and re-runs the assertion until it passes or times out. This makes tests more stable because they adapt to variable performance.
- Auto-retry applies to:
cy.get(),cy.contains(), chained assertions like.should('be.visible'),.should('contain.text', ...), and many other “query + assert” patterns. - Auto-retry does not apply to: arbitrary JavaScript you run in
.then()(it runs once), and fixed delays likecy.wait(5000)(it always waits the full time). - Practical implication: prefer “query + should” over “query + then + manual checks”. Put conditions into
.should()so Cypress can retry.
Before/after: letting Cypress retry instead of sleeping
Before (arbitrary delay):
// Flaky and slow: might be too short on CI, always wastes time when fast locally cy.get('[data-cy=save]').click() cy.wait(5000) cy.contains('Saved').should('be.visible')After (deterministic UI condition):
// Cypress retries until the success message appears (or times out) cy.get('[data-cy=save]').click() cy.contains('Saved').should('be.visible')In the “after” version, Cypress will keep checking for the “Saved” message. If it appears in 200ms, the test continues immediately; if it takes 4 seconds, it still passes without changing the test.
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
2) Waiting on UI conditions (visibility, text, enabled state)
UI-driven waits are usually the simplest and most robust: wait for the element state that indicates the app is ready. Common readiness signals include: a spinner disappearing, a button becoming enabled, a list rendering items, or a toast message appearing.
Common UI waits you should prefer
- Visibility: wait for an element to be visible before interacting.
- Text content: wait for a label, heading, or status text to update.
- Enabled/disabled: wait for a button to become enabled before clicking.
- Existence/non-existence: wait for a loading indicator to disappear.
Step-by-step: replace cy.wait(5000) with UI readiness checks
Scenario: clicking “Refresh” triggers loading, then a table updates.
1) Trigger the action.
cy.get('[data-cy=refresh]').click()2) Wait for a loading indicator to appear (optional but can make intent clearer).
cy.get('[data-cy=loading]').should('be.visible')3) Wait for the loading indicator to go away.
cy.get('[data-cy=loading]').should('not.exist')4) Assert the updated UI state (text, row count, etc.).
cy.get('[data-cy=results-table]').should('be.visible') cy.get('[data-cy=result-row]').should('have.length.greaterThan', 0)Examples of stable UI waits
Wait for text to update:
cy.get('[data-cy=status]').should('contain.text', 'Complete')Wait for a button to become enabled:
cy.get('[data-cy=submit]').should('be.enabled').click()Wait for a modal to open before interacting:
cy.get('[data-cy=open-settings]').click() cy.get('[data-cy=settings-modal]').should('be.visible') cy.get('[data-cy=settings-modal] [data-cy=save]').should('be.enabled').click()Avoid “manual polling” in .then(): if you read a value in .then() and assert in plain JS, Cypress cannot retry. Prefer .should() so Cypress can keep checking.
3) Waiting on network calls using intercept aliases
When UI state depends on API responses, the most deterministic synchronization is to wait for the specific request(s) that must complete. Cypress can wait on an aliased request and then assert on the response and/or the UI.
This chapter focuses on waiting with aliases (not on the basics of stubbing). Even when you are not stubbing and are hitting real endpoints, waiting on an alias can remove timing guesswork.
Before/after: replace cy.wait(5000) with cy.wait('@alias')
Before (arbitrary delay after search):
cy.get('[data-cy=search-input]').type('cypress') cy.get('[data-cy=search-submit]').click() cy.wait(5000) cy.get('[data-cy=search-results]').should('be.visible')After (wait for the search request):
cy.intercept('GET', '/api/search*').as('search') cy.get('[data-cy=search-input]').type('cypress') cy.get('[data-cy=search-submit]').click() cy.wait('@search') cy.get('[data-cy=search-results]').should('be.visible')Now the test proceeds as soon as the request completes, and it fails with a meaningful timeout if the request never happens.
Step-by-step: wait for a request and assert on it
1) Create an intercept and alias it.
cy.intercept('POST', '/api/orders').as('createOrder')2) Perform the UI action that triggers the request.
cy.get('[data-cy=place-order]').click()3) Wait for the request and assert on the response.
cy.wait('@createOrder').its('response.statusCode').should('eq', 201)4) Assert the UI reflects success.
cy.contains('Order confirmed').should('be.visible')Handling multiple requests deterministically
If an action triggers more than one request, wait for the ones that matter to the UI you’re asserting. For example, a “Save” might call PUT /profile and then GET /profile to refresh.
cy.intercept('PUT', '/api/profile').as('saveProfile') cy.intercept('GET', '/api/profile').as('fetchProfile') cy.get('[data-cy=save]').click() cy.wait('@saveProfile').its('response.statusCode').should('eq', 200) cy.wait('@fetchProfile') cy.get('[data-cy=profile-name]').should('contain.text', 'Updated')Tip: if the UI updates only after the second request, waiting for the first one alone may still be flaky. Align your waits with what actually gates the UI change.
4) When to adjust timeouts and where
Auto-retry is only helpful if the timeout is appropriate. Cypress has default timeouts for commands and assertions; sometimes you need to adjust them for known slow operations (large pages, heavy CI load, slow third-party auth flows). The goal is to increase timeouts in targeted places rather than globally masking performance problems.
Prefer targeted timeouts on the specific command
Use the { timeout: ... } option on the command that needs more time. This keeps the rest of the test fast and makes the slow point explicit.
// Wait longer for a heavy table to render cy.get('[data-cy=report-table]', { timeout: 20000 }).should('be.visible')You can also apply timeouts to cy.contains() when waiting for text that appears after async work:
cy.contains('Report ready', { timeout: 20000 }).should('be.visible')Adjusting timeouts for network waits
cy.wait('@alias') also supports a timeout option. Use it when you know a particular request can legitimately take longer in some environments.
cy.wait('@exportReport', { timeout: 30000 })When (and how) to adjust globally
Global timeout changes can be useful for a consistently slow environment, but they can hide regressions and make failures take longer. If you do adjust globally, do it intentionally and document why.
Examples of global settings you might encounter or configure include default command timeouts and page load timeouts. Prefer to keep global values close to defaults and override locally where needed.
Avoid increasing timeouts to compensate for missing synchronization
If a test is flaky because it clicks before a button is enabled, increasing timeouts on unrelated commands won’t fix the root cause. Replace the timing guess with a readiness condition:
// Better than “just increase timeout”: wait for enabled state cy.get('[data-cy=submit]').should('be.enabled').click()5) Diagnosing flaky timing issues with screenshots, videos, and command logs
When a test fails intermittently, your goal is to identify what the test did versus what the UI was doing at that moment. Cypress provides artifacts and logs that help you pinpoint whether you’re missing a wait on UI state, a network call, or a transition.
Use the Command Log to see what Cypress retried and why it failed
- Click a failed command in the Command Log to inspect what Cypress was looking for (e.g., element not found, not visible, assertion mismatch).
- Look for patterns like “element detached from DOM” (often indicates re-rendering) or “timed out retrying” (your condition never became true).
- If you used
.then()for checks, consider moving the check into.should()so Cypress can retry.
Screenshots: confirm the UI state at failure time
Cypress can capture screenshots on failure. Use them to answer:
- Was the element present but covered by a loader?
- Did the page navigate somewhere unexpected?
- Was there an error banner/toast that explains the missing state?
If the screenshot shows a spinner still visible, the fix is usually to wait for the spinner to disappear (or wait for the request that drives it), not to add a longer sleep.
Videos: understand transitions and race conditions
Videos are especially helpful for:
- Animations/transitions where elements become visible slightly later
- UI re-renders that detach and recreate elements
- Unexpected double navigations or redirects
When you see a click happen before the UI is ready, add a deterministic wait (enabled/visible/not.exist) immediately before the interaction.
Network panel (within Cypress) and alias waits
If you use intercept aliases, you can often tell whether the request fired at all. A common flaky pattern is “UI assertion runs before the request completes.” Fix by waiting on the alias and then asserting UI state.
cy.intercept('GET', '/api/dashboard').as('dashboard') cy.visit('/dashboard') cy.wait('@dashboard') cy.get('[data-cy=dashboard-ready]').should('be.visible')Practical checklist for removing timing flake
- Replace
cy.wait(5000)with a UI condition (should('be.visible'),should('not.exist'),should('be.enabled'),should('contain.text', ...)). - If the UI depends on data, wait on the relevant network alias (
cy.wait('@alias')) and optionally assert the response status. - Use targeted timeouts on the slow command rather than increasing everything globally.
- Use screenshots/videos to identify the missing readiness signal (loader, navigation, re-render).
- Prefer assertions in
.should()over checks inside.then()to keep auto-retry working.