QA Interview Hub · Knowledge base · Quizzes

Cypress

Retry-ability, waiting, timeouts

where stability comes from

What is retry-ability and what exactly is repeated?

Cypress divides commands into three groups, and they behave differently.

GroupExamplesBehaviour
Queriesget, find, contains, its, invoke, eq, filter, firstThey only read state, so it is safe to repeat them
Assertionsshould, andThey search for nothing, but they are what trigger the repeats
Actionsclick, type, check, selectThey change state, so they run once

How the retry loop works

When an assertion stands behind a query, Cypress finds the elements and checks the assertion, and if it didn't pass, after a few milliseconds it repeats both steps, the search and the check. It stops when the assertion passed or defaultCommandTimeout, 4 seconds, passed.

cy.get('[data-cy=cart-count]').should('have.text', '3');
// each moment: find the element → check the text → not 3? once more

Since Cypress 12 the whole chain of queries is repeated from the start, not only the last command. Before, Cypress could hold an old element that React or Vue had already re-rendered, and fail with the error "element is detached from the DOM". Now the element is looked up anew each time.

cy.get('[data-cy=order-list]')
  .find('[data-cy=order]')
  .first()
  .should('contain', 'Paid');
// on every attempt anew: get → find → first → should

Several assertions in a chain must pass at the same time, in one attempt:

cy.get('[data-cy=submit]')
  .should('be.visible')
  .and('not.be.disabled')
  .and('have.text', 'Pay');

Why actions aren't repeated

If click on "Pay" were retried while the page hadn't updated yet, Cypress would click a second time, and the order would be created twice. So an action runs exactly once.

But before that it waits for actionability, also up to 4 seconds. The element must be in the DOM, visible, not disabled (for type also not readonly), not animating and not covered by a modal or a loader. Before a click Cypress also scrolls the element into view. So "once" doesn't mean "blindly".

Traps you get caught on

1. .then kills retries. The callback in .then runs once.

// ❌ if the list is still loading, it fails at once
cy.get('li').then(($li) => {
  expect($li).to.have.length(3);
});

// ✅ retried
cy.get('li').should('have.length', 3);

// ✅ also retried: the callback inside should
cy.get('li').should(($li) => {
  expect($li).to.have.length(3);
});

2. An assertion right after an action. After .click() there is no query in the chain that can be restarted, so .should checks the same element that the click yielded. If the application re-rendered it, the test fails. It is more reliable to start a new chain.

// ⚠️ risky
cy.get('[data-cy=tab-stats]').click().should('have.class', 'active');

// ✅ reliable: a new chain with a query
cy.get('[data-cy=tab-stats]').click();
cy.get('[data-cy=tab-stats]').should('have.class', 'active');

3. .invoke with a side effect. It is a query, so during retries the method is called several times. For reading a value it is fine, for a function that changes something it is dangerous.

4. What isn't retried at all. cy.visit, cy.request, cy.task, cy.wait, .then have only their own timeouts, not a retry loop.

In an interview: In Cypress only queries are retried, the commands that read the DOM, like get, find or contains, together with the should that follows them. Since version twelve Cypress restarts the whole chain of queries on each attempt, so the element is looked up anew every time. Actions like click or type run once, because a repeated click could, for example, create an order twice. But before running they wait for the element to become visible, enabled, stable and not covered. Two practical things follow: I write checks with should, not then, because then isn't retried, and after an action I start a new chain with cy.get, so the assertion can look the element up again.
And in Playwright:

The mechanism is different: both actions and assertions wait. Before running, an action checks actionability and looks the element up by the locator each time, and expect(locator) is retried for up to 5 seconds. So Playwright has no split into queries and actions and no .then trap. But it has its own: expect(await locator.isVisible()).toBe(true) checks the state once and isn't retried.

Queries, actions and assertions: what is the difference?

TypeExamplesBehaviour
Queriesget, find, contains, its, invoke, eq, filterRepeated until the following assertion passes
Assertionsshould, andMake the preceding queries repeat
Actionsclick, type, check, selectWait for actionability, then run once
Othersvisit, request, wait, thenNot retried, have their own timeouts

The practical conclusion: put the assertion right after a query, not after an action. A chain .click().should(...) already checks the result of the click without clicking again.

In an interview: Queries are requests to the DOM: get, find, contains, its. They are repeated until the assertion after them passes. Assertions, should and and, are what trigger those repeats. Actions, click, type, select, wait for the element to become actionable but run once, because a repeated click could break the application's state.
And in Playwright:

There is no such split. Creating a locator does nothing, an action waits for the element to be ready, and the assertion expect(locator) is repeated. The only thing not retried is an assertion over an already obtained value: expect(await locator.isVisible()).toBe(true).

