Stop Stubbing Blindly: Getting Real Value from Cypress cy.intercept()
Fixed cy.wait(ms) delays are the top cause of flaky Cypress suites. Learn how cy.intercept() with aliased routes fixes timing, enables deterministic stubbing, and where its limits are.
11 May 2026, 19:09 UTC

Your Cypress suite fails on CI but passes locally, and the culprit is almost always the same: a test that asserts on data before the network call finishes, patched over with cy.wait(2000). Fixed waits are a coin flip dressed up as synchronization. The useful takeaway: cy.intercept() plus an aliased cy.wait() gives you deterministic control over network timing, and it doubles as a way to stub responses so your UI tests stop depending on a live backend. The catch is that a stubbed test proves nothing about your real API — so you need to know where the line is.
What cy.intercept() actually does
cy.intercept() sits between the browser under test and the network. When your app fires an HTTP request that matches a route you defined, Cypress can do three things with it: let it through untouched (pure spying), modify the request or response, or answer it entirely with a stubbed reply. Your application code never knows the difference.
It has been the standard API since Cypress 6, and the only option since cy.server()/cy.route() were removed in Cypress 7. If you copy an old tutorial that uses cy.route(), it will simply fail on any current install — worth checking before you wonder why nothing intercepts.
Two limitations matter up front. First, cy.intercept() only sees traffic from the browser Cypress drives; server-to-server calls are invisible to it. Second, matching semantics (globs, query strings, streaming bodies) have version-sensitive edge cases, so confirm behavior against the Cypress version actually installed in your project.
Replacing cy.wait(ms) with route aliases
The highest-value use of cy.intercept() isn't stubbing at all — it's synchronization. You declare a route, give it an alias with .as(), and wait on the alias instead of guessing a duration:
// cypress/e2e/users.cy.js — runs in the Cypress browser context
describe('users page', () => {
it('renders the user list after the API responds', () => {
cy.intercept('GET', '/api/users').as('getUsers');
cy.visit('/users');
cy.wait('@getUsers'); // waits for the real request to complete
cy.get('[data-cy=user-row]').should('have.length.greaterThan', 0);
});
});The test now proceeds the instant the response arrives — fast when the backend is fast, patient when it isn't. This alone eliminates the most common source of flaky Cypress suites. No special permissions are needed; this is ordinary test code that runs wherever your other Cypress specs run.
A worked stubbing example
Stubbing goes further: instead of letting the request through, you answer it yourself. This isolates the frontend so UI tests are fast and deterministic, and lets you simulate states a real backend makes awkward — empty lists, errors, slow responses.
it('shows an empty state when no users exist', () => {
cy.intercept('GET', '/api/users', { statusCode: 200, body: [] }).as('getUsers');
cy.visit('/users');
cy.wait('@getUsers');
cy.get('[data-cy=empty-state]').should('be.visible');
});
it('shows an error banner when the API fails', () => {
cy.intercept('GET', '/api/users', { statusCode: 500, body: { message: 'boom' } })
.as('getUsers');
cy.visit('/users');
cy.wait('@getUsers');
cy.get('[data-cy=error-banner]').should('contain', 'boom');
});Beyond static objects, you can reply with a fixture ({ fixture: 'users.json' }, loaded from cypress/fixtures/) or a handler function for dynamic responses. Matching supports method, URL glob or regex, headers, and query strings — but keep globs narrow. Something like cy.intercept('GET', '/api/**') can silently swallow unrelated requests and produce tests that pass for the wrong reason.
The trade-off: stubs drift from reality
A stubbed test proves your UI handles the response you gave it. It proves nothing about whether the real API still returns that shape. Fixtures rot quietly: the backend renames displayName to name, your stubbed suite stays green, and production breaks. This is false confidence, and it compounds as stub coverage grows.
The standard mitigation is a split strategy: stub for the bulk of UI behavior tests (fast, deterministic, easy to force edge cases), and keep a small set of true end-to-end tests against a live backend to validate the actual contract. Some teams also generate fixtures from real API responses on a schedule so drift surfaces quickly. Streaming or binary response bodies may need explicit handling in intercept handlers — another reason to verify against your version rather than assume.
How to verify the stub is doing what you think
Three quick checks keep you honest:
- Inspect the route in the runner. In Cypress's time-travel debugger, the intercepted call appears in the command log. Click it to confirm the alias, status code, and body match what you intended.
- Remove the stub. Temporarily delete the
cy.intercept()reply and run the test against the real backend. If the test still passes unchanged, your stub wasn't driving the outcome — or your assertions are too weak. - Check your version. Run
npx cypress versionin the project. Anything v6+ hascy.intercept(); v7+ removed the old API entirely. Confirm matching edge cases in the docs for that version.
Actionable closing
Pick your flakiest spec this week — the one with the cy.wait(3000) nobody will admit to writing. Replace the fixed wait with an aliased intercept, then add one stubbed test for an error state your backend can't easily produce. Keep the live-backend smoke tests for contract coverage. That combination — intercepts for timing, stubs for UI states, real calls for truth — is where cy.intercept() earns its keep.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.