Handling Waits and Retries — Cypress Auto-Waiting

8 min read

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:

  1. Query the DOM for [data-testid='submit']. Did it find an element?
  2. If no, wait a few milliseconds and re-query. Repeat until it finds the element or the command timeout (default 4 seconds) fires.
  3. Once the element exists, check actionability — is it visible? Not disabled? Not covered by another element? Not animating? If any check fails, retry.
  4. Scroll the element into view if needed.
  5. Dispatch the clickmousedown, mouseup, click events.

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:

  1. Is the selector right? Open the Selector Playground and hover the actual element. Does it match what your test typed?
  2. 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.
  3. Is the app legitimately slower than 4 seconds? Bump the timeout for that specific command ({ timeout: 10000 }). Don't bump global without good reason.
  4. Is the element inside an iframe? Cypress can't see across iframe boundaries by default — see the next lesson.
  5. Is there a previous step that's silently failing? Sometimes a cy.visit lands 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 is cy.intercept + alias + cy.wait("@alias") (chapter 4) or a should assertion on a stable post-click DOM signal. Treat every cy.wait(ms) in a code review as a smell.
  • Bumping defaultCommandTimeout to 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 .then fires too early and the test flakes. Use .should() for anything that might take time. Reserve .then for arithmetic and parsing on values you've already waited for.

🎯 Practice task

Replace flaky waits with retry-aware assertions. 20-25 minutes.

  1. In your scaffolded project, create cypress/e2e/auto-waiting.cy.ts against Sauce Demo (baseUrl: "https://www.saucedemo.com").
  2. 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". No cy.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 .then assertion that flakes" — replace one of the working should assertions with cy.get(...).then(($el) => expect($el).to.have.length(6)) and add cy.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.
  3. 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.
  4. Force a timeout failure by typoing a data-test value (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.
  5. Stretch: open the Cypress commands cheat sheet, find the defaultCommandTimeout, requestTimeout, responseTimeout, and pageLoadTimeout settings, 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.

// tip to track lessons you complete and pick up where you left off across devices.