Let’s be honest — we’ve all done it.
You run into flaky tests, and your quick fix looks like this 👇
cy.wait(5000)
Five seconds later — it works!
…but only sometimes. 😅
Welcome to the illusion of stability that cy.wait() gives.
In this article, let’s break down why most Cypress testers misuse it, how it silently kills reliability, and the right synchronization patterns to make your tests rock-solid. ⚙️
🧨 The Problem with Hard Waits
cy.wait(5000) doesn’t wait for your app to be ready —
it just waits for time to pass.
If the backend is faster → you waste time.
If it’s slower → your test fails anyway.
You end up with tests that:
❌ Pass locally but fail in CI
❌ Depend on network speed
❌ Hide real synchronization bugs
In short:
cy.wait()is a band-aid for bad synchronization.
✅ What You Should Do Instead
Cypress gives you powerful automatic retry mechanisms.
Let’s replace cy.wait() with smarter, intent-based waits 👇
🔍 1. Wait for Elements to Appear
Bad 👇
cy.wait(3000)
cy.get('.user-profile').click()
Good 👇
cy.get('.user-profile', { timeout: 10000 }).should('be.visible').click()💡 Cypress automatically retries cy.get() and .should() until the condition is met — no manual delays needed.
🔄 2. Wait for API Calls to Complete
If you’re testing a flow that depends on network calls,
use route aliasing and network intercepts.
cy.intercept('GET', '/api/users').as('getUsers')
cy.get('#load-users').click()
cy.wait('@getUsers')
cy.get('.user-list').should('have.length.greaterThan', 0)✅ cy.wait('@getUsers') waits for the actual request, not an arbitrary time.
✅ Makes tests both faster and stable.
⚙️ 3. Wait for UI State Changes
Don’t guess — assert.
Instead of this 👇
cy.wait(5000)
cy.get('.notification').should('contain', 'Success')
Do this 👇
cy.get('.notification').should('contain', 'Success')Cypress automatically retries the .should() until the condition is true or the timeout expires.
🧠 Pro Tip: Combine Intercepts with Assertions
Sometimes the UI and API timing differ — fix it by chaining intelligently.
cy.intercept('POST', '/api/login').as('login')
cy.get('#submit').click()
cy.wait('@login').its('response.statusCode').should('eq', 200)
cy.get('.dashboard').should('be.visible')Here’s what’s happening:
- Cypress waits for the network call to finish.
- Then it verifies response success.
- Finally, it checks UI readiness.
That’s real synchronization. 💪
🧩 Advanced: Dynamic Waiting via Custom Commands
You can abstract your waits for reuse:
Cypress.Commands.add('waitForSpinner', () => {
cy.get('.spinner', { timeout: 10000 }).should('not.exist')
})Now just call:
cy.waitForSpinner()
Reusable, readable, reliable. ✨
🧱 The Right Waiting Hierarchy
Think of Cypress waits like this pyramid 🧱
Layer Description Example Assertions Retry-based checks .should('be.visible') Intercepts Wait for backend events cy.wait('@api') Custom Commands Abstract complex waits cy.waitForSpinner() Hard Waits Only for debugging cy.wait(5000)
🧠 Use hard waits only for debugging — never in committed tests.
🚀 Bonus: Real-World Example
Let’s fix a flaky signup test.
❌ Before:
cy.visit('/signup')
cy.get('#email').type('qa@test.com')
cy.get('#submit').click()
cy.wait(5000)
cy.get('.success').should('contain', 'Welcome')✅ After:
cy.visit('/signup')
cy.intercept('POST', '/api/signup').as('signup')
cy.get('#email').type('qa@test.com')
cy.get('#submit').click()
cy.wait('@signup').its('response.statusCode').should('eq', 201)
cy.get('.success').should('contain', 'Welcome')Result?
✅ 4x faster test run
✅ 0 flaky retries
✅ Confidence restored in CI
🧭 Final Thought
If your Cypress suite feels “flaky,” it’s probably not Cypress’s fault — it’s your waiting strategy.
The goal isn’t to wait more —
It’s to wait smarter.
So the next time you reach for cy.wait(5000), ask yourself —
“What exactly am I waiting for?”
Because great testers don’t wait on time —
they wait on truth. 💥


