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.
Cookie and localStorage leakage
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 20Or 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">doneIf 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:
- Add
cy.wait("@alias")after every navigation and action that triggers a network request - Increase the command timeout for the specific assertion that's failing
- Add
beforeEachstate reset — cookies, localStorage, database - Check for test order dependence — run the test in isolation, then in the full suite
- Enable video recording and watch the failure frame by frame
- 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.