Cypress
Cypress Flaky Tests: Retries, Detached Elements and Network Waits
The usual reasons Cypress tests fail intermittently and the fixes that hold up in CI.

Cypress flaky tests pass on one run and fail on the next, with no code change in between. Nearly every case traces back to one of four causes: a re-render that detaches the element you hold, a wait sized for a fast day, a network call the test never waits for, or state that leaks from one test into the next. This guide works through each one by the error text Cypress prints. Each fix comes with code we ran against a small demo app with Cypress 16.2 in Chrome. Then it covers test retries, which keep CI green while you fix the cause, but only if you can still see the flakes.
Everything here is current as of October 2026 (Cypress 16.2.0, released 13 October 2026).
Key takeaways
- Reproduce first. Run the test 20 times in one spec with
Cypress._.times, headless, in the browser your CI uses. Then force the slow case on purpose withcy.intercept. - "The subject is no longer attached to the DOM" means you chained onto an action, or kept an element after a re-render. End each chain at its action, and start the next step with a fresh query or an alias.
cy.wait(2000)doesn't fix timing. In our two runs it cut failures from 12 and 9 in 20 to 5 and 1 in 20, and made every test 2 seconds slower. Waiting on an intercept alias brought them to 0 in 20 both times.- Reset server state in
beforeEach, notafterEach. Test isolation clears the browser, not your database. - Turn retries on for
runModeonly. Then check the "(Attempt 2 of 3)" lines, because the run summary counts a test that passed on retry as passing.
Reproduce Cypress flaky tests before you fix them
A fix you can't test is a guess. Before you change anything, make the flake happen on demand.
Run the test many times in one spec. Cypress has no repeat flag, but its bundled Lodash builds the loop for you. Each copy is a real test with its own retry budget and its own result:
// cypress/flaky/waits.cy.ts (excerpt)
const search = (term: string) => {
cy.visit('/search.html');
cy.get('[data-cy="search-input"]').type(term);
cy.get('[data-cy="search-button"]').click();
};
describe('no wait at all', () => {
Cypress._.times(20, (k) => {
it(`finds the mugs, run ${k + 1}`, () => {
search('mug');
cy.get('[data-cy="results"] li').should('have.length', 2);
});
});
});Run it the way CI runs it. Use cypress run, headless, with the same --browser your pipeline passes. Run the whole spec, not one test with .only. A test that passes alone and fails in the full spec is depending on another test, which the state section covers.
npx cypress run --browser chrome --config specPattern=cypress/flaky/waits.cy.tsThe demo keeps its broken specs outside the default specPattern, hence --config. In your own project, --spec <file> does the same job.
Force the slow case. If you suspect timing, make the slow path happen every time. A route handler can hold the real response back. This test now fails on every run instead of one run in three:
it('makes the slow case happen every time', () => {
// Hold every search answer for 5 seconds.
cy.intercept({ method: 'GET', pathname: '/api/search' }, (req) => {
req.on('response', (res) => {
res.setDelay(5000);
});
});
cy.visit('/search.html');
cy.get('[data-cy="search-input"]').type('mug');
cy.get('[data-cy="search-button"]').click();
cy.get('[data-cy="results"] li').should('have.length', 2);
});A failure you can repeat is one you can fix. When your change is in, the same loop and the same delay show whether it holds.
"Detached from the DOM": the subject is no longer attached
This is the error Cypress prints when you chain a second click onto the first, and the app re-renders the button in between:
it('chains a second action onto the first', () => {
cy.get('[data-cy="add-to-cart"]').click().click();
cy.get('[data-cy="cart-count"]').should('have.text', '2');
});CypressError: `cy.click()` failed because the page updated as a result of this command, but you tried
to continue the command chain. The subject is no longer attached to the DOM, and Cypress cannot requery
the page after commands such as `cy.click()`.The demo cart page works like many component frameworks. Every render replaces the button with a new element. The first click triggers a render, so the element the chain holds is gone from the page.
To see why Cypress can't recover, you need its split between queries and actions. The retry-ability guide explains it. Queries such as cy.get(), .find() and .eq(), plus assertions, are retried as one chain until the assertion passes. Actions such as .click() and .type() run once. Cypress can re-run the queries before an action. It can't re-run anything past one, because that would repeat the click.
Fix 1: end each chain at its action
Start every step with its own query, and put an assertion between steps that waits for the re-render:
it('adds two mugs', () => {
cy.get('[data-cy="add-to-cart"]').click();
cy.get('[data-cy="cart-count"]').should('have.text', '1');
cy.get('[data-cy="add-to-cart"]').click();
cy.get('[data-cy="cart-count"]').should('have.text', '2');
});The assertion in the middle matters. Without it, the second cy.get() can find the button that is about to be replaced. The count check waits until the first add has finished and the page has settled.
Fix 2: don't keep elements in variables
The second way to get the same error is to save an element and use it later:
it('keeps a reference to the button in a variable', () => {
let button: JQuery<HTMLElement>;
cy.get('[data-cy="add-to-cart"]').then(($button) => {
button = $button;
});
cy.get('[data-cy="add-to-cart"]').click();
cy.get('[data-cy="cart-count"]').should('have.text', '1');
cy.then(() => cy.wrap(button).click());
});This fails with the same "no longer attached" error on the last line. The variable still points at the original element, and the app threw that element away two renders ago. Use an alias instead. Since Cypress 12, an alias re-runs its queries every time you read it (.as() docs), so cy.get('@addButton') always finds the current button:
it('reuses the button through an alias, which re-runs the query', () => {
cy.get('[data-cy="add-to-cart"]').as('addButton');
cy.get('@addButton').click();
cy.get('[data-cy="cart-count"]').should('have.text', '1');
cy.get('@addButton').should('have.text', 'Add to cart').click();
cy.get('[data-cy="cart-count"]').should('have.text', '2');
});If you actually want a frozen value, such as a total before a change, pass { type: 'static' } to .as(). Keep that for values, not for elements you will click.
Fixed waits: why cy.wait(2000) only lowers the failure rate
The demo search API answers in anywhere from 0.3 to 7 seconds. That's an exaggerated version of a busy staging server. We ran the search test 20 times in each of three forms, in one cypress run, and then did the whole run again:
| Version | Failed (of 20), run 1 | Failed (of 20), run 2 | Time added to every test |
|---|---|---|---|
cy.get(...).should(...) alone |
12 | 9 | none |
cy.wait(2000) first, then the same assertion |
5 | 1 | 2 seconds |
cy.wait('@search') first, then the same assertion |
0 | 0 | only the real response time |
Your numbers will differ, since the delays are random, and the swing between our two runs shows how little one run proves. The pattern held both times. Both failing versions died with the same message:
AssertionError: Timed out retrying after 4000ms: Expected to find element: `[data-cy="results"] li`, but never found it.The reason is which clock is running. A query with an assertion retries for defaultCommandTimeout, which is 4 seconds by default (configuration). A fixed 2-second wait just moves that 4-second window 2 seconds later. Any answer slower than 6 seconds still fails, and every fast answer still pays the 2 seconds. Across 20 tests, that's 40 seconds of sleeping.

