Cypress gives frontend engineers a superpower: the ability to write E2E tests that watch our app behave just like a real user would. But with great power comes… well, a lot of subtle flakiness if you’re not careful.
cy.wait Illusion: What's Really HappeningThe scenario is simple: you have a component that loads data, and after a user action, it loads new data using the same API endpoint. To ensure the new data has arrived, you intercept the request and then use cy.wait('@requestAlias') multiple times.
// A common, flawed approach: cy.intercept('GET', '/api/items/*', { fixture: 'item-1' }).as('getItems'); cy.visit('/items'); // 1. Wait for the initial load cy.wait('@getItems'); // ... User performs an action that triggers the SAME request ... // 2. Wait for the second load cy.wait('@getItems'); // <-- THIS IS THE PROBLEM
Cypress's cy.intercept logic is designed to capture a single match for an alias. When you call cy.wait('@getItems') for the first time, it finds the initial request, waits for its resolution, and then the alias is fulfilled.
When you call cy.wait('@getItems') a second time, Cypress does not reset the listener. Instead, it checks if a request has already been resolved with that alias. Because the first request has resolved, the second cy.wait command resolves immediately, without waiting for the new network call to finish. Your test is now racing against the network, not waiting for it.
(Works, explicit, but verbose)
cy.intercept('GET', '/api/items').as('getItems_1') cy.get('[data-testid=refresh]').click() cy.wait('@getItems_1') cy.intercept('GET', '/api/items').as('getItems_2') cy.get('[data-testid=load-more]').click() cy.wait('@getItems_2')
Clear, deterministic, but repetitive.
times: 1 to force Cypress to “consume” intercepts(Cleaner: Cypress forgets the intercept after one match)
This is the missing tool many engineers don’t realize exists.
cy.intercept({ method: 'GET', pathname: '/api/items', times: 1 }).as('getItems') // trigger request 1 cy.get('[data-testid=refresh]').click() cy.wait('@getItems') cy.intercept({ method: 'GET', pathname: '/api/items', times: 1 }).as('getItems') // trigger request 2 cy.get('[data-testid=load-more]').click() cy.wait('@getItems')
Why this works:
times: 1 means Cypress removes the intercept after a single matching requestcy.wait('@getItems') now truly waits for the next occurrenceThis technique gives you explicit, occurrence-specific intercepts without alias clutter. For tests that must assert network behavior (payloads, headers, error flows), it’s a clean and robust pattern.
(The best fix. UI > network.)
Here’s the golden rule:
That means the most stable tests assert what the user sees:
Example with user-visible cues:
cy.get('[data-testid=refresh]').click() cy.get('[data-testid=spinner]').should('exist') cy.get('[data-testid=spinner]').should('not.exist') cy.get('[data-testid=item-list]') .children() .should('have.length.at.least', 1)
No reliance on internal network timing. No alias lifecycle. Zero flake.
Accessible UI patterns make great Cypress hooks:
aria-busy attribute<ul data-testid="item-list" aria-busy="true">
Test:
cy.get('[data-testid=item-list]').should('have.attr', 'aria-busy', 'false')
role="status" with live regions<div role="status" aria-live="polite" data-testid="status"> Loading… </div>
Test:
cy.get('[data-testid=status]').should('contain', 'Loaded 10 items')
cy.get('[data-testid=submit]').should('be.disabled') cy.get('[data-testid=submit]').should('not.be.disabled')
These patterns aid screen reader users and produce stable, deterministic E2E tests.
There ARE valid scenarios:
For those cases: Combine times: 1 with explicit, fresh intercepts defined right before triggers.
For other cases: the test should rely on the UI state.
(Network + UI, the best of both worlds)
// UI-driven loading signal cy.get('[data-testid=create]').click() cy.get('[data-testid=spinner]').should('exist') // Network contract check cy.intercept({ method: 'POST', pathname: '/api/items', times: 1 }).as('postItem') cy.get('[data-testid=create]').click() cy.wait('@postItem') .its('request.body') .should('deep.include', { title: 'New item' }) // Final user-visible assertion cy.get('[data-testid=status]').should('contain', 'Item created')
The network part is accurate. The UI part is resilient. The test is rock-solid.
For accessible, deterministic, non-flaky Cypress tests
aria-busy, role="status", aria-live, and disabled statestimes: 1 to auto-expire the interceptcy.wait('@alias') waits “for the next request”\

Copy linkX (Twitter)LinkedInFacebookEmail
XRP at Risk of $2.05 Retest, Analy
