skip to content

Limits of Retrying

Retrying cures a narrow class of timing bug and nothing else. Interviewers ask because a candidate who trusts it everywhere writes tests that fail in ways they cannot explain.

on this pageshow

explore

questions

5

In Cypress, how many times does a .then() callback run while a later assertion retries?

level: middleimportance: must knowfreq 62%

answer

  1. Not every step in a chain re-runs
  2. Which steps count as queries?
  3. A callback is not a query
  4. The yielded value is frozen
  5. Callback assertions retry as one unit

basics

~10 s

Exactly once. .then() is not a query, so Cypress never re-runs it or anything before it; an assertion after it keeps retrying against the single value the callback already yielded, then times out.

solid answer

~40 s

`.then()` is not a query, so it executes once and breaks the chain of retryable steps. When a `.should()` after it fails, Cypress retries only from the `.then()` result onward — it does not re-read the DOM, re-run `.invoke('text')`, or call the callback again. That is why `cy.get('[data-testid="copies-available"]').invoke('text').then(parseFloat).should('be.gte', 1)` fails against a count the catalogue renders a second later: `parseFloat` ran once on the placeholder, yielded `NaN`, and the assertion retries against `NaN` until the timeout. The fix is to move the work that must be repeated into a `.should(callback)`, whose whole body — including the query chain leading to it — is retried until no expectation inside it throws.

code

javascript · 14 lines
javascript
// Catalogue fills in the copies counter about a second after load.

// WRONG: .then() runs once on the placeholder and yields NaN forever.
cy.get('[data-testid="copies-available"]')
  .invoke('text')
  .then(parseFloat)
  .should('be.gte', 1)

// RIGHT: everything inside .should(callback) is retried together.
cy.get('[data-testid="copies-available"]').should(($span) => {
  const copies = parseFloat($span.text())
  // Chai's expect, retried until it stops throwing
  expect(copies).to.be.gte(1)
})

go deeper

for a junior

Remember the one-line rule: .then() runs once. If a value needs re-reading until the application settles, it cannot be read inside .then().

for a middle

Be ready to walk a chain step by step and say what each retry actually re-evaluates, and to rewrite the failing chain as a .should() with a callback.

for a senior

Expect to diagnose this from a timeout alone in someone else's suite, and to explain why the same test passes locally and fails on a slower machine.

for a principal

Set the convention: which reshaping is allowed in test code at all, and whether values that need parsing before assertion should be exposed by the application instead.

