Home › Testing › Stop Using cy.wait(ms): Fix Flaky Cypress Tests
Beginner 5 min · September 23, 2026

Stop Using cy.wait(ms): Fix Flaky Cypress Tests

Replace cy.wait(ms) with cy.intercept() plus cy.wait(@alias).

N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Drawn from code that ran under real load.

Follow
✓ Production
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
Before you start⏱ 9 min
  • ✓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
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • 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
✦ Definition~90s read
What is Cypress Flaky Tests?

The cy.wait() command has two completely different modes, and confusing them causes most of the damage. Called with an alias — cy.wait('@getUsers') — it pauses until a matching network request completes, adapting to the app's real pace on every run.

★
A fixed sleep is like microwaving every meal for exactly 3 minutes: soup explodes while the casserole stays frozen.

Called with milliseconds — cy.wait(3000) — it pauses blindly for exactly that long regardless of what the app does. The first synchronizes with reality; the second synchronizes with a guess its author made months ago about latency that changes daily.

cy.intercept() is what makes alias waits possible: it declares the expected traffic (method plus URL pattern) and labels it with .as('name'), observing real requests by default without stubbing them. The canonical sequence is intercept-then-act-then-wait — register before the triggering click or visit, act, wait on the alias, then assert the rendered outcome.

Each step covers one leg of the pipeline: network declared, action fired, response awaited, UI verified.

Two refinements complete the pattern. Network-aware assertions after the wait cover the render lag between response arrival and paint, since the alias only proves the network finished. And per-assertion timeouts replace sleeps for genuinely slow steps, widening the patient window without charging idle time on fast days.

Together these turn timing from the suite's enemy into a measured, budgeted input — and fixed sleeps become what they should have been all along: a temporary debug tool, never a committed line.

Plain-English First

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.

cypress/e2e/fixed-sleep-tax.cy.jsJAVASCRIPT
1
2
3
4
5
6
7
8
// The double tax of fixed sleeps
cy.get('[data-testid="load-users"]').click()
cy.wait(3000) // fast day: 2.8s wasted idle. slow day: response takes
               // 4.1s -> assertion runs too early -> FAIL. Paid AND failed.
cy.get('[data-testid="user-row"]').should('have.length', 5)

// Scale that across a suite: 200 sleeps x 3s avg x every CI run
// = 10 minutes of blind waiting per run, and the flakes survive.
Try it live
📊 Production Insight
Multiply sleep milliseconds by runs per day and show the team the CI-hours cost. That number funds every migration in this article.
🎯 Key Takeaway
Fixed sleeps tax every run and protect none reliably — and they hide what the test actually waits for.

cy.intercept(): Teaching Cypress About Your Network

The cy.intercept() command teaches Cypress about your app's network contract: which method and URL pattern to watch, and what to call it. Registered with .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 cy.visit(). 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.

cypress/e2e/intercept-basics.cy.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
// cy.intercept(): name the traffic you expect
// Register BEFORE the action that triggers the request
cy.intercept('GET', '/api/users*').as('getUsers')
cy.intercept('POST', '/api/users').as('createUser')
cy.intercept('GET', '/api/teams/*').as('getTeam')

// Observing (default): request still hits the real backend,
// Cypress just notifies your test when it completes.
cy.visit('/users') // page-load requests are caught too,
                    // because the intercept was registered first
Try it live
📊 Production Insight
Most 'intercept doesn't work' tickets are ordering bugs or over-strict patterns. The Routes panel settles both in seconds.
🎯 Key Takeaway
Intercept before the trigger, match on method plus stable path, verify hits in the Routes panel.

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.

cypress/e2e/alias-wait-pattern.cy.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// The core replacement pattern: intercept -> act -> wait -> assert
cy.intercept('GET', '/api/users*').as('getUsers')
cy.get('[data-testid="load-users"]').click()
cy.wait('@getUsers') // pauses exactly until the response lands
cy.get('[data-testid="user-row"]').should('have.length', 5)

// Waiting on several requests at once
cy.intercept('GET', '/api/users*').as('getUsers')
cy.intercept('GET', '/api/teams*').as('getTeams')
cy.visit('/dashboard')
cy.wait(['@getUsers', '@getTeams'])
cy.get('[data-testid="dashboard-ready"]').should('be.visible')

