Debugging and Fixing Flaky Cypress Tests: Timing, Network, and State

Debugging and Fixing Flaky Cypress Tests: Timing, Network, and State

Cypress flaky tests are particularly frustrating because Cypress has automatic waiting built in — so why are tests still timing out? The answer is usually one of three things: the test is waiting for the wrong thing, network requests are unpredictable, or state leaks between tests.

This post walks through each category with concrete debugging techniques and fixes.

Timing Issues

The wrong waiting strategy

Cypress automatically retries commands until they succeed or timeout. cy.get(".button") waits up to 4 seconds (configurable) for the element to appear. But the default timeout isn't enough for slow operations, and the retry logic doesn't apply to all assertions.

Common mistake: waiting for an element, not the state

// Flaky: waits for element, but element may be visible before data loads
cy.get(".user-list").should("be.visible");
cy.get(".user-item").should("have.length", 5);

// Better: wait for the data itself
cy.intercept("GET", "/api/users").as("getUsers");
cy.visit("/users");
cy.wait("@getUsers");
cy.get(".user-item").should("have.length", 5);

Common mistake: fixed delays

// Flaky: arbitrary timeout
cy.wait(2000);
cy.get(".modal").should("be.visible");

// Better: wait for the condition
cy.get(".modal", { timeout: 10000 }).should("be.visible");

Increasing timeouts for slow operations

For known slow operations (file uploads, video processing, complex reports), increase the per-command timeout:

// Global config
// cypress.config.js
export default defineConfig({
  defaultCommandTimeout: 10000, // 10s instead of 4s
  requestTimeout: 15000,
  responseTimeout: 30000,
});

// Per-command override
cy.get(".export-status", { timeout: 30000 }).should("contain", "Complete");

Don't increase the global timeout to compensate for all timing issues — that just makes your tests slower and masks real problems. Use per-command timeouts for specific slow operations.

Network Request Handling

Intercept all requests that affect test state

The most reliable Cypress tests intercept every network request that matters and wait explicitly for those requests:

describe("User management", () => {
  beforeEach(() => {
    cy.intercept("GET", "/api/users*").as("getUsers");
    cy.intercept("POST", "/api/users").as("createUser");
    cy.intercept("DELETE", "/api/users/*").as("deleteUser");
  });

  it("creates a new user", () => {
    cy.visit("/users");
    cy.wait("@getUsers"); // Wait for initial load

    cy.get("[data-cy=add-user]").click();
    cy.get("[data-cy=email-input]").type("new@example.com");
    cy.get("[data-cy=submit]").click();

    cy.wait("@createUser").its("response.statusCode").should("eq", 201);
    cy.wait("@getUsers"); // Wait for list refresh
    cy.get("[data-cy=user-row]").should("contain", "new@example.com");
  });
});

Without explicit cy.wait() for the refresh request, the test might check the user list before the POST response triggers a re-fetch.

Stubbing network requests for determinism

For tests that don't need real API responses, stub the network:

it("shows empty state when no users exist", () => {
  cy.intercept("GET", "/api/users", { body: [], statusCode: 200 }).as("getUsers");
  cy.visit("/users");
  cy.wait("@getUsers");
  cy.get("[data-cy=empty-state]").should("be.visible");
});

it("handles API error gracefully", () => {
  cy.intercept("GET", "/api/users", { statusCode: 503 }).as("getUsers");
  cy.visit("/users");
  cy.wait("@getUsers");
  cy.get("[data-cy=error-banner]").should("contain", "Unable to load users");
});

Stubbed tests are deterministic — they always get the same response, regardless of server state or network conditions.

Handling race conditions with aliased requests

When multiple requests fire in sequence and your test needs to wait for all of them:

it("loads dashboard with all widgets", () => {
  cy.intercept("GET", "/api/metrics*").as("getMetrics");
  cy.intercept("GET", "/api/alerts*").as("getAlerts");
  cy.intercept("GET", "/api/activity*").as("getActivity");

  cy.visit("/dashboard");

  // Wait for all three requests to complete
  cy.wait(["@getMetrics", "@getAlerts", "@getActivity"]);

  cy.get("[data-cy=metrics-widget]").should("be.visible");
  cy.get("[data-cy=alerts-widget]").should("be.visible");
  cy.get("[data-cy=activity-widget]").should("be.visible");
});

cy.wait() with an array waits for all aliases before proceeding.

State Management Between Tests

Database/session leakage

