The single biggest reason Cypress tests are less flaky than Selenium tests is automatic retry. Almost every command — selection, assertion, network wait — re-runs itself silently until it passes or hits a timeout. If you internalise just one Cypress concept, make it this one. The vast majority of "stop using cy.wait(2000)" advice you'll ever read flows from understanding what's already happening for you under the hood.
How auto-waiting actually works
Take this two-line test:
cy.get("[data-testid='submit']").click();
cy.get("[data-testid='confirmation']").should("be.visible");When Cypress runs the first line, it does roughly this:
- Query the DOM for
[data-testid='submit']. Did it find an element? - If no, wait a few milliseconds and re-query. Repeat until it finds the element or the command timeout (default 4 seconds) fires.
- Once the element exists, check actionability — is it visible? Not disabled? Not covered by another element? Not animating? If any check fails, retry.
- Scroll the element into view if needed.
- Dispatch the click —
mousedown,mouseup,clickevents.
Every step retries silently. You don't write the loop; Cypress writes it for you. The same logic applies to .click, .type, .check, .select, .should, and most of the rest of the API.
Assertions retry too — and they retry the whole chain
.should() is part of the same retry machinery. When you write:
cy.get("[data-testid='product-card']").should("have.length", 6);Cypress doesn't grab [data-testid='product-card'] once and then assert. It re-queries the DOM, runs the assertion, and if the assertion fails, re-queries again. So if the page loads four cards immediately and adds two more after a fetch, the test still passes — the chain keeps retrying until six show up or the timeout fires.
This is why chained queries work without explicit waiting:
cy.get("[data-testid='product-list']")
.find("[data-testid='product-card']")
.should("have.length", 6);The whole chain — get → find → should — is re-run on every retry attempt. If the list isn't there yet, Cypress keeps trying the entire pipeline.
Configuring the timeout
The default is 4 seconds, set by defaultCommandTimeout in cypress.config.ts:
import { defineConfig } from "cypress";
export default defineConfig({
e2e: {
defaultCommandTimeout: 4000, // default
},
});For genuinely slow apps — staging environments, heavy SSR pages, slow third-party services — bump it globally:
defaultCommandTimeout: 10000,Or, more surgically, set the timeout per command:
cy.get("[data-testid='reports-table']", { timeout: 15000 })
.should("be.visible");Per-command timeouts are the right tool when one specific element is slow and the rest of the suite shouldn't pay the cost. Bumping the global timeout from 4s to 30s "to fix flake" usually masks a real problem and just makes failing tests take 26 more seconds to fail.
When cy.wait() is wrong
// ❌ Anti-pattern
cy.wait(3000);
cy.get("[data-testid='confirmation']").click();Three reasons this is wrong:
- Slow when the page is fast. If the confirmation appears in 200ms, you've wasted 2.8 seconds. Across a 100-test suite, you've added five minutes per run.
- Flaky when the page is slow. A CI machine under load takes 4 seconds. Your 3-second wait fails the test even though the app worked perfectly.
- Hides real signals. A test that needs
cy.wait(3000)to be reliable is telling you something — usually that the app doesn't expose a signal you can wait on properly. Sleeping past it hides the signal.
Replace fixed waits with assertions:
// ✅ Correct — wait on a real DOM signal
cy.get("[data-testid='confirmation']").should("be.visible").click();When cy.wait() is appropriate
cy.wait() has one legitimate form: waiting on an aliased network request:
cy.intercept("POST", "/api/orders").as("createOrder");
cy.get("[data-testid='checkout-btn']").click();
cy.wait("@createOrder"); // ← waits for the actual API call to fire and resolve
cy.get("[data-testid='confirmation']").should("be.visible");This is condition-based waiting — the test pauses until a real thing happens (the POST resolves), not until a fixed clock runs out. Chapter 4 covers cy.intercept and aliased waits in depth.
cy.wait("@alias") — almost always the right call. cy.wait(ms) — almost always the wrong one.
Debugging timeout failures
When a test fails with "Timed out retrying after 4000ms: Expected to find element", the runner is telling you exactly what didn't happen. The diagnostic checklist:
- Is the selector right? Open the Selector Playground and hover the actual element. Does it match what your test typed?
- Is the element actually appearing? Run the test, watch the runner. If the element never shows up, the bug is in the app or the test setup, not the timeout.
- Is the app legitimately slower than 4 seconds? Bump the timeout for that specific command (
{ timeout: 10000 }). Don't bump global without good reason. - Is the element inside an iframe? Cypress can't see across iframe boundaries by default — see the next lesson.
- Is there a previous step that's silently failing? Sometimes a
cy.visitlands on the wrong URL because of a redirect, and every following selector is querying the wrong page.
Time Travel (chapter 1, lesson 4) is your friend here: hover the failing command, look at the DOM Cypress saw, and 90% of the time you'll spot the answer.
A complete example with retries doing the work
A typed test that loads a slow page, waits for items to appear, and asserts on the count — without a single explicit wait:
describe("Dashboard with delayed data", () => {
beforeEach(() => {
cy.visit("/dashboard");
});
it("shows orders once the data loads", () => {
// The orders table starts empty and populates after a network fetch.
// Cypress retries the assertion until at least one row appears.
cy.get("[data-testid='order-row']").should("have.length.greaterThan", 0);
// The total count text updates from "Loading…" to "12 orders".
// Cypress retries the contain assertion until the text matches.
cy.get("[data-testid='order-count']").should("contain", "orders");
// Click the first order — Cypress waits for it to be clickable.
cy.get("[data-testid='order-row']").first().click();
cy.url().should("match", /\/orders\/\d+/);
});
it("shows a slow-loading widget within 15 seconds", () => {
cy.get("[data-testid='analytics-widget']", { timeout: 15000 })
.should("be.visible");
});
});No cy.wait, no setTimeout. The first test trusts the default 4-second timeout. The second one bumps a single command to 15 seconds because the analytics widget is genuinely slow on staging — surgical, not global.
The retry loop, visualised
Step 1 of 5
Run command
Cypress executes the next command in the queue, e.g. cy.get('[data-testid=submit]').
⚠️ Common mistakes
- Reaching for
cy.wait(ms)to fix a flaky test. It almost never fixes anything — it just delays the moment the flake reveals itself. If a test is flaky after a click, the right fix iscy.intercept+ alias +cy.wait("@alias")(chapter 4) or ashouldassertion on a stable post-click DOM signal. Treat everycy.wait(ms)in a code review as a smell. - Bumping
defaultCommandTimeoutto 30 seconds because one element is slow. That makes every other failing test take 26 more seconds to surface. Use per-command{ timeout: 30000 }instead, surgically, on the one element that needs it. - Believing
.then()retries..then()is the synchronous escape hatch — it runs once with whatever the previous chain yielded. If the previous chain has already settled, fine; if it might still be loading, the assertion inside.thenfires too early and the test flakes. Use.should()for anything that might take time. Reserve.thenfor arithmetic and parsing on values you've already waited for.
🎯 Practice task
Replace flaky waits with retry-aware assertions. 20-25 minutes.
- In your scaffolded project, create
cypress/e2e/auto-waiting.cy.tsagainst Sauce Demo (baseUrl: "https://www.saucedemo.com"). - Write a
describe("Auto-waiting drills")with these tests:- "clicks Add to cart on the first product without an explicit wait" — log in, then a single
cy.contains("[data-test='inventory-item']", "Backpack").contains("button", "Add to cart").click()followed by an assertion that the cart badge has text"1". Nocy.wait. Confirm it passes. - "asserts the inventory list has six items" —
cy.get("[data-test='inventory-item']").should("have.length", 6). The retry behaviour means this works even if the list paints in two waves. - "deliberately uses
cy.wait(0)and a.thenassertion that flakes" — replace one of the workingshouldassertions withcy.get(...).then(($el) => expect($el).to.have.length(6))and addcy.wait(0)before it. Run several times in CI mode (npm run cy:run) — note that this passes locally but is the kind of test that breaks under load.
- "clicks Add to cart on the first product without an explicit wait" — log in, then a single
- Surgically extend a timeout. Add a synthetic delay by inserting
cy.intercept("GET", "**/inventory.json", (req) => { req.on("response", (res) => res.setDelay(6000)) })(chapter 4 preview) before the inventory assertion. The default 4-second timeout fails. Now add{ timeout: 10000 }to the assertion. It passes. You've just used a per-command timeout the right way. - Force a timeout failure by typoing a
data-testvalue (e.g.data-test='inventory-itemm'). Run the spec — read the failure message and the suggestion. Hover the failing command in Time Travel. Fix the typo. - Stretch: open the Cypress commands cheat sheet, find the
defaultCommandTimeout,requestTimeout,responseTimeout, andpageLoadTimeoutsettings, and write one sentence explaining what each one controls. These are the four knobs you'll touch in real projects when timing edges show up.
You now know why Cypress is fast and what not to write. The next lesson goes one level deeper into the DOM — iframes and shadow roots, the two boundaries Cypress's default DOM access can't cross.