// Inspecting what came back (optional, powerful)
cy.wait('@getUsers').its('response.statusCode').should('eq', 200)
Try it live
📊 Production Insight
Alias waits convert timing mysteries into network facts. 'Which request never finished?' beats 'why is this flaky?' every time.
🎯 Key Takeaway
cy.wait(@alias) tracks the app's actual pace — and its timeout failure names the work that 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.

📊 Production Insight
Presence-of-done beats absence-of-busy. Assert what the user should see, and render lag becomes a covered case.
🎯 Key Takeaway
Assert the rendered outcome after every alias wait — response received is not UI finished.

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.

cypress/e2e/timeout-not-sleep.cy.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
// Timeouts: widen the patient window, don't add blind delay
// Slow report endpoint? Extend THIS assertion's timeout...
cy.get('[data-testid="report-total"]', { timeout: 15000 })
  .should('contain', '$42,000')

// ...not a sleep before it. Compare:
// BAD:  cy.wait(10000); cy.get('[data-testid="report-total"]')...
//        charges 10s on EVERY run, fast or slow
// GOOD: { timeout: 15000 } passes in 1s on fast days,
//        waits up to 15s only when the app needs it

// Suite-level backstop in config (raise thoughtfully)
// cypress.config.js: e2e: { defaultCommandTimeout: 8000 }
Try it live
📊 Production Insight
A timeout with a comment naming the slow endpoint is engineering; a bare sleep is a wish. Reviewers should demand the difference.
🎯 Key Takeaway
Timeouts pass instantly on fast days and wait only when needed — budget them per step from measured latency.

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.

💡Convert, Verify, Then Delete
Delete sleeps only after the replacement proves itself: add the intercept, watch several green runs, then remove the sleep. A removed-without-proof sleep is how a fixed test becomes a new flake with a new author to blame.
📊 Production Insight
The dashboard metric that matters is sleep-seconds deleted per sprint — visible progress turns cleanup from chore into sport.
🎯 Key Takeaway
Migrate flakiest-first with proof before deletion, and lint against new sleeps so gains compound.
● Production incidentPOST-MORTEMseverity: high

The 200 Fixed Sleeps That Made the Suite Slow and Flaky at Once

Symptom
Daily red builds on different specs, each 'fixed' by raising a sleep. Suite runtime grew past 40 minutes while the flake rate stayed flat — the classic signature of timing guesses compounding instead of converging.
Assumption
The team assumed timing was roughly constant — that 3 seconds covered every environment because it covered the developer's laptop. Nobody measured CI latency distribution or considered that a sleep only helps when the work finishes inside it.
Root cause
Developers had papered over timing flakes with 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.
Fix
They banned bare cy.wait(ms) in review, converted the suite flow by flow to intercept-plus-alias waits with UI assertions, and watched suite time fall from 43 to 26 minutes while the flake rate dropped to near zero. The remaining timeout bumps went onto specific slow assertions, documented with comments naming the slow backend endpoint.
Key lesson
  • 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.
Production debug guideFive checks that replace every guessed delay with a wait on real work.5 entries
Symptom · 01
A spec with cy.wait(ms) fails intermittently
→
Fix
Pick the flakiest spec, find what the sleep was covering (a list loading, a save completing), and add 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.
Symptom · 02
cy.wait('@alias') times out though data appears on screen
→
Fix
Move the 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.
Symptom · 03
Intercept never fires (zero hits in the Routes panel)
→
Fix
Open DevTools Network tab during the run, copy the request's real method and path, and loosen the pattern to 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.
Symptom · 04
Alias wait passes but the following UI assertion flakes
→
Fix
Keep the alias wait and append the rendered-output assertion: row count, expected text, totals. The assertion's retry loop covers the render lag the network wait cannot see. If rendering is genuinely slow, raise that assertion's timeout rather than re-adding a sleep.
Symptom · 05
Suite takes too long and still flakes (sleep sprawl)
→
Fix
Rank specs by sleep-milliseconds × runs-per-day and convert the top offenders first — that is where CI minutes and flakes concentrate. Add a lint rule flagging new bare cy.wait(ms) so the suite improves monotonically instead of regrowing sleeps.
Flaky cy.wait(ms) Tests — Root Cause vs Fix at a Glance
Root CauseHow to ConfirmFixPrevention
Fixed sleep shorter (or longer) than actual latencyFailure rate tracks environment speed; suite is slow AND flakyReplace with cy.intercept() + cy.wait('@alias')Ban bare cy.wait(ms) in review; assert events not clocks
Intercept registered after the request firedRequest visible in DevTools but alias wait times outRegister intercept before the triggering actionStandardize intercept-then-act-then-wait ordering
Intercept pattern does not match the requestRoutes panel shows zero hits for the aliasMatch on method + stable path fragmentVerify matches in the runner before trusting the alias
Network done but render not finishedPasses the wait, fails on the next UI assertionAdd a network-aware UI assertion after the waitAlways pair alias waits with rendered-output checks
⚙ Quick Reference
4 commands from this guide
FileCommand / CodePurpose
cypresse2efixed-sleep-tax.cy.jscy.get('[data-testid="load-users"]').click()Why cy.wait(ms) Makes Tests Flaky
cypresse2eintercept-basics.cy.jscy.intercept('GET', '/api/users*').as('getUsers')cy.intercept()
cypresse2ealias-wait-pattern.cy.jscy.intercept('GET', '/api/users*').as('getUsers')cy.wait(@alias)
cypresse2etimeout-not-sleep.cy.jscy.get('[data-testid="report-total"]', { timeout: 15000 })Timeouts Done Right

