This is the most misleading error Cypress produces, because it reads like a statement of fact about the network and it usually is not one. In my experience it has four distinct causes, and only one of them means what the message says. Fixing the wrong one is how a spec ends up with three nested waits and a cy.wait(2000) on top.
Here is how to tell them apart in about a minute.
Cause 1: you aliased after the trigger
The classic, and the one everybody knows about — except it hides beautifully inside a helper.
export function openReport() {
cy.get('[data-cy="report-btn"]').click()
cy.intercept('POST', '**/api/report/generate').as('generate')
cy.wait('@generate')
}
The click fires the request. cy.intercept registers a route matcher for requests that happen after it is set up. Cypress commands queue, so the ordering is exactly as written: click, then start listening. Fast backend, no request. Slow backend, request caught. This is the one that "only fails in CI", because CI is often faster on the network hop than your laptop tunnelling to a remote environment.
You will not spot it by reading the spec, because the spec says openReport() and looks innocent. The fix is architectural: a helper that waits on a request must own the trigger, not follow it.
export function withIntercept(
method: string,
route: string,
alias: string,
trigger: () => void,
) {
cy.intercept(method, route).as(alias)
trigger()
return cy.wait(`@${alias}`)
}
withIntercept('POST', '**/api/report/generate', 'generate', () => {
cy.get('[data-cy="report-btn"]').click()
})
Take the trigger as a callback. Now the ordering is enforced by the signature and cannot be got wrong by a caller. I converted a set of shared helpers to this shape and a whole family of intermittent failures across unrelated specs disappeared in one commit — which is the signature of a helper-level race rather than a spec-level one.
Cause 2: a spec-local handler ended the chain and starved a shared alias
This one cost me the most time, so I will spell it out.
Suppose your support file sets up a broad intercept that several helpers wait on:
cy.intercept('POST', '**/api/**').as('apiCalls')
Then in one spec you want to inspect a specific response, so you add:
cy.intercept('POST', '**/api/report/generate', (req) => {
req.continue((res) => {
expect(res.statusCode).to.eq(200)
})
})
Now the shared @apiCalls alias stops resolving for that route, and every helper that waited on it fails with No request ever occurred — including helpers in the same spec that have nothing to do with reports.
Cypress runs matching route handlers as a chain, most recently registered first. A handler that takes over responsibility for the response — req.continue() with a callback, or req.reply() — completes the request from that handler and the earlier matcher never gets its turn. Your spec-local intercept did not add an observer; it took the request off the shared route.
Fix: stay pure middleware. Observe the response through the event hook and let the chain continue past you.
cy.intercept('POST', '**/api/report/generate', (req) => {
req.on('before:response', (res) => {
expect(res.statusCode).to.eq(200)
})
})
Same access to the response, same ability to mutate it, but this handler does not terminate the chain, so the shared alias still fires. req.on('before:response') is the tool you actually wanted almost every time you reached for req.continue.
How to spot it: the failing wait is on a broad or shared alias, the failure appeared right after somebody added a narrow intercept, and the request is plainly visible in the command log. If Cypress shows you the request and still says it never occurred, you are looking at a chain-ownership problem, not a network problem.
Cause 3: the endpoint only fires when the value actually changed
Not a race at all, and no amount of intercept restructuring will help.
Plenty of forms are wired so the save request is skipped when the submitted value is identical to the current one. Data-entry grids, inline editors, autosave fields, "commit" flows — all commonly diff before sending. Your test types 42 into a cell that already contains 42, the app correctly does nothing, and your wait times out.
The tell is that the test passes once and then fails forever, or passes on a freshly restored environment and fails on a reused one. The first run changed the value; every run after that is a no-op. If your reaction to that description is "so the fix is to restore the database", careful — that hides the fragility rather than removing it.
Three real options, in order of preference:
Make the input relative to current state.
cy.get('[data-cy="qty"]').invoke('val').then((current) => {
const next = Number(current) + 1
cy.get('[data-cy="qty"]').clear().type(String(next))
})
Reset the fixture in a beforeEach, so the starting value is known rather than inherited.
Assert on the outcome instead of the request. If what you care about is that the total updated, wait on the total. A request is an implementation detail; the rendered total is the behaviour. Waiting on requests is a means, not a goal, and specs that wait on the network for things they could observe in the DOM are harder to maintain for no gain.
Cause 4: the response is a 204 and the event you are waiting for never comes
Some endpoints acknowledge and hand off. You POST to start a job, the server returns 204 No Content, and the actual result arrives later over a different channel — a websocket push, a poll, a second request — when there is a result to send. In the empty or no-op case, nothing is ever sent.
So a waiter built like this hangs:
cy.intercept('POST', '**/api/procedure/run').as('run')
cy.intercept('POST', '**/api/procedure/result').as('result')
cy.get('[data-cy="run"]').click()
cy.wait('@run')
cy.wait('@result')
@run resolves. @result times out, because for this input the server had nothing to report and correctly sent nothing.
Wait for what the application cannot finish without: the acknowledgement you can observe, then the visible end state.
cy.intercept('POST', '**/api/procedure/run').as('run')
cy.get('[data-cy="run"]').click()
cy.wait('@run').its('response.statusCode').should('be.oneOf', [200, 204])
cy.get('[data-cy="run-spinner"]').should('not.exist')
cy.get('[data-cy="status"]').should('contain', 'Completed')
Chase this one down properly before you rewrite selectors. Two specs I inherited were tagged as data-source problems for months; both were this — a 204 hand-off and a waiter listening for an event that only exists on the non-empty path.
While you are here: stop counting requests
Related failure, same family:
cy.wait(['@layout', '@layout', '@layout'])
Exact-count waits encode a request count into your test. The moment the app legitimately batches two calls into one, or adds a fourth, the spec fails and tells you nothing useful. They are also unstable across environments where caching differs.
One wait plus a retriable assertion on the end state does the same job and survives refactors. Where you genuinely need "all in-flight work settled", wrap it in a named helper so there is one place to fix when the app's request pattern changes.
The one-minute triage
| What you see | Cause | Fix |
|---|---|---|
| Request visible in command log, wait on a narrow alias fails | Aliased after the trigger | Helper owns the trigger |
| Request visible, wait on a shared/broad alias fails | A later handler ended the chain | req.on('before:response') |
| Passes once, then never again | Endpoint only fires on change | Relative input or fixture reset |
| First wait resolves, second times out; response was 204 | Hand-off with no result event | Wait on the ack plus the UI end state |
Rules
- Any helper that waits on a request must accept the trigger as a callback.
- Use
req.on('before:response')to observe. Reservereq.continue/req.replyfor when you intend to own the request. - If a test passes exactly once, suspect a change-guarded endpoint, not a race.
- A
204is an acknowledgement, not a result. Never wait on an event the server sends only sometimes. - Prefer asserting the visible end state over the network call that produced it.