## Queries retry; `.then()` does not Cypress splits what you chain off `cy` into kinds, and retry-ability follows that split. **Queries** such as `cy.get()`, `.find()` and `.eq()` link together and re-run as a unit. `.then()` is not one of them: it is a callback step that executes **once**, when the previous step yields it a subject. Nothing before a `.then()` re-runs after it has executed, and the callback itself is never invoked a second time on behalf of a later assertion. That is the whole mechanism behind a failure that reads like a Cypress bug and is not one. ## Walking a chain that cannot pass A library catalogue renders a placeholder in the copies counter and fills in the real number after a request comes back roughly a second later: ```javascript cy.get('[data-testid="copies-available"]') // the <span>, containing a dash .invoke('text') // '-' .then(parseFloat) // NaN .should('be.gte', 1) // retries against NaN, then fails ``` | Step | First execution | On each retry of the final assertion | |---|---|---| | `cy.get('[data-testid="copies-available"]')` | yields the `<span>` | not re-run | | `.invoke('text')` | yields the placeholder text | not re-run | | `.then(parseFloat)` | yields `NaN` | not re-run | | `.should('be.gte', 1)` | fails | re-evaluated against the same `NaN` | The assertion is doing its job perfectly. It retries for the full `defaultCommandTimeout`, and every retry compares the *same frozen value*, because the steps that could have produced a fresher one are behind a `.then()` that already ran. ## The fix: put the work inside `.should(callback)` `.should()` accepts a callback, and Cypress retries the entire chain leading up to it plus every expectation inside it until none of them throws: ```javascript cy.get('[data-testid="copies-available"]').should(($span) => { const copies = parseFloat($span.text()) // re-read on every attempt expect(copies).to.be.gte(1) // Chai's expect }) ``` Two constraints on that callback are worth knowing: - It must be **side-effect free**, because it will run many times. - You **cannot invoke Cypress commands inside it** — no `cy.get()`, no `cy.log()`. Put commands before or after the `.should()`. ## Where this bites in real suites - **Parsing or reshaping a value** — `.invoke('text').then(Number)`, trimming a currency symbol, splitting a "3 of 12" label. All of it freezes at the moment `.then()` ran. - **Asserting on a spy or stub inside `.then()`** — the callback fires before the application has finished calling it. Alias the spy with `.as()` and assert through `cy.get('@alias')` instead, so the assertion retries. - **Doing several actions on an element captured in a `.then()` closure** — the captured element can be replaced by a re-render between them, and `.then()` adds no protection against that. - **Branching on what the page currently shows** — a `.then()` sees one snapshot of the application, taken once, at whatever moment the queue reached it. ## The same chain without the `.then()` Delete the callback and the chain retries end to end, because everything left in it is a query followed by an assertion: ```javascript cy.get('[data-testid="copies-available"]') .invoke('text') .should('match', /^[1-9]/) // Chai's match; the whole chain re-runs ``` A failing assertion here sends Cypress back to `cy.get()`, which re-reads the element, and `.invoke('text')` reads the current text again on every attempt. The only difference between this chain and the one that can never pass is the `.then()` sitting in the middle of it — which is also the cheapest way to convince yourself where the boundary is. Remove the callback and watch the same assertion start passing. When the reshaping genuinely cannot be expressed as an assertion on the raw value — parsing, arithmetic, comparing two numbers read from the page — the callback form of `.should()` is the tool for it, not `.then()`. ## Rules of thumb 1. If the value must be re-read until it settles, it belongs **inside** a `.should(callback)`, not behind a `.then()`. 2. Use `.then()` for work that is genuinely one-shot: reading a value you will pass to another command, or asserting on something that is already final. 3. When a chain "won't wait", look for the first non-query step in it before you reach for a longer timeout — a longer timeout only means retrying the same frozen value for longer.

  • Why does raising the timeout on that chain not help?
    A longer timeout buys more retries of the assertion, but each retry compares the same value `.then()` yielded once. Nothing upstream re-runs, so more time simply means failing later. Only moving the parsing into a `.should(callback)` — or splitting the chain — changes what is re-evaluated.
  • Can you call Cypress commands inside a .should() callback to re-query?
    No. Cypress commands are not allowed inside a `.should(callback)`; the callback is retried many times and must stay side-effect free. Query the element with `cy.get()` before the `.should()`, then do the reshaping and the expectations inside the callback.

saying these in an interview costs you the question

  • Believes .then() re-runs when a later assertion fails
  • Raises the timeout instead of moving the work into .should()
  • Asserts on a stub inside .then() rather than through an alias
  • Thinks .then() adds waiting or retry protection to a chain
  • Calls cy commands inside a .should() callback
open as a page

A Cypress test that borrows the last copy of a book passes, then fails on every retry. Why?

level: seniorimportance: must knowfreq 42%

basics

~20 s

A Cypress retry repeats only the test and its beforeEach and afterEach hooks. Suite-level seeding in a before hook is not repeated, and the backend is not rewound, so the second attempt runs against a catalogue whose last copy is already on loan.

open as a page

In Cypress, what do retries.runMode and retries.openMode control, and what are their defaults?

level: juniorimportance: should knowfreq 55%

basics

~20 s

They set how many extra attempts Cypress gives a failing test: runMode applies to cypress run, openMode to cypress open. Both default to 0, so an unconfigured suite never retries a failing test at all.

open as a page

In Cypress, why can a .should() in the middle of a chain leave the next query with a stale element?

level: seniorimportance: should knowfreq 46%

basics

~20 s

A mid-chain assertion is a retry boundary. Once it passes, Cypress locks in the subject at that point, so later queries retry from that element instead of re-querying from the top - and a re-render leaves it detached.

open as a page

Should a Cypress test behave differently when Cypress.currentRetry is greater than zero?

level: principalimportance: nice to knowfreq 22%

basics

~20 s

Almost never for anything that changes what is asserted. Branching on the attempt number makes the retry a different test, so a green second attempt no longer tells you the first failure was environmental. Extra diagnostics are the defensible exception.

open as a page