How Cypress retry-ability really works
Cypress retries commands until they pass or time out — but only some commands, and only some of the time. Understanding which is the difference between solid tests and flaky ones.
The retry-ability model in one diagram
Picture a timeline. On the left, Cypress runs a command. The command either resolves immediately or it doesn't. If it doesn't, Cypress re-runs the entire tail of the chain — not just the failing command, but everything after it — up until the assertion at the end either passes or the defaultCommandTimeout (4 seconds by default) expires.
This is the key insight: retry-ability is driven by assertions, not commands. Cypress doesn't retry cy.get() on its own. It retries cy.get() because there's an .should() further down the chain that hasn't passed yet. The assertion is what tells Cypress "we're not done yet."
If there is no assertion, the command runs once and moves on. No retry.
Queries vs actions vs assertions — which retry, which don't
Cypress splits its API into three categories, and only one of them retries by default.
Queries (cy.get, cy.find, cy.contains, cy.closest, cy.filter, cy.eq) are pure read operations. They retry as long as the assertion that follows them is still failing. The retry loop re-queries the DOM each time, which means queries always reflect the current state of the page.
Actions (cy.click, cy.type, cy.select, cy.trigger) do not retry. They run exactly once. Before running, they wait for the subject element to be visible and not disabled — that's actionability checking, not retry-ability. Once the element passes the actionability checks, the action fires. If the DOM changes after the click, Cypress doesn't click again.
Assertions (.should, .and, cy.contains used as an assertion) are what drive the retry loop. They check the subject against an expectation. If the check fails, Cypress goes back to the nearest retryable point in the chain and tries again.
// This retries: cy.get retries until .error is visible
cy.get('[data-testid="error-message"]').should('be.visible');
// This does NOT retry: click fires once
cy.get('button').click();
// This retries: get + contains retries until text appears
cy.get('.status').contains('Saved');The single-element rule
There's a subtlety with cy.get() that catches people out. When you write:
cy.get('.item').should('have.length', 3);Cypress retries the whole expression — not just the should. Each retry goes back to cy.get('.item') and re-queries. This is intentional and useful: it handles the case where the list is still loading.
But when you do:
cy.get('.item').first().should('have.text', 'Apple');The retry loop runs cy.get('.item'), then .first(), then checks the assertion. If the first item isn't 'Apple' yet, it retries from cy.get('.item') again. This is fine and expected.
Where it breaks down is when cy.get('.item') returns zero elements. The retry loop keeps firing, but .first() on an empty jQuery set returns an empty set, and the assertion fails every time. The fix is to make the selector specific enough that it returns elements when they exist, or to assert on length first.
How chains break retry-ability
The most common way to accidentally opt out of retry-ability is a .then() callback.
// Retry-ability stops here
cy.get('.price').then(($el) => {
expect($el.text()).to.equal('£9.99');
});.then() receives the subject as a value and runs synchronous JavaScript. Cypress can't retry through a .then() because the callback might have side effects. Once you're inside .then(), you're in "run once" territory.
The version that retries properly:
cy.get('.price').should('have.text', '£9.99');Same check, but .should() is part of the retry loop. Cypress will keep re-querying .price and re-checking the text until it matches or times out.
The same problem applies to .each():
// Does NOT retry if items aren't loaded yet
cy.get('.item').each(($item) => {
cy.wrap($item).should('be.visible');
});By the time .each() runs, the number of items is fixed. If the list was empty when the assertion first ran, .each() doesn't iterate, and the test passes vacuously — a silent false positive.
Debugging retry failures with cy.log and the time travel debugger
When a command times out, the error message tells you what failed but not why it kept failing. The two best tools for this are cy.log() and the time travel UI.
cy.log() inside a retry loop runs on every retry attempt, so you can watch the state evolving:
cy.get('[data-testid="status"]')
.should(($el) => {
cy.log(`Current text: ${$el.text()}`);
expect($el.text()).to.equal('Ready');
});The callback form of .should() is key here — it retries the whole callback, and each retry logs the current text. You can see exactly what the element contained on each attempt.
The time travel debugger is the Cypress UI feature that lets you click any command in the log and see a DOM snapshot at that point in time. For retry failures, focus on the snapshot just before the timeout. You're usually looking at one of three things: the element doesn't exist yet, the element exists but has wrong text, or the element is there but not visible. Each leads to a different fix.
For persistent flakiness in CI where the time travel UI isn't available, cy.screenshot() inside a .should() callback gives you a PNG of the DOM state at each retry:
cy.get('[data-testid="modal"]').should(($modal) => {
if (!$modal.is(':visible')) {
cy.screenshot(`modal-not-visible-${Date.now()}`);
}
expect($modal).to.be.visible;
});This is a debugging pattern, not a production one — remove it once you've found the root cause. The goal is always to make the test fast and deterministic, not to collect screenshots of it failing.
// related
How Playwright's auto-waiting actually works
Cypress retries commands; Playwright auto-waits on actionability. Same problem, different solution. Here's what Playwright is actually doing when you call .click().
The week our flaky-test rate dropped from 18% to 2%
Our CI was failing 18% of runs to flakes we'd stopped looking at. One week, four changes, no new tests. Here's what we actually did.