Why is cy.wait(3000) an antipattern and what to use instead?

A fixed pause is always a guess: if the application responded in half a second, the test wastes another two and a half for nothing; if it took four, the test fails. That is, the test is both slow and unstable.

Instead of a pause, wait for a concrete state: an assertion on an element or the completion of a specific request through an alias.

// ❌
cy.get('[data-cy=save]').click();
cy.wait(3000);

// ✅ wait for the state in the UI
cy.get('[data-cy=toast]').should('contain', 'Saved');

// ✅ wait for a specific request
cy.intercept('POST', '/api/profile').as('save');
cy.get('[data-cy=save]').click();
cy.wait('@save').its('response.statusCode').should('eq', 200);
In an interview: Because it is a guessed number: if the application responded faster, the test waits for nothing, and if slower, the test fails. Instead I wait for a concrete state: either an assertion on an element, which is retried itself, or the completion of a specific request through an intercept with an alias and cy.wait on that alias.
And in Playwright:

The same rule: page.waitForTimeout() is an antipattern. Instead of it use a web-first assertion or page.waitForResponse().

What timeouts does Cypress have and how do you override them?

TimeoutDefaultWhat it applies to
defaultCommandTimeout4 sQueries, assertions, actionability
requestTimeout5 sHow long cy.wait('@alias') waits for the request to be sent
responseTimeout30 sThe response to cy.request and to an intercepted request
pageLoadTimeout60 sPage load in cy.visit
taskTimeout60 scy.task

Increase a timeout locally, for a specific command, not globally, otherwise you hide the problem.

cy.get('[data-cy=report]', { timeout: 15000 }).should('be.visible');

describe('slow reports', { defaultCommandTimeout: 10000 }, () => {
  it('generates a PDF', () => { /* ... */ });
});
In an interview: The main one is defaultCommandTimeout, four seconds, for element search and assertions. For the network there is requestTimeout, five seconds for the request to be sent, and responseTimeout, thirty seconds for the response. There are also pageLoadTimeout for cy.visit and taskTimeout for cy.task. I override them locally: with the timeout option in a specific command or in a describe's config, not globally.
And in Playwright:
TimeoutDefault
The whole test (timeout)30 s
expect5 s
actionTimeoutno separate limit, bounded by the test
navigationTimeoutno separate limit, bounded by the test
globalTimeoutthe whole run

The key difference: Playwright has a timeout for the whole test, and Cypress doesn't have one by default.

A test is flaky on CI. How do you look for the cause in Cypress?

Collect evidence. A screenshot on failure is taken automatically in cypress run. Video has been off by default since Cypress 13: turn on video: true for CI. Cypress Cloud has Test Replay with the DOM, network and console.

Reproduce. Run one spec many times, for example with a Cypress._.times loop or a plugin like cypress-burn, with the same resources as on CI.

Typical causes: cy.wait(ms) instead of waiting for a state; an action before the API response arrived; shared test data between tests; animations; dependence on test order; an intercept registered after the action.

Retries, retries: { runMode: 2, openMode: 0 }, are only a temporary bandage. Every test that passed on the second attempt must end with an investigation.

Cypress._.times(20, (i) => {
  it('checkout #' + i, () => {
    // the flaky test
  });
});
In an interview: First I collect evidence: a screenshot on failure, video (it has to be turned on explicitly, because it has been off since version thirteen), and Test Replay if there is Cloud. Then I reproduce it: I run the spec dozens of times in a loop. Next I check the typical causes: hard cy.wait calls, actions before the API response, shared data, animations, an intercept registered after the action. Retries in runMode help to survive until the release, but they don't cure anything, so I investigate each such test.
And in Playwright:

The main tool is Trace Viewer: trace: 'on-first-retry' gives a timeline with DOM snapshots, network and console without a paid service. Reproduction is --repeat-each=20, and checking for competition over data is --workers=1.

Waiting and retry-ability

Cypress waits automatically: query commands like cy.get are repeated until the element is found or the timeout expires, 4 seconds by default (defaultCommandTimeout). Assertions through .should() are also retried together with the preceding query. Actions such as click and type wait for the element to become visible, not covered and not disabled.

Only the last query before an assertion is retried, and action commands like click are not repeated. So a chain should preferably be built so that the query and the check go together. A timeout can be overridden for a specific command through the timeout parameter.

cy.wait(5000) is bad, because it is a fixed pause: on a fast environment it needlessly slows the run down, and on a slow one it may still not be enough. The right way is to wait for a concrete condition: an element's visibility through should, or a network request through cy.intercept with an alias and cy.wait('@alias').