Stop Using cy.wait(ms): Fix Flaky Cypress Tests
Replace cy.wait(ms) with cy.intercept() plus cy.wait(@alias).
20+ years shipping production backend systems. Drawn from code that ran under real load.
- ✓A Cypress suite with network-driven pages (API-backed lists, forms, dashboards)
- ✓Basic familiarity with browser DevTools Network tab
- ✓Permission to add a lint rule to your project's eslint config
- cy.wait(ms) with a fixed number guesses at latency — too short on slow runs (flake) and wasted idle time on fast ones (slow suite)
- Declare the expected traffic first: cy.intercept('GET', '/api/users').as('getUsers') before the triggering action
- Then cy.wait('@getUsers') pauses exactly until that request finishes, fast or slow
- Finish with a UI assertion on the rendered data so render lag after the response is covered too
A fixed sleep is like microwaving every meal for exactly 3 minutes: soup explodes while the casserole stays frozen. Waiting on the network alias is like using a food thermometer — you cook until done, however long that takes. Same kitchen, same food, but one method checks reality and the other just watches the clock. Your tests deserve the thermometer, not the egg timer.
Your suite has a line everyone recognizes: cy.wait(3000) sitting after a click like a nervous habit. It was added at 6 PM on a Friday to stop a flake, it worked that evening, and it has been quietly taxing every run since. Now the suite takes 40 minutes, the flake is back anyway, and someone just bumped the sleep to 5000.
Fixed sleeps fail in both directions at once. When the network is fast, the test sits idle burning CI minutes for nothing. When the network is slow — a cold CI runner, a throttled staging API — the response arrives after the sleep ends and the test fails exactly as if the sleep were never there. You pay the cost on every run and still lose the race on bad days. It is the worst of both worlds, formalized as a testing strategy.
There is a better wait, and it has been in Cypress all along: tell Cypress which network request you expect with cy.intercept(), then wait for that request with cy.wait(@alias). The test pauses precisely until the app's real work finishes — 200 milliseconds on good days, 9 seconds on bad ones — then proceeds the instant it can. This article converts every sleep in your suite into that pattern.
Why cy.wait(ms) Makes Tests Flaky
A fixed sleep makes a promise it cannot keep: that the app's work will finish within exactly N milliseconds on every machine, every run, forever. Latency is not constant — it shifts with CI load, cold caches, throttled staging APIs, and payload sizes. When the work finishes early, the sleep burns idle time. When the work finishes late, the test fails as though the sleep never existed. One line of code, failures in both directions.
The damage compounds at suite scale. Two hundred sleeps averaging three seconds inject ten minutes of dead air into every CI run — runs that still flake, because averages never cover the slow tail. Teams respond by raising the numbers, which lengthens every run while merely moving the failure threshold to a rarer slow day. The flake rate feels slightly better for a week, the suite gets permanently slower, and the underlying race is untouched.
Worst of all, sleeps hide the real synchronization point. A reader seeing cy.wait(3000) learns nothing about what the test waits for — a request, a render, an animation? The knowledge lives in the original author's head and rots there. Event-based waits document themselves: cy.wait('@getUsers') followed by a row-count assertion tells the next reader exactly which work must finish before the test proceeds.
cy.intercept(): Teaching Cypress About Your Network
The command teaches Cypress about your app's network contract: which method and URL pattern to watch, and what to call it. Registered with cy.intercept().as('getUsers'), the intercept listens for matching traffic while letting it flow to the real backend untouched — observation is the default, stubbing is opt-in. Your test gains a named event it can wait on instead of a guessed duration.
Matching is where intercepts succeed or fail. Match on the HTTP method plus a stable path fragment ('GET', '/api/users*'), deliberately ignoring volatile query strings, pagination params, and cache-busters. Full-URL patterns rot the first time the frontend adds a query param; method-plus-fragment survives normal API evolution. The Cypress runner's Routes panel shows every hit per alias — check it before trusting any green run.
Registration order is the whole game. An intercept only sees requests fired after it starts listening, so it must precede the click, the visit, or the keystroke that triggers the traffic. For page-load requests, that means intercepting before . Get the order right and the alias becomes a faithful record of the app's real work; get it wrong and you will chase timeout ghosts for an afternoon.cy.visit()
cy.wait(@alias): Wait for the Request, Not the Clock
The replacement pattern has four beats — intercept, act, wait, assert — and runs at the speed of the app rather than the speed of your guess. cy.wait('@getUsers') pauses the test until the matching request completes or the command timeout expires, whichever comes first. Fast backend, 200-millisecond pause. Slow CI day, nine-second pause. Either way the test proceeds the instant the work is done, not a millisecond before or after.
The wait also yields the interception object, so you can assert on the traffic itself: status codes, response bodies, request payloads. cy.wait('@createUser').its('response.statusCode').should('eq', 201) proves the backend accepted the write, not merely that something happened. For parallel loads, pass an array of aliases and wait for the whole set before asserting the composed UI.
Keep the default command timeout in mind as the backstop: an alias wait that never matches fails loudly at the timeout instead of hanging forever. That loud failure is a feature — it points at the missing request (wrong pattern, wrong order, dead backend) instead of letting the test wander into unrelated assertions. A timed-out alias wait is Cypress telling you exactly which expected work never happened.
Network-Aware Assertions Instead of Fixed Sleeps
Network completion is not render completion, and tests that stop at the alias wait still flake. The response lands, Cypress resumes, and the assertion runs while React is mid-render — rows still mounting, totals still computing. The fix is a network-aware assertion: follow every alias wait with a check on the rendered outcome the user would see, and let that assertion's retry loop absorb the paint lag.
Choose assertions that prove the pipeline finished end to end. Row counts (should('have.length', 5)), expected text (should('contain', 'Ada')), computed totals — each verifies data arrived and rendered. Avoid asserting loading-state absence alone; a spinner can unmount before the rows mount, passing your check during the gap. Assert presence of the finished UI, not absence of the busy UI.
When a step is genuinely slow — a report endpoint that takes twelve seconds — resist the sleep reflex and extend that assertion's timeout instead ({ timeout: 15000 }). Timeouts widen the patient window while keeping the instant-pass behavior on fast days; sleeps charge the maximum on every day. Comment the bump with the reason (slow endpoint name, data volume) so the next reader knows it is measured patience, not another guess.
Timeouts Done Right: When Waiting Longer Is Correct
Timeouts and sleeps look similar — both involve milliseconds — but behave oppositely. A sleep always consumes its full duration: cy.wait(10000) burns ten seconds on a run where the app answered in one. A timeout consumes nothing when things are fast: an assertion with { timeout: 15000 } passes in one second on good days and waits up to fifteen only when the app genuinely needs it. One charges the maximum always; the other charges the actual.
Apply timeouts surgically to the steps that earned them. The report endpoint that aggregates for twelve seconds gets a longer assertion timeout with a comment naming the endpoint. The suite-wide defaultCommandTimeout stays modest so genuinely stuck tests still fail fast instead of hanging the pipeline. Blanket timeout inflation is just sleeps with better branding — same cost, vaguer responsibility.
Measure before and after. Record the suite's p50 and p95 step durations from the Cypress dashboard, bump only the steps whose p95 exceeds the default, and note the reason beside each bump. Timeout policy driven by measured latency converges; timeout policy driven by vibes recreates the sleep sprawl under a new name. Patience should be budgeted, documented, and reviewed — never sprinkled.
Removing Every cy.wait(ms) From Your Suite
Migration is a loop, not a flag day: convert, verify, delete, repeat — flakiest specs first. For each sleep, identify the covered work, add the intercept and alias wait plus UI assertion, and run the spec several times across different hours (latency varies by time of day on shared CI). Only when the replacement is green repeatedly does the sleep get deleted. Proof before removal keeps every intermediate state shippable.
Rank the backlog by impact: sleep milliseconds multiplied by executions per day. A five-second sleep in a smoke spec run hourly outranks a one-second sleep in a nightly spec — convert where CI minutes and developer attention concentrate. Publish the running total of deleted sleep-seconds; watching suite time fall sprint by sprint keeps the cleanup funded against feature pressure.
Lock the gains with automation. An eslint rule (eslint-plugin-cypress supports no-unnecessary-waiting) flags new bare cy.wait(ms) in review, and a periodic grep over the suite counts remaining instances for the team dashboard. Suites regrow sleeps under deadline pressure unless the guardrail is mechanical. Convert deliberately, verify rigorously, and make the fixed state the path of least resistance.
The 200 Fixed Sleeps That Made the Suite Slow and Flaky at Once
cy.wait(1000) through cy.wait(8000) across the suite — over 200 instances. On the shared CI runners, p95 API latency regularly exceeded the guessed sleeps, so specs failed after waiting the full delay: slow and broken simultaneously. Local runs stayed green because the laptop's API responded in milliseconds.- Fixed sleeps charge every run the maximum delay while protecting only the average case — invert that with event-based waits.
- Suite duration is a feature: every minute of blind sleeping is CI budget that could fund real coverage.
- Migration economics matter — convert the highest sleep-millseconds-times-frequency specs first and let the time savings fund the rest.
cy.intercept('GET', '/api/that-resource*').as('load') before the trigger plus cy.wait('@load') after it. Delete the numeric sleep. Run the spec five times — the alias version should be both faster and stabler.cy.intercept() above the click or cy.visit() that fires the request — for page-load requests, register before visiting. Re-run and watch the Routes panel: the alias should log a hit. A wait with no matching hit always times out, which is correct behavior for a miswired test.METHOD + stable-path*. Re-run with the Routes panel open and confirm the hit before trusting the green result — a passing test on a non-matching alias is testing nothing.| File | Command / Code | Purpose |
|---|---|---|
| cypress | cy.get('[data-testid="load-users"]').click() | Why cy.wait(ms) Makes Tests Flaky |
| cypress | cy.intercept('GET', '/api/users*').as('getUsers') | cy.intercept() |
| cypress | cy.intercept('GET', '/api/users*').as('getUsers') | cy.wait(@alias) |
| cypress | cy.get('[data-testid="report-total"]', { timeout: 15000 }) | Timeouts Done Right |
Key takeaways
Common mistakes to avoid
5 patternsSprinkling cy.wait(ms) after every click
cy.wait(3000) with cy.intercept('GET', '/api/users').as('users') before the action and cy.wait('@users') after it. The test now waits for the actual response, whether it takes 200ms or 9 seconds.Waiting on an alias whose intercept was registered too late
cy.wait('@users') times out even though the network tab shows the request, because the intercept started listening after the request already fired.cy.wait('@alias') after it. Order matters: intercept-then-act-then-wait, always.Writing intercepts that never match the real request
'GET', '/api/users*') rather than the full URL with volatile query params. Verify the match in the Cypress runner's Routes panel.Asserting immediately after cy.wait(@alias) with no UI check
cy.wait('@users'); cy.get('[data-testid="user-row"]').should('have.length', 5). Network completion plus rendered output proves the whole pipeline finished.Raising every fixed sleep when the suite gets slower
{ timeout: 15000 }) instead of adding sleeps. Timeouts extend the patient window; sleeps add blind delay. One adapts, the other guesses.Interview Questions on This Topic
Why is cy.wait(ms) with a fixed number considered bad practice?
Frequently Asked Questions
20+ years shipping production backend systems. Drawn from code that ran under real load.
That's Cypress. Mark it forged?
5 min read · try the examples if you haven't