The most common state issue in Cypress: test data created by one test affects the next test. A user created in test 1 appears in the list assertion in test 2.

Fix: reset state before each test

// cypress/support/commands.js
Cypress.Commands.add("resetDatabase", () => {
  cy.request("POST", "/api/test/reset-db"); // Server-side reset endpoint
});

Cypress.Commands.add("seedDatabase", (fixture) => {
  cy.request("POST", "/api/test/seed", { fixture });
});

// In tests:
beforeEach(() => {
  cy.resetDatabase();
  cy.seedDatabase("base-users");
});

Add a /api/test/reset-db endpoint to your development server that truncates relevant tables and reseeds with known data. This is fast (milliseconds) and makes every test start from a known state.

Cypress preserves cookies and localStorage between tests within a spec by default. This can cause auth state from one test to bleed into another.

// cypress/support/e2e.js
beforeEach(() => {
  cy.clearCookies();
  cy.clearLocalStorage();
  cy.clearSessionStorage();
});

Or use cy.session() for explicit session management:

// Session is cached per user — logged in once per spec, not per test
Cypress.Commands.add("loginAs", (username) => {
  cy.session(username, () => {
    cy.visit("/login");
    cy.get("[data-cy=email]").type(`${username}@example.com`);
    cy.get("[data-cy=password]").type("testpassword");
    cy.get("[data-cy=submit]").click();
    cy.url().should("include", "/dashboard");
  });
});

cy.session() caches the session state after the first call. Subsequent tests restore the cached state instead of going through the login flow again — faster and more deterministic.

Debugging Tools

Time-travel debugging with Cypress Studio

The Cypress App's time-travel debugger lets you hover over any command in the log and see the state of the DOM at that point. When a test fails at cy.get(".user-count").should("contain", "5"), click that command to see what the DOM actually contained at that moment.

Screenshots and videos

Enable these in your CI config:

// cypress.config.js
export default defineConfig({
  screenshotOnRunFailure: true,
  video: true, // Record video of every test run
  videosFolder: "cypress/videos",
  screenshotsFolder: "cypress/screenshots",
});

Upload artifacts in CI:

- name: Upload test artifacts
  if: failure()
  uses: actions/upload-artifact@v4
  with:
    name: cypress-artifacts
    path: |
      cypress/videos/
      cypress/screenshots/

The cy.log() approach

Add cy.log() statements to understand what's happening at each step:

it("processes checkout", () => {
  cy.log("Starting checkout test");
  cy.visit("/cart");
  cy.log("Waiting for cart to load");
  cy.intercept("GET", "/api/cart").as("getCart");
  cy.wait("@getCart");
  cy.log("Cart loaded, verifying items");
  cy.get("[data-cy=cart-item]").should("have.length", 2);
});

cy.log() output appears in the command log, helping trace exactly where timing issues occur.

Using --repeat to Reproduce Locally

Reproduce CI flakiness locally by running the test repeatedly:

# Run a specific test 20 times
npx cypress run \
  --spec <span class="hljs-string">"cypress/e2e/checkout.cy.js" \
  --<span class="hljs-built_in">env numTestsKeptInMemory=0 \
  --config numTestsKeptInMemory=0 \
  -- --repeat 20

Or use a shell loop:

for i <span class="hljs-keyword">in {1..20}; <span class="hljs-keyword">do
  npx cypress run --spec <span class="hljs-string">"cypress/e2e/checkout.cy.js" --quiet
  <span class="hljs-built_in">echo <span class="hljs-string">"Run $i complete: $?"
<span class="hljs-keyword">done

If 2 out of 20 runs fail, you have ~10% flakiness — enough to investigate seriously.

The Debugging Checklist

When a Cypress test is flaky, check in this order:

  1. Add cy.wait("@alias") after every navigation and action that triggers a network request
  2. Increase the command timeout for the specific assertion that's failing
  3. Add beforeEach state reset — cookies, localStorage, database
  4. Check for test order dependence — run the test in isolation, then in the full suite
  5. Enable video recording and watch the failure frame by frame
  6. Add cy.log() at each step to see exactly where timing diverges

Most Cypress flakiness is timing + network. Fix the interceptions, add explicit waits, and 80% of flaky tests become stable.

For monitoring which Cypress tests are flaky in production CI runs over time, HelpMeTest tracks test reliability across runs and surfaces tests that show inconsistent behavior before they become a recurring problem.

Read more