QA How-To
How to Use Cypress handling flaky tests (2026)
Master Cypress handling flaky tests with retry-ability, observable waits, stable selectors, isolated data, clock control, retries, diagnosis, and CI triage.
25 min read | 3,053 words
TL;DR
Fix Cypress flake at its source: use retrying query assertions, wait on observable outcomes, isolate test data, control time, and match network calls before triggers. Configure a small CI retry allowance only as visible detection and containment.
Key Takeaways
- Distinguish Cypress query retry-ability from whole-test retries before choosing a fix.
- Replace fixed sleeps with observable network, UI, URL, or saved-state conditions.
- Register intercepts before triggers and use narrow method plus URL matchers.
- Create independent parallel-safe data and make retry setup and cleanup idempotent.
- Freeze browser time and control locale, randomness, and animation when they affect assertions.
- Treat fail-then-pass outcomes as flake evidence and preserve first-attempt artifacts.
- Use quarantine only with ownership, continued execution, and a concrete exit condition.
Cypress handling flaky tests means removing uncontrolled timing, ambiguous selectors, shared state, and environment instability, then using retries as detection and containment rather than as the primary fix. A flaky test can pass and fail without a relevant code change, which makes it different from a consistently failing regression.
Cypress already retries queries and their attached assertions. Most flake appears when a suite breaks that model with fixed sleeps, stores stale elements, races network calls, or depends on data left by another test. This 2026 guide shows a root-cause workflow, current retry configuration, and practical code patterns for stable CI.
TL;DR
| Symptom | Likely cause | First response |
|---|---|---|
| Element sometimes not found | Rendering or selector race | Use a retrying query plus outcome assertion |
| Alias wait times out | Intercept registered too late or mismatched | Register before trigger and narrow method plus URL |
| Passes alone, fails in suite | Shared state or order dependence | Reset through API and make the test independent |
| Fails near midnight or month end | Real clock and timezone coupling | Use fixed dates with cy.clock() |
| Passes on retry | Flake detected, not fixed | Preserve artifacts and investigate the first failure |
| Only CI fails | Resource, browser, data, or environment difference | Reproduce CI configuration and inspect evidence |
A dependable default is zero retries in open mode and a small, deliberate number in run mode. A retry that passes should remain visible as flaky work, not disappear from engineering attention.
1. What Cypress Handling Flaky Tests Actually Means
A flaky test produces different outcomes under effectively identical inputs. The unstable factor may live in test code, application code, network dependencies, test data, browser behavior, or CI infrastructure. Classifying the source prevents random timeout increases.
Cypress has built-in retry-ability. Queries such as cy.get() and cy.contains() retry, and assertions linked to them cause the query chain to run again until it passes or times out. Action commands such as .click() wait for actionability, but Cypress does not repeatedly click because that could change application state several times.
Test retries are different. They rerun a failed it() block, including its beforeEach and afterEach hooks, up to the configured number of additional attempts. A test that fails once and passes on retry is still evidence of flake, even if the overall run is allowed to pass.
Three goals guide the work:
- Prevent flake by designing deterministic tests and testable product states.
- Detect flake through repeat runs, retry evidence, and historical tracking.
- Contain unavoidable external instability without hiding product regressions.
Do not label every intermittent failure "just CI." Capture the failing command, screenshot, video, request state, browser console, test attempt, and environment identity. A repeated signature usually reveals an owned boundary.
2. Build a Flake Taxonomy Before Editing Code
Start with a small classification table. It turns a noisy failure queue into decisions.
| Category | Typical evidence | Durable fix |
|---|---|---|
| UI timing | Element appears after request or animation | Assert readiness and rely on retrying queries |
| Network race | Request fired before alias registration | Register cy.intercept() before the trigger |
| Selector ambiguity | Multiple text matches or changing DOM position | Use accessible identity or stable test attribute |
| Data collision | Duplicate record, shared account, parallel overwrite | Create isolated data and clean it predictably |
| Time dependence | Boundary failures by timezone or current date | Freeze clock and use explicit UTC data |
| State leak | Passes alone, fails after another spec | Reset storage and server state per test |
| Infrastructure | Browser crash, service outage, disk pressure | Fix capacity, health gates, or dependency policy |
| Product race | UI really loses an update | Fix application synchronization and keep regression test |
Record the first failure, not only the last retry. Later attempts can pass after a cache warms, a service recovers, or another test creates data. That successful state may erase the useful clue.
Reproduce the same browser, viewport, environment, feature flags, shard, and data setup. Running a CI-only failure repeatedly against a different local stack provides weak evidence.
Use focused repetition to establish a pattern, but do not claim that twenty passing runs prove absence of flake. The goal is to increase confidence and isolate a trigger. Once the cause is understood, add a regression assertion tied to it.
3. Replace Fixed Waits with Observable Conditions
A fixed wait guesses how long the system needs. It can be unnecessarily slow on a fast run and still too short on a busy one.
// Fragile
cy.get('[data-cy="refresh-orders"]').click()
cy.wait(3000)
cy.get('[data-cy="order-row"]').should('have.length', 5)
Wait for the owned event and then assert the outcome:
cy.intercept('GET', '/api/orders?status=open*').as('loadOpenOrders')
cy.get('[data-cy="refresh-orders"]').click()
cy.wait('@loadOpenOrders').then(({ response }) => {
expect(response?.statusCode).to.eq(200)
expect(response?.body.items).to.have.length(5)
})
cy.get('[data-cy="order-row"]').should('have.length', 5)
The alias does not replace the UI assertion. It explains network completion, while the retrying query explains rendering completion.
For state that has no request, assert a user-observable condition:
cy.get('[role="dialog"]')
.should('be.visible')
.and('have.attr', 'aria-busy', 'false')
cy.contains('[role="dialog"] button', 'Confirm')
.should('be.enabled')
.click()
Do not place arbitrary waits after every action. Identify the specific signal: a request, enabled control, removed loading indicator, changed URL, saved record, or status message. If no signal exists, the product may need a testable readiness contract.
Increasing defaultCommandTimeout globally makes every missing element fail more slowly and can hide a few badly synchronized paths. Use a targeted timeout only when a documented business operation legitimately takes longer.
4. Use Retry-Ability Without Breaking the Query Chain
Cypress retries query chains with attached assertions. Keep synchronous transformation inside .should() when the value may change.
cy.get('[data-cy="cart-total"]')
.should(($total) => {
const amount = Number(
$total.text().replace('#39;, '').trim(),
)
expect(amount).to.eq(42)
})
Cypress reruns the preceding query and callback until the assertion passes. The callback must be idempotent. Do not click, send requests, or create server data inside it because retries could repeat the side effect.
.then() runs once after the previous command resolves. It is correct for one-time extraction or branching after readiness, but it does not turn a changing value into a retrying assertion:
// Fragile if text is still updating when get first resolves.
cy.get('[data-cy="job-status"]').then(($status) => {
expect($status.text()).to.eq('Complete')
})
// Retryable.
cy.get('[data-cy="job-status"]').should('have.text', 'Complete')
Avoid storing a jQuery element and using it after the framework re-renders. Re-query by stable identity:
cy.get('[data-cy="save"]').click()
cy.get('[data-cy="save"]').should('be.disabled')
Understanding the boundary between queries, assertions, and actions is central to Cypress handling flaky tests. The Cypress assertions guide provides more examples of retrying chains.
5. Stabilize Selectors Around User Identity
Selectors should identify what the user or product contract means, not where an element happened to render. CSS paths based on child indexes, generated class names, or framework markup break during harmless refactors.
Use accessible role and label semantics through visible text when they are unique, or stable test attributes for controls without durable customer-facing language.
cy.contains('button', 'Create project').click()
cy.get('[data-cy="project-name"]').type('Checkout reliability')
cy.get('[data-cy="submit-project"]').click()
Scope ambiguous content to a meaningful container:
cy.contains('[data-cy="project-row"]', 'Checkout reliability')
.within(() => {
cy.contains('button', 'Open').click()
})
Do not solve duplicate matches with .first() unless first position is the requirement. It makes the test pass against whichever element happens to render first.
Dynamic IDs can be stable if they represent domain identity, such as data-project-id="42". Generated component IDs are not. Agree on a test attribute convention with frontend engineers and treat those attributes as a compatibility surface.
Selector flake sometimes reveals a real accessibility defect. If two controls have the same vague label, users of assistive technology may also struggle. Improving accessible names can improve both product quality and test stability.
6. Register Network Intercepts Before the Trigger
A common alias timeout occurs because the application sent the request before cy.intercept() registered.
// Fragile
cy.visit('/projects')
cy.intercept('GET', '/api/projects').as('projects')
cy.wait('@projects')
Register first:
cy.intercept('GET', '/api/projects*').as('loadProjects')
cy.visit('/projects')
cy.wait('@loadProjects')
.its('response.statusCode')
.should('eq', 200)
cy.get('[data-cy="project-row"]').should('be.visible')
Use a method and narrow URL so an unrelated request cannot satisfy the alias. If query values matter, use a RouteMatcher:
cy.intercept({
method: 'GET',
pathname: '/api/projects',
query: {
status: 'active',
},
}).as('loadActiveProjects')
Browser cache or a service worker can prevent a normal network request from reaching the intercept layer. Inspect the browser network tools and application caching policy rather than widening the route to match everything.
Stub only when deterministic frontend state is the goal. An intercept returning fixture data removes backend variability but also removes integration coverage. Balance controlled UI tests with direct API contracts and a smaller set of real service paths. See the Cypress cy.intercept guide for route order and request lifecycle.
7. Make Test Data Independent and Parallel-Safe
A test should create the state it needs and should not depend on a previous test. Order dependence often appears only in CI sharding or when a developer runs one spec.
Use an API or Node task for setup when UI setup is not the behavior under test:
describe('project editing', () => {
beforeEach(() => {
cy.request('POST', '/api/test-support/projects', {
name: `qa-project-${Cypress._.uniqueId()}`,
status: 'active',
}).then(({ body }) => {
cy.wrap(body.id).as('projectId')
})
})
it('renames a project', function () {
cy.visit(`/projects/${this.projectId}`)
cy.get('[data-cy="project-name"]').clear().type('Stable checkout')
cy.contains('button', 'Save').click()
cy.contains('Changes saved').should('be.visible')
})
})
In a real suite, use a run-safe unique identifier that is observable and cleanable. A process-local counter alone can collide across CI machines. Combine an approved run ID with a test-specific suffix, or let the server allocate identity.
Cleanup should be idempotent and scoped. Deleting "all projects" in afterEach can race another parallel test. Delete only the record created by the current test, or use isolated tenants that the environment resets between runs.
Avoid a shared administrator account if tests change its preferences, cart, permissions, or sessions. Create independent users or reset state through a supported endpoint. Programmatic login with cy.session() can improve speed, but validate sessions and keep mutable business data separate.
8. Control Time, Locale, Randomness, and Animations
Tests that use the real clock can fail around midnight, daylight-saving transitions, month end, expiration windows, or timezones. Freeze browser time before visiting when the application reads time during startup.
it('shows an invoice as overdue', () => {
cy.clock(new Date('2026-07-13T12:00:00Z').getTime())
cy.intercept('GET', '/api/invoices/42', {
body: {
id: 42,
dueAt: '2026-07-12T12:00:00Z',
status: 'open',
},
}).as('getInvoice')
cy.visit('/invoices/42')
cy.wait('@getInvoice')
cy.contains('Overdue').should('be.visible')
})
Use cy.tick() for application timers you intentionally control. Server clocks, database timestamps, and remote services are not changed by cy.clock(), so keep the boundary explicit.
Set locale and timezone consistently in the test environment when formatting matters. Assert machine values at API boundaries and user-visible format in a small locale matrix.
Replace uncontrolled random data with seeded builders or explicit cases. Random input can explore behavior, but the seed and generated case must be recorded to reproduce a failure. Ordinary regression tests should communicate their inputs.
Animations can delay actionability or move targets. Prefer product-level reduced-motion support in the test environment or assert stable completion. Do not globally disable behavior that the scenario specifically tests.
9. Configure Stable Test Retries Deliberately
By default, Cypress does not retry a failed test. Configure additional attempts globally or by mode:
import { defineConfig } from 'cypress'
export default defineConfig({
retries: {
runMode: 1,
openMode: 0,
},
e2e: {
baseUrl: 'http://localhost:3000',
},
})
runMode: 1 means one additional attempt after the first failure, for at most two attempts. openMode: 0 keeps local debugging immediate.
A suite or test can override retries:
describe(
'external settlement status',
{ retries: { runMode: 2, openMode: 0 } },
() => {
it('shows the settled payment', () => {
cy.visit('/payments/42')
cy.contains('[data-cy="payment-status"]', 'Settled', {
timeout: 20000,
}).should('be.visible')
})
},
)
Use the override only when the dependency policy justifies it. A permanently flaky selector does not deserve more attempts.
On each retry, Cypress reruns beforeEach and afterEach. It does not retry failures in before or after hooks. Setup must therefore be repeatable and cleanup idempotent.
Screenshots from retry attempts receive attempt suffixes. Preserve first-attempt evidence because the passing retry can hide the original state. If a test passes only after a retry, track it as flaky and prioritize a fix.
10. Use Experimental Flake Detection with Care
Current Cypress also offers experimental retry strategies. They can require multiple passing attempts or mark a test as failed whenever flake is detected. Because these options are experimental, pin the Cypress version and review release notes before enabling them across a large suite.
A pass-threshold configuration looks like this:
import { defineConfig } from 'cypress'
export default defineConfig({
retries: {
experimentalStrategy: 'detect-flake-and-pass-on-threshold',
experimentalOptions: {
maxRetries: 2,
passesRequired: 2,
},
openMode: true,
runMode: true,
},
})
With experimental strategies, openMode and runMode are explicit Booleans rather than numeric retry counts at the global level. maxRetries is the number of retries after the first failure, and passesRequired controls how many passing attempts are required for a final pass.
The alternative detect-flake-but-always-fail strategy is useful when any fail-then-pass sequence should fail the build. It creates pressure to address flake but can disrupt teams with a large existing backlog. Pilot it on a small suite.
Do not combine experimental settings with assumptions from stable numeric retries. Confirm reporter, Cypress Cloud, and CI behavior in a branch first. The strategy changes classification, not the root causes of timing, data, and state failures.
11. Diagnose Failures with First-Attempt Evidence
A useful artifact bundle answers: what command failed, what the user could see, what requests occurred, which data existed, which browser and commit ran, and whether this was a retry.
Use descriptive aliases and assertions so the Command Log reads like a product flow. Capture sanitized response codes and domain identifiers, not tokens or entire private payloads.
In open mode, inspect time-travel snapshots around the failed command. In CI, retain failure screenshots and video according to risk. Browser console errors and application logs can reveal a real rendering crash that an element timeout merely reports indirectly.
Cypress exposes Cypress.currentRetry, which is zero on the initial attempt. Use it for diagnostic labels, not to change business behavior:
it('loads the dashboard', { retries: 1 }, () => {
cy.log(`retry index: ${Cypress.currentRetry}`)
cy.visit('/dashboard')
cy.contains('h1', 'Dashboard').should('be.visible')
})
Never make the second attempt skip setup, use a different selector, or accept weaker assertions. That converts retries into alternate test logic.
Cluster failures by signature: same selector, endpoint, browser crash, tenant collision, or clock boundary. Historical flake rate from Cypress Cloud or another result store can prioritize high-frequency, high-impact cases. A raw count alone can mislead when test execution volume differs, so include run count and business criticality.
12. Quarantine Without Creating a Permanent Blind Spot
Quarantine is a temporary containment tool for a known flaky test that blocks delivery. It should have an owner, issue, reason, date, and exit condition. The quarantined test should still run in a non-blocking lane so evidence continues to accumulate.
Do not silently add .skip() and forget the test. A skipped critical path provides no signal and can remain invisible for months. Prefer a tagged job or result policy that reports the test separately.
Before quarantine, determine whether the failure might be a real product race. If customers can encounter it, lowering test authority is the wrong first response. Create an application defect and preserve the reproducing test.
Set service expectations for the flake backlog. Prioritize by customer risk, frequency, CI cost, and ownership clarity. Delete obsolete tests rather than quarantining them forever. Simplify redundant UI coverage when a faster API or component test owns the rule better.
A good exit condition is concrete: stable data factory deployed, selector contract changed, thirty scheduled runs with the same CI profile completed, and the regression assertion retained. The run count is supporting evidence, not mathematical proof.
13. Cypress Handling Flaky Tests Across Test Layers
Many E2E flakes come from testing too much through one browser path. Move deterministic logic to the lowest layer that can prove it.
| Behavior | Best primary layer | Cypress E2E role |
|---|---|---|
| Date calculation | Unit | One visible formatted result |
| File parser boundaries | Service or API | Upload and final status |
| Component loading states | Component | Integrated critical state |
| Backend validation matrix | API | Representative user error |
| Cross-service checkout | E2E | Critical owned journey |
| Browser accessibility flow | E2E or component | Keyboard and user outcome |
Cypress component testing keeps browser rendering and Cypress retry-ability while removing deployment and backend variables. Direct API tests create data faster and diagnose contract failures more precisely. E2E remains valuable for integration risks that only the assembled system exposes.
Do not move a flaky test merely to make the dashboard green. Preserve the behavior at the appropriate layer and ensure critical integrations still have coverage.
The test automation pyramid guide helps teams assign checks by feedback speed and failure clarity. A healthy suite has fewer end-to-end dependencies, strong API setup, and focused customer journeys.
14. Create a Team Flake-Reduction Workflow
Define one intake format: test name, commit, environment, browser, attempt history, first failure, artifacts, suspected category, owner, and business impact. This prevents the same failure from being rediscovered in chat.
Triage new flake daily or at a frequency matched to run volume. Fix straightforward selector, wait, and data issues immediately. Route infrastructure and product races to the correct owners with reproducible evidence.
Measure trends without rewarding hidden retries. Useful indicators include fail-then-pass tests, retries consumed, time lost, top signatures, age of open flake issues, and quarantine count. Pair rates with execution volume.
Review new tests for deterministic setup, observable waits, stable selectors, clock control, and parallel safety. A short pull request checklist catches many issues before CI history is polluted.
Finally, define a retry policy. State where retries are allowed, how flaky passes are reported, who reviews them, and when an experimental always-fail strategy is appropriate. Teams reduce flake when reliability is an owned engineering quality, not an occasional cleanup project.
Interview Questions and Answers
Q: What is the difference between retry-ability and test retries?
Retry-ability re-runs Cypress queries and attached assertions within one attempt. Test retries rerun the whole failed test, including beforeEach and afterEach.
Q: Why is cy.wait(3000) flaky?
It guesses completion time. A slower run still fails, while a faster run wastes time. Wait on an owned request or user-observable state.
Q: How do you diagnose a CI-only flake?
Reproduce the same browser, environment, shard, data, and flags, then inspect first-attempt command, network, screenshot, video, console, and infrastructure evidence.
Q: Should a flaky test have more retries?
Retries can detect and temporarily contain flake, but they do not repair it. Use a small policy, preserve flaky-pass visibility, and fix the root cause.
Q: How do you prevent data collisions?
Create isolated run-scoped records, avoid shared mutable accounts, clean only owned data, and make setup plus cleanup idempotent across retries and parallel jobs.
Q: When should you use cy.clock()?
Use it when browser behavior depends on time or timers. Remember it does not change remote service or database clocks.
Q: How do experimental retry strategies help?
They can require multiple passes or fail a test whenever inconsistent outcomes reveal flake. They improve classification but remain experimental and do not remove the underlying instability.
Common Mistakes
- Increasing global timeouts before identifying the unstable boundary.
- Replacing every failure with a fixed wait.
- Performing side effects inside a retrying
.should()callback. - Using
.first()to hide ambiguous selectors. - Registering intercepts after the request trigger.
- Sharing mutable users or records across parallel tests.
- Using the real clock for date-boundary scenarios.
- Treating a passing retry as a fully passing test.
- Changing logic or weakening assertions on later attempts.
- Debugging only the last attempt instead of the first failure.
- Skipping tests without an owner and exit condition.
- Moving behavior to a lower layer without retaining critical integration coverage.
- Blaming CI without comparing configuration and resource evidence.
Conclusion
Cypress handling flaky tests is a systems discipline: rely on query retry-ability, wait for observable outcomes, register network routes early, isolate data, control time, and preserve first-failure evidence. Configure retries as a transparent safety net, not a substitute for reliable tests.
Choose one high-frequency flaky signature, classify it, reproduce the CI conditions, repair the owned boundary, and keep a regression assertion. Repeating that workflow steadily restores trust in the suite and shortens delivery feedback.
Interview Questions and Answers
How do you approach a flaky Cypress test?
I capture the first failure and classify it as test, product, data, dependency, or infrastructure instability. I reproduce the CI profile, replace guesses with observable boundaries, repair isolation or synchronization, and retain a regression assertion.
Explain Cypress retry-ability versus test retries.
Retry-ability reruns queries and attached assertions within the same test attempt. Test retries rerun the failed it block and its beforeEach and afterEach hooks. The first helps synchronize expected UI change, while the second detects or contains inconsistent attempts.
Why are fixed waits harmful?
They encode an estimate rather than a condition. They waste time on fast runs and fail on slower ones. I wait on a network alias, enabled control, URL transition, removed loading state, or final record.
How do you make Cypress tests parallel-safe?
Each test creates run-scoped data, avoids shared mutable accounts, and cleans only records it owns. Setup and cleanup are idempotent because retries and failed attempts can repeat lifecycle hooks.
How do you configure retries without hiding defects?
I use a small run-mode allowance, no local retries by default, and reporting that marks fail-then-pass as flaky. I investigate the first attempt and never weaken assertions or change business logic on retry.
When would you quarantine a test?
Only as temporary containment for a known issue that blocks delivery and is not hiding a likely customer regression. The test keeps running separately with an owner, issue, evidence, and explicit exit criteria.
How do you choose the right layer for a flaky scenario?
I put deterministic calculations and boundary matrices in unit, component, or API tests, then keep representative integrated behavior in Cypress E2E. Moving layers must preserve the risk coverage, not merely remove a red test.
Frequently Asked Questions
Why are my Cypress tests flaky?
Common causes are fixed waits, late intercept registration, ambiguous selectors, stale elements, shared test data, real-time boundaries, service variability, and CI resource differences. Classify evidence before changing timeouts.
Does Cypress automatically retry failed tests?
Not by default. Cypress automatically retries many queries and assertions, but whole-test retries must be configured with the retries option.
What is a good Cypress retries configuration?
A common starting policy is runMode: 1 and openMode: 0. The exact value should reflect risk, CI cost, and a process that still reports fail-then-pass tests as flaky.
Should I use cy.wait with milliseconds?
Use a fixed delay only when elapsed time itself is the behavior under test. For synchronization, wait on a named request or retrying user-visible state.
How do I fix tests that pass alone but fail in a suite?
Look for shared users, server records, storage, clock changes, aliases, and order dependencies. Give each test isolated setup and idempotent cleanup.
Can cy.clock control backend time?
No. cy.clock controls browser timers in the application context. Remote services and databases need their own test-time controls or fixed API data.
What should a team do with quarantined flaky tests?
Keep them running in a visible non-blocking lane, assign an owner and issue, record the reason and date, and define an evidence-based exit condition. Do not silently skip them.