skip to content

In Cypress, why does a `while` loop around `cy.get()` hang the spec instead of retrying?

level: seniorimportance: nice to knowfreq 31%

answer

  1. The body must finish before anything runs
  2. What the loop condition is actually reading
  3. Queue length grows, nothing executes
  4. There is no error, only a dead tab
  5. Re-enter the chain from inside a callback

basics

~20 s

Because the loop never gives control back. Cypress only starts running commands once the spec body finishes, so every iteration piles more commands onto the queue, the exit condition can never flip, and the browser eventually gives out.

solid answer

~40 s

A `while` loop keeps the spec body executing, and Cypress does not begin draining its queue until that body has returned. So each pass through the loop appends another `cy.get('[data-cy=copies-available]')` and another `cy.reload()` without any of them running, the flag the condition tests is never updated, and the queue grows until the tab runs out of memory. Nothing in the loop is retried, because nothing in the loop has executed. The shape that works is **recursion driven from inside the chain**: a function that queues one attempt, and inside its `.then()` either finishes or calls itself again to append the next attempt. Each call happens while Cypress is already running, so the condition is evaluated against a page that has actually loaded.

code

javascript · 23 lines
javascript
// Recursion re-enters the chain; a while loop never lets it start.
const borrowWhenAvailable = (attemptsLeft) => {
  if (attemptsLeft === 0) {
    throw new Error('No copy of Dune came back on the shelf')
  }

  cy.get('[data-cy=copies-available]').then(($count) => {
    if (Number($count.text()) > 0) {
      cy.get('[data-cy=borrow]').click()

      return
    }

    cy.reload()
    borrowWhenAvailable(attemptsLeft - 1)
  })
}

it('borrows a copy once one is returned', () => {
  cy.visit('/catalogue/dune')
  borrowWhenAvailable(5)
  cy.get('[data-cy=loan-confirmation]').should('be.visible')
})

go deeper

for a junior

Know the rule of thumb: do not put Cypress commands inside a loop whose exit condition depends on those commands. Prefer a chained assertion, which already re-runs the query for you.

for a middle

Explain the mechanism precisely — the body records commands and returns before any of them run, so the condition is evaluated against a page that has not loaded and can never change.

for a senior

You are expected to have seen the symptom: an empty Command Log, climbing memory, a tab that dies with no test failure. Show how you recognise it and how you replace it with a bounded recursive attempt.

for a principal

Decide when a suite is allowed to retry an interaction at all. A recursive retry hides an app that needs nudging, and the standard should say when that is a legitimate model of the product and when it is masking a defect.

"Retry this until the copy comes back on the shelf" is an ordinary programming instinct, and in Cypress the ordinary implementation of it — a loop — is the one shape that cannot work. Knowing why, and knowing the shape that replaces it, is the difference between a spec that fails clearly and a browser tab that dies without an error. ## Why the loop cannot make progress Two facts collide: - Cypress **records** commands while the spec body runs and **executes** them only once that body has returned. - A `while` loop keeps the body running until its condition changes. Put together, the loop's exit condition depends on work that cannot start until the loop ends. Every iteration appends a few more commands to a queue nobody is draining. The flag being tested is still at its initial value on iteration one thousand, exactly as it was on iteration one. What you observe is characteristic and easy to mistake for something else: - **No error is raised.** The spec does not fail; it stops making progress. - **The Command Log stays empty**, because no command has run to log itself. - **Memory climbs steadily** as queued command objects accumulate. - **The tab eventually becomes unresponsive** or is killed, and the run reports a crash rather than a test failure. - The same code **reviews as correct** — there is nothing syntactically wrong with it. A `for` loop with a fixed count is a different case and is fine: it appends a known number of commands and then the body returns. The problem is specifically a loop whose condition depends on the queued work. ## Loop versus recursion | | `while` loop | recursion from inside `.then()` | |---|---|---| | When the condition is evaluated | while the body is still recording | while Cypress is running the queue | | What the page looks like then | not yet loaded | loaded, and reloaded on each attempt | | Effect of one iteration | appends commands, runs none | runs one attempt end to end | | Termination | never; the flag cannot change | on success, or on an attempt budget | | Failure mode | queue growth, then a dead tab | a clear thrown error you can read | ## The shape that works 1. Write a function that queues **one** attempt: query the page, then act on the result. 2. Put the decision **inside** the `.then()` callback, where the query has actually resolved. 3. On success, stop appending — return without queuing anything more. 4. On failure, queue whatever moves the app forward (a reload, a filter change) and **call the function again**, which appends the next attempt to the chain that is already running. 5. Carry an explicit attempt budget and throw a descriptive error when it runs out, so an unrecoverable state fails loudly instead of hanging. Because each recursive call happens while Cypress is draining the queue, its appended commands land behind the current one and run in turn. The chain grows one attempt at a time instead of a thousand at once. ## Before you reach for recursion at all Recursion is the right tool for a genuine **retry the whole interaction** problem — reload the catalogue page, look again, act. It is the wrong tool for something Cypress already does for you. Before writing it, check that the case is not one of these: - **Waiting for an element or text to appear.** A chained assertion such as `cy.get('[data-cy=copies-available]').should('have.text', '1')` already re-runs the query until it holds or the timeout expires. - **Waiting for a request to finish.** `cy.intercept()` with an alias and `cy.wait('@alias')` is a direct signal, and it is what the alias mechanism exists for. - **Waiting for something the test itself can cause.** If the spec can put the catalogue in the state it needs — return the copy through an API call before the assertion — there is nothing to retry. Recursion earns its place only when the app genuinely needs to be nudged and re-inspected repeatedly, and even then the attempt budget matters more than the loop: a spec that retries forever is a spec that will one day hang a pipeline instead of reporting a defect.

  • Is a `for` loop over a fixed list of book titles equally dangerous?
    No. A fixed-count loop appends a known number of commands and then the body returns, so the queue drains normally. The danger is specific to a loop whose exit condition depends on work that has only been queued.
  • How do you stop a recursive retry from hanging a pipeline?
    Pass an explicit attempt budget and throw a descriptive error when it reaches zero. Without one, an app stuck in the wrong state produces a spec that keeps appending attempts until the command timeout or the CI job limit kills it, with no useful message.

saying these in an interview costs you the question

  • Says the loop retries because Cypress auto-waits
  • Adds a fixed wait inside the loop body
  • Blames browser memory rather than the loop
  • Writes a recursive retry with no attempt budget
  • Reaches for recursion where an assertion already retries