The Cypress best practices say the same: guard each step with a route alias or an assertion, not an arbitrary wait. A fixed wait after cy.visit() or cy.request() buys nothing either: cy.visit() already waits for the page's load event, and cy.request() for the server's response.
Network timing: wait for the request you mean
cy.wait('@alias') uses two other clocks (cy.wait docs). requestTimeout (5 seconds by default) is how long the request has to start. responseTimeout (30 seconds by default) is how long the answer has to arrive. That's why the alias version above survived a 7-second API:
it('finds the mugs', () => {
cy.intercept({ method: 'GET', pathname: '/api/search' }).as('search');
cy.visit('/search.html');
cy.get('[data-cy="search-input"]').type('mug');
cy.get('[data-cy="search-button"]').click();
// Waits up to responseTimeout (30 s by default) for the answer, not defaultCommandTimeout (4 s).
cy.wait('@search').its('response.statusCode').should('eq', 200);
cy.get('[data-cy="results"] li').should('have.length', 2);
cy.get('[data-cy="status"]').should('have.text', '2 results');
});Keep the DOM assertions after the wait. The response arriving doesn't mean the page has rendered it, so the assertion is still what proves the user can see the result.
Three mistakes turn this pattern flaky again.
Registering the intercept after the action. A route only sees requests made after you define it (cy.intercept docs). Click first and intercept second, and the wait has nothing to catch:
CypressError: Timed out retrying after 5000ms: `cy.wait()` timed out waiting `5000ms` for the 1st request to the route: `search`. No request ever occurred.This one is flaky rather than always red when the request sometimes fires late, for example after a debounce. Define every intercept before the cy.visit() or click that triggers it.
Waiting for the wrong request. Each cy.wait('@search') takes the next matching request, in order. If the page sends two searches, the first wait gets the first one, even though the screen will show the second. Match the request you mean:
it('waits for the search for "stove", not any search', () => {
cy.intercept({ method: 'GET', pathname: '/api/search', query: { q: 'stove' } }).as('stoveSearch');
cy.visit('/search.html');
cy.get('[data-cy="search-input"]').type('mug');
cy.get('[data-cy="search-button"]').click();
cy.get('[data-cy="search-input"]').clear();
cy.get('[data-cy="search-input"]').type('stove');
cy.get('[data-cy="search-button"]').click();
cy.wait('@stoveSearch');
cy.get('[data-cy="results"] li').should('have.length', 1).and('have.text', 'Camp Stove');
});A URL glob that matches too little or too much. String URLs are glob patterns, and ? in a glob means "any one character", not a query string. Matching on pathname and query, as above, avoids that trap. For GraphQL, where every request hits one URL, set req.alias inside the route handler based on the operation in the request body. The intercept docs show how.
State between tests: what test isolation does and doesn't reset
With testIsolation: true, the default for end-to-end tests, Cypress resets the browser before each test. It loads about:blank and clears cookies, localStorage and sessionStorage in all domains (test isolation). It doesn't clear IndexedDB. It can't touch your server.
So this spec passes or fails depending on order and on what ran before:
describe('notes (broken)', () => {
it('adds a note', () => {
cy.visit('/notes.html');
cy.get('[data-cy="note-input"]').type('Buy milk');
cy.get('[data-cy="note-add"]').click();
cy.get('[data-cy="notes"] li').should('have.length', 1);
});
it('starts empty', () => {
cy.visit('/notes.html');
cy.get('[data-cy="empty"]').should('be.visible');
});
});Against a fresh server, the first test passes and the second fails, because the note is still on the server:
AssertionError: Timed out retrying after 4000ms: expected '<p>' to be 'visible'Run starts empty alone with .only against a fresh server and it passes. Run the whole spec a second time against the same server, and the first test fails too:
AssertionError: Timed out retrying after 4000ms: Too many elements found. Found '2', expected '1'.That's the classic signature of leaked state: green alone, red in the full run, and different again on the next run. Put the server back in a known state before each test, and seed what a test needs through the API, not the UI:
describe('notes', () => {
beforeEach(() => {
cy.request('POST', '/api/test/reset');
});
it('adds a note', () => {
cy.visit('/notes.html');
cy.get('[data-cy="note-input"]').type('Buy milk');
cy.get('[data-cy="note-add"]').click();
cy.get('[data-cy="notes"] li').should('have.length', 1).and('have.text', 'Buy milk');
});
it('starts empty', () => {
cy.visit('/notes.html');
cy.get('[data-cy="empty"]').should('be.visible');
});
it('shows notes that already exist', () => {
cy.request('POST', '/api/notes', { text: 'Seeded note' });
cy.visit('/notes.html');
cy.get('[data-cy="notes"] li').should('have.text', 'Seeded note');
});
});The reset endpoint here belongs to the demo app. In a real app, use a test-only route behind a flag, or a cy.task() that reseeds the database from Node. Three rules keep this stable:
- Clean up before, not after. The best-practices page explains why: an
afterEachmay never run, for example when Cypress is refreshed in the middle of a test. AbeforeEachalways runs first. - Use
beforeEach, notbefore, for the reset. On a retry, Cypress re-runsbeforeEachhooks, so each attempt starts clean. A retriedadds a notewithout the reset would add a second note and fail for a new reason. - Keep hooks boring. If a
beforeEachfails, Cypress fails the current test and skips the rest of that suite. In our run, a slow load insidebeforeEachgave "1 failing" and "Skipped: 1", so one flaky hook hid a test that never ran.
Setting testIsolation: false makes tests share the page, cookies and storage too. It can speed up a long journey split into steps. It also makes every test depend on the one before it, so treat it as a deliberate exception.
Cypress retries in run mode
Retries don't fix flaky tests. They keep a pipeline usable while you fix them. Cypress doesn't retry by default (test retries). Set them per mode in cypress.config.ts:
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3100',
specPattern: 'cypress/e2e/**/*.cy.ts',
supportFile: false,
// Retry a failed test twice in `cypress run` on CI; never in `cypress run` locally or in `cypress open`.
retries: { runMode: process.env.CI ? 2 : 0, openMode: 0 },
},
});runMode applies to cypress run, and openMode to the interactive cypress open. Keep openMode at 0. When you're debugging, you want the first failure, not a pass on the third try. The CI check above also keeps local cypress run honest. A plain number such as retries: 2 applies to both modes.
Here is what a retried test looks like. The demo report page loads slowly (6 seconds) until its server cache is warm. That's like the first test after a deploy:
report (flaky)
(Attempt 1 of 3) shows the monthly total
(Attempt 2 of 3) shows the monthly total
✓ shows the monthly total (241ms)
1 passing (9s)The results table said "Passing: 1, Failing: 0". Only the attempt lines and two screenshots, named ... (failed).png and ... (failed) (attempt 2).png, show that the test failed twice. If nobody reads those, retries quietly hide a real slowdown. The fix here is the same as for search: wait on an intercept for /api/report, which allows up to 30 seconds for the answer.
What else to know about Cypress retries:
- Hooks:
beforeEachandafterEachre-run on every attempt. A failure in abeforeorafterhook is not retried. - Overrides: a suite or a test can set its own retries, which win over the config. With both set below, the test's own value won: our run printed "(Attempt 1 of 4)".
describe('report', { retries: { runMode: 1, openMode: 0 } }, () => {
it('shows the monthly total', { retries: { runMode: 3, openMode: 0 } }, () => {
cy.log(`attempt ${Cypress.currentRetry + 1}`);
cy.visit('/report.html');
cy.get('[data-cy="total"]').should('have.text', '42');
});
});Cypress.currentRetryis 0 on the first attempt. Log it if you need to tell attempts apart.- Cypress Cloud gives a test that passed after a retry a Flaky badge, and you can filter a run for flaky tests. Without Cloud, read the attempt lines or your reporter's output.
Make flakes fail on purpose: experimental retry strategies
If you'd rather a flaky test turn the build red while still learning whether a retry passes, Cypress has two experimental strategies. detect-flake-but-always-fail retries a failed test but fails it if any attempt failed. detect-flake-and-pass-on-threshold passes it only after a set number of passing attempts. With either one, runMode and openMode become booleans:
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3100',
specPattern: 'cypress/flaky/report.cy.ts',
supportFile: false,
retries: {
openMode: false,
runMode: true,
experimentalStrategy: 'detect-flake-but-always-fail',
experimentalOptions: { maxRetries: 2, stopIfAnyPassed: true },
},
},
});On the cold report page, the output now shows the whole story and still fails the run:
✖(Attempt 1 of 3) shows the monthly total
✖(Attempt 2 of 3) shows the monthly total
✓(Attempt 3 of 3) shows the monthly total
✖ shows the monthly total (4161ms)
0 passing (9s)
1 failingThat suits a nightly flake-hunting job. For pull requests, plain retries plus someone who reads the attempt lines is usually the better trade.
Blendra's Cypress export defaults
Blendra records a flow in Chrome and exports it as a test suite in your repo. On Pro+ and Team, a project can export Cypress (Beta). The generated cypress.config.ts follows the advice above:
- Retries in CI only:
retries: { runMode: isCI ? 2 : 0, openMode: 0 }, whereisCIis!!process.env.CI. A failed test is retried twice in CI and not at all locally. - Timeouts:
defaultCommandTimeoutis 5 seconds per step and check, matching the Playwright TypeScript suite's assertion timeout.pageLoadTimeoutis 60 seconds. If an element on a slow page takes longer than 5 seconds to appear, the export docs tell you to raisedefaultCommandTimeout. - Re-queried elements, no fixed waits: each step finds its element through a custom Cypress query (
Cypress.Commands.addQuery), so the lookup re-runs likecy.get(). The generated code never callscy.wait()with a number. - Results: the vendored plugin sends Blendra one result per test, from the final attempt. A retried test's duration covers every attempt.
Blendra also has its own rules for calling a failure flaky: its five newest results must have changed status at least twice, and between 20% and 80% of its recent passed and failed results must be failures. If you're also weighing a switch, Cypress vs Playwright compares the two tools. Flaky Playwright tests covers the same problem on the other side.
FAQ
Why does my Cypress test pass locally but fail in CI?
CI machines are often slower or shared, and CI runs headless against a fresh server and database. Timing that fits inside 4 seconds on your laptop may not fit there, and a fresh database exposes tests that relied on leftover data. Reproduce with cypress run in the CI browser and with a forced delay, as shown above.
Should I just raise defaultCommandTimeout?
Only when the app is genuinely slow everywhere, and then raise it once in the config, not with scattered { timeout } options. For a slow API call, waiting on an intercept alias is better: it uses the 30-second responseTimeout for that request only, and fails with a clear "No request ever occurred" when the request never happens.
Does cy.wait('@alias') wait for the page to update?
No. It waits for the request and its response. The page renders afterwards, so follow it with an assertion on what the user sees, such as .should('have.length', 2).
Are Cypress retries bad practice?
No, as long as you treat a pass on retry as a bug report. Turn them on for runMode in CI only. Read the attempt lines or Cloud's Flaky label. Or use detect-flake-but-always-fail in a scheduled job, so flakes surface instead of disappearing. For more habits that prevent flakes in the first place, see Cypress best practices.