Cypress Test Retries: A Practical Way to Tame Flaky E2E Tests Without Hiding Real Bugs
Cypress retries can stabilize a flaky CI pipeline — or hide real bugs. Here's how to configure run-mode-only retries, pair them with assertion-driven waits, and keep flake visible.
13 Apr 2026, 14:48 UTC

The problem: green locally, red in CI
Your end-to-end suite passes on your machine, then fails in CI on a test that asserts a toast message appeared. You re-run the pipeline, it passes, and nobody learns anything. This is the classic flaky-test loop, and it's where Cypress's built-in retries option earns its place — if you treat it as a pressure valve, not a fix.
The thesis of this post: enable a small number of retries in run mode only, pair them with stable selectors and explicit assertions, and track every test that needed a retry so you can fix it later.
How retries actually work in Cypress
Cypress re-runs a failed test from the beginning, up to the number of attempts you configure. Each attempt is a full re-execution of the test body (including beforeEach hooks), so state from the failed attempt doesn't leak into the next one. You configure retries separately for cypress run (headless, CI) and cypress open (the interactive runner):
// cypress.config.js
const { defineConfig } = require('cypress');
module.exports = defineConfig({
e2e: {
retries: {
runMode: 2, // CI: up to 3 total attempts
openMode: 0, // local dev: fail immediately
},
},
});The split matters. In open mode you want failures to surface instantly so you see flake while developing. In run mode you want the pipeline to survive the occasional timing hiccup. Setting openMode: 0 keeps the local feedback loop honest.
You can also override retries per test or per suite, which is useful when one known-flaky area needs an extra attempt while the rest of the suite stays strict:
// cypress/e2e/checkout.cy.js
describe('checkout', { retries: { runMode: 3 } }, () => {
it('completes payment', () => {
cy.visit('/checkout');
cy.get('[data-testid="pay-button"]').click();
cy.get('[data-testid="confirmation"]').should('be.visible');
});
});Retries don't fix timing problems — assertions do
A retry only helps if the failure is genuinely intermittent. If your test fails because it clicks an element before the app finishes rendering, retrying just rolls the dice again. The more durable fix is to write assertions that wait. Cypress commands retry automatically until a timeout, so prefer asserting on a visible outcome over sprinkling cy.wait(500) through the suite:
// Fragile: fixed wait, still races on slow CI runners
cy.wait(500);
cy.get('[data-testid="toast"]').should('contain', 'Saved');
// Better: assert the precondition, let Cypress's built-in retry-ability wait
cy.get('[data-testid="save-button"]').should('not.be.disabled').click();
cy.get('[data-testid="toast"]', { timeout: 10000 }).should('contain', 'Saved');Two habits make retries far more effective: use data-testid attributes instead of CSS classes or DOM position (classes change with styling refactors; test IDs don't), and assert on application state before interacting. With those in place, most remaining flake comes from genuine environment noise — network variance, shared CI runners, third-party services — which is exactly what retries are for.
Verifying the configuration
To confirm retries behave as expected, create a deliberately flaky spec and run it headless from your project root (no special permissions needed, just your normal dev environment):
// cypress/e2e/flake-demo.cy.js
let attempt = 0;
it('fails the first time', () => {
attempt += 1;
expect(attempt).to.be.greaterThan(1);
});Run npx cypress run --spec cypress/e2e/flake-demo.cy.js. In the terminal output you should see the test listed with multiple attempts and a final "passing" status, plus a note that it passed on a retry. Then open the interactive runner with npx cypress open and run the same spec — it should fail on the first attempt, confirming your openMode: 0 setting. Exact output formatting varies by Cypress version, so check the docs for your installed version if the report looks different.
The trade-off: CI time and hidden flake
Retries are not free. A suite with runMode: 2 can, in the worst case, take close to three times as long, and every retried pass is a flaky test you didn't fix. Two guardrails keep this healthy:
- Keep the count low. One or two retries in run mode is usually enough. If a test needs three attempts regularly, it's broken, not flaky.
- Track retried tests. Cypress reports which tests passed on a retry (and this data is available in Cypress Cloud if you record runs). Review that list weekly and file fixes — a retry that fires every build is a bug report, not a success.
Also note that retries apply to the whole test, not individual commands. If a beforeEach hook is itself flaky, every attempt pays that cost too.
Actionable closing
Set retries: { runMode: 2, openMode: 0 } today, audit your suite for cy.wait(milliseconds) calls and replace them with assertion-driven waits, and put "tests that passed on retry" on your team's weekly review agenda. Retries should make your pipeline quieter while you fix the underlying flake — not make the flake invisible.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.