Article · Network & API Mocking for Reliable Tests

Cypress cy.intercept Best Practices for Flaky Tests

cy.intercept() is the single most powerful lever for deterministic Cypress tests — and the most common source of self-inflicted flakiness when misused. A matcher too broad, an alias bound too late, or a global intercept that leaks across specs each reintroduces the race conditions interception was meant to remove. This guide, part of Cypress Network Interception Patterns under Network & API Mocking for Reliable Tests, standardizes matching, aliasing, response lifecycle, and isolation so intercepts stabilize instead of destabilize.

13 sections URL: /network-api-mocking-for-reliable-tests/cypress-network-interception-patterns/cypress-cyintercept-best-practices-for-flaky-tests/
Four foundations of reliable interception Strict matching, alias-before-action, explicit reply/continue, and per-test isolation together make cy.intercept deterministic. strict matchmethod + path alias firstbefore action reply / continueno hangs isolateper test
Reliable interception rests on four foundations — every flake below is a violation of one of them.

Strict Route Matching & Specificity #

A glob like **/api/** collides with unrelated endpoints and makes mock resolution unpredictable. Always pin the HTTP method and an exact path, use a regex or req.query for dynamic segments, and add { times: 1 } for single-use endpoints so stale mock data cannot contaminate later assertions.

Broad glob versus pinned matcher A broad glob catches several endpoints ambiguously; a method-plus-path matcher maps to one logical call. **/api/**collides, ambiguous GET /api/users?role=adminone logical call
Pin the method and path so an alias maps to exactly one request, never a family of them.
// Method + exact query match prevents catching a similar endpoint by accident.
cy.intercept({ method: 'GET', url: '/api/users?role=admin' }, { fixture: 'admin-users.json' }).as('getAdmins');

Alias Binding & Wait Synchronization #

The classic flake is cy.wait('@alias') running before the request dispatches, because the intercept was registered after the triggering action. Bind the alias immediately after cy.intercept(), before the click or visit, then wait on the alias instead of a numeric timer — the full pattern is in Aliasing and Waiting on cy.intercept Requests.

Register before the action Registering the intercept and alias before the UI action is what lets Cypress capture the request. intercept().as() click / visit cy.wait('@alias') register after the action → request escapes capture
Cypress cannot retroactively capture a request that fired before its route was registered.
// Alias bound before the click; wait + assert on the exact call, no numeric delay.
cy.intercept('POST', '/api/v1/checkout', { fixture: 'checkout-success.json' }).as('checkoutReq');
cy.get('[data-testid=pay-button]').click();
cy.wait('@checkoutReq').its('response.statusCode').should('eq', 200);

Response Lifecycle & Payload Control #

An intercept handler must call req.reply() or req.continue() or the request hangs until timeout. Use req.reply({ statusCode, body }) for deterministic stubs, and mutate res.body inside req.reply((res) => ...) when you only need to flip one field rather than own the whole payload.

Reply, continue, or hang A handler that neither replies nor continues hangs the request; reply stubs it, continue forwards it. intercept handler req.reply() → stub req.continue() → forward neither → hang
Every handler path must terminate in reply or continue, or the request hangs to timeout.
// Mutate one field on the live response instead of owning the whole payload.
cy.intercept('GET', '/api/config', (req) => {
  req.reply((res) => {
    res.body.featureFlags.darkMode = true; // surgical change, low maintenance
    res.send();
  });
}).as('getConfig');

Test Isolation & State Cleanup #

Global intercepts in support/e2e.js persist across files and cause cross-test pollution — especially dangerous under Cypress Cloud parallelization. Declare route mocks in beforeEach() or at the spec level so every test starts clean, and remember aliases reset per test.

Global persistence versus per-test reset A global intercept bleeds state into the next spec; a beforeEach intercept resets it each test. spec A spec B polluted test 1 clean test 2 clean global beforeEach
Per-test registration keeps parallel workers from leaking mock state into each other.

Configuration & Implementation Snippets #

The { times: 1 } option is the cleanest defense against a single-use handler contaminating later requests.

times:1 handler falls through after one hit A times:1 intercept serves the first request then lets subsequent identical requests fall through. times: 1 interceptPOST /api/submit request 1 → stub request 2 → falls through
`times: 1` scopes a stub to a single request so it cannot bleed into the next one.
// times: 1 fires exactly once, then falls through — no stale mock for later requests.
cy.intercept({ method: 'POST', url: '/api/submit', times: 1 }, {
  statusCode: 200,
  body: { id: 'order-123' },
}).as('submitOnce');

Common Pitfalls & Resolutions #

cy.intercept anti-patterns and fixes Late binding, reused aliases, over-mocking CDNs, and time-based waits each map to a fix. late intercept binding register before the action reuse aliases across tests re-declare in beforeEach over-mock CDNs / analytics mock only failure states time-based cy.wait(ms) alias wait + retry-ability
Every red habit reintroduces a race; the green fix restores determinism.
  • Late intercept binding — register before any UI interaction that fires the call.
  • Reusing aliases across tests — aliases reset per test; re-declare in beforeEach().
  • Over-mocking third-party CDNs — mock analytics or fonts only when testing failure states.
  • Time-based waits in CI — replace numeric cy.wait() with alias waits and retry-ability.

Reliability Targets #

Best-practice scorecard Targets for timing assertions, binding success, CI pass consistency, and network overhead. 0timing asserts 100%binding success > 99.5%CI consistency < 5%overhead
Eliminating timing assertions is the target that drives the other three.
Metric Target How to hit it
Timing-based assertions eliminated Alias waits, never numeric cy.wait
Intercept binding success 100% Register before the triggering action
CI pass consistency > 99.5% Strict matchers + per-test isolation
Overhead vs real network < 5% Cached fixtures, single-use stubs

FAQ #

How do I fix “cy.wait() timed out waiting for @alias”? Confirm the intercept registers before the request fires, that the URL and method match exactly, and that the alias is not overwritten. The Cypress network tab shows the real timing.

Should I mock all network requests? No. Mock external and non-deterministic dependencies; keep internal API calls real to test data flow, using intercepts to assert payloads and cover edge cases.

How does cy.intercept handle concurrent identical requests? Intercepts queue in registration order and, without times, the last matching one wins. Use times: 1 or unique aliases per request to isolate concurrent calls.

Matching Precisely Enough, and No More #

Most interception trouble is matcher trouble. Too broad and the intercept catches requests the test never meant to control; too narrow and it silently fails to match, so the real request goes through — producing a spec that passes locally with a warm cache and fails on a fresh runner.

Three properties should be as specific as the behaviour requires and no more. Method, whenever an endpoint serves both reads and writes, since a pattern without it will answer a create with a list payload. Path specificity, because a pattern covering a collection will also cover its sub-resources, and whichever alias registered first wins. Query parameters, for anything paginated or filtered, since the response differs and the URL is the only thing distinguishing the calls.

The counter-pressure is that over-specific matchers break on harmless changes: a cache-busting parameter, an analytics tag, a client version field in the body. The balance that survives is to match on method, path and the query keys that change the answer, and to use a predicate for bodies where only some fields matter rather than comparing whole objects.

// Specific enough to be unambiguous, loose enough to survive additive changes.
// Trade-off: a predicate is more code than a literal body match and does not
// break when the client starts sending an extra field.
cy.intercept({ method: 'POST', pathname: '/api/orders', query: { tenant: 'acme' } }, (req) => {
  expect(req.body).to.include({ currency: 'EUR' });
  req.reply({ statusCode: 201, body: { id: 'ORD-1' } });
}).as('createOrder');

Registration Order and Test Isolation #

Two structural rules prevent most interception surprises, and both concern where the intercept is declared rather than what it does.

Register before the action that triggers the request. An intercept declared after the visit misses everything fired during page load, which is where authentication, feature-flag and bootstrap requests happen. This single ordering mistake accounts for a large share of “the intercept never fired” reports.

Register per test, not per spec. An intercept declared in a before hook survives across the tests in a spec, so a scenario set up for one test can answer requests in another — and an alias registered there refers to a request that already happened, which produces assertions that pass for entirely the wrong reason. Declaring in beforeEach or inside the test keeps each test’s network picture its own.

The session cache adds a wrinkle worth knowing: a restored session skips the login flow, so intercepts written to observe authentication traffic will not fire on specs that restore rather than perform the login. That is usually desirable, and a spec that genuinely asserts on authentication requests must opt out of the cache or perform the flow explicitly.

Disabling per-test isolation compounds all of this. The page persists, in-flight requests from an earlier test can land during a later one, and the intercept meant to handle them may already have been superseded — which is another argument for leaving isolation on and caching the login instead.

Assertions Worth Making on a Request #

An intercept gives access to the outgoing request, and using it turns a mock from something that always agrees into a check on the client.

Three assertions carry most of the value. Required fields in the body, because a client that stops sending a mandatory parameter still receives a stubbed 200 and the suite notices nothing. Authentication and tenancy headers, which are easy to drop in a refactor and produce failures only in environments that enforce them. Query parameters that change behaviour — pagination, filters, sort order — since a client sending the wrong page is a real defect that a permissive stub conceals entirely.

What to avoid asserting is the exact serialisation of everything. A test comparing a whole request body against a literal breaks on any harmless addition, such as a client version field, and those failures train people to loosen assertions rather than to read them.

// Assert the parts of the request that carry meaning, ignore the rest.
// Trade-off: a predicate is more code than a literal comparison and it survives
// additive changes that do not affect behaviour.
cy.intercept('POST', '/api/orders', (req) => {
  expect(req.headers).to.have.property('x-tenant');
  expect(req.body).to.include({ currency: 'EUR' });
  req.reply({ statusCode: 201, body: { id: 'ORD-1' } });
}).as('createOrder');

Prefer the Narrowest Intervention #

When several techniques would work, the narrowest one usually ages best: stub a single endpoint rather than a prefix, override for one test rather than in a shared hook, and assert on one field rather than a whole body. Each narrowing reduces the number of unrelated changes that can break the spec, which is what keeps a suite maintainable as the application moves underneath it.

Reviewing intercepts as carefully as application code pays off, because a stub is a claim about how a dependency behaves. An unreviewed handler that drifted from the API produces green tests and a broken feature, which is the worst outcome available.

A stub that has never been reviewed is an assumption, not a fixture.