Key takeaways

1
cy.wait(ms) guesses at timing
wrong in both directions, slow always and flaky when it matters.
2
cy.intercept() names the expected request; cy.wait(@alias) pauses exactly until it completes.
3
Order is intercept-then-act-then-wait
register before the triggering action.
4
Match on method plus stable path fragment; verify hits in the Routes panel.
5
Pair every alias wait with a UI assertion so render lag cannot sneak past.
6
Extend assertion timeouts for slow steps instead of adding blind sleeps.

Common mistakes to avoid

5 patterns
×

Sprinkling cy.wait(ms) after every click

Symptom
Suite time balloons while flakes persist — the sleeps slow every run but still lose to any response slower than the guessed milliseconds.
Fix
Replace 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

Symptom
cy.wait('@users') times out even though the network tab shows the request, because the intercept started listening after the request already fired.
Fix
Define the intercept before the action that triggers the request, then cy.wait('@alias') after it. Order matters: intercept-then-act-then-wait, always.
×

Writing intercepts that never match the real request

Symptom
The alias wait times out while the app visibly loads data — the pattern missed due to a query string, method, or hostname difference.
Fix
Match on method plus a stable path fragment ('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

Symptom
Tests pass the wait but fail on the next line because rendering lagged behind the response — you synchronized the network but not the paint.
Fix
Chain the assertion to the UI: 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

Symptom
Sleeps grow from 1s to 5s to 10s across the suite, runs take forever, and the flakes return whenever latency exceeds the newest guess.
Fix
Bump that assertion's timeout ({ timeout: 15000 }) instead of adding sleeps. Timeouts extend the patient window; sleeps add blind delay. One adapts, the other guesses.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Why is cy.wait(ms) with a fixed number considered bad practice?
Q02JUNIOR
How do you wait for data to load after clicking a button?
Q03SENIOR
cy.wait('@users') times out but the UI shows the data. What do you check...
Q04SENIOR
The alias wait passes but the next assertion still flakes. Why?
Q05SENIOR
How would you eliminate fixed sleeps across a 500-spec suite?
Q01 of 05JUNIOR

Why is cy.wait(ms) with a fixed number considered bad practice?

ANSWER
Because it guesses at timing: too short on a slow run and the test fails, too long and every run pays idle delay. Network latency varies by environment and load, so any fixed number is wrong somewhere. The fix is waiting on the event itself with cy.intercept plus cy.wait(@alias).
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Is cy.wait(ms) ever acceptable?
02
Why does cy.wait('@alias') time out when the request clearly happened?
03
Can I wait for more than one request at a time?
04
How do I write an intercept that actually matches?
05
Does cy.intercept() stop requests from reaching my backend?
06
How do I migrate a suite full of fixed sleeps?
N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Drawn from code that ran under real load.

Follow
✓ Verified
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
🔥

That's Cypress. Mark it forged?

5 min read · try the examples if you haven't

←
Previous
Cypress Cross-Origin Error on Redirect
4 / 4 · Cypress
Next
Playwright Test Timeout of 30000ms Exceeded
→