Skip to content
Independent guides for QA & test automationRSSEditorial policy
QA Vibes

Cypress "failed because this element is detached from the DOM": The Messages That Replaced It, and the Fixes

"cy.click() failed because this element is detached from the DOM" no longer exists: Cypress 12.0.0 replaced it with two messages about the page updating. We reproduced both in Cypress 16.1.1, on Sauce Demo and on two one-file local pages: chained actions, a wrapped element kept too long, and a button that disappeared while covered. Why the alias the error suggests failed for a button that changed, and why Cypress could click a button replaced every 20 ms.

QA Vibes EditorialPublished 9 minTested with Cypress 16.1.1, Electron 146, Chrome 154, Node.js 20.20.2, Windows 11Revision history ↓

Key takeaways

  • The message people search for is from Cypress 11 and earlier. Since 12.0.0, Cypress says "failed because the page updated as a result of this command" or "while this command was executing" instead.
  • Chaining an action onto an action that re-renders, like select().select() or click().click(), failed every time on Sauce Demo. Start each action from its own cy.get().
  • The alias the error message suggests only helps when the same selector still matches. After Add to cart, the alias looked for a button that was now Remove and failed after 4 seconds.
  • cy.wrap() of an element kept from before the change can't be re-queried, and failed even on a should().
  • Cypress clicked a button replaced every 20 ms in 10 of 10 tries, because it re-runs the query while it waits. It fails only when the element is really gone.
Contents (9 sections)

Introduction

"cy.click() failed because this element is detached from the DOM" is one of the most searched Cypress errors, and you won't see it in a current version of Cypress. Cypress 12.0.0 changed how commands find elements, and replaced that message with two new ones. Many of the answers that rank for it were written for the old behaviour.

This page shows what the current messages say, what triggers each of them, and which fixes held up. It was run on 13 October 2026 with Cypress 16.1.1 on Windows 11, headless, in the default Electron 146 browser and again in Chrome 154 with the same results, against Sauce Demo and two one-file local pages.

What changed in Cypress 12

In Cypress's own source, the error for a detached element in v11.2.0 reads:

${cmd(obj.cmd)} failed because this element is detached from the DOM.
...
This DOM element likely became detached somewhere between the previous and current command.

In v12.0.0 that entry is gone, and two others take its place, both linking to the same documentation page:

  • detached_after_command: "failed because the page updated as a result of this command, but you tried to continue the command chain."
  • detached_during_actionability: "failed because the page updated while this command was executing."

Both are from packages/driver/src/cypress/error_messages.ts at the v11.2.0 and v12.0.0 tags of the Cypress repository. The Cypress 16.1.1 runner we installed has only the two new messages; the old sentence appears nowhere in it.

The reason for the change is in the retry-ability guide. Since 12.0.0, queries such as cy.get(), cy.find() and cy.contains() are re-run when the element they found is replaced, so a chain of queries finds the new element by itself. Actions such as .click(), .type() and .select() are not queries. They act once, and the element they yield is the one they acted on, even if the action made the app replace it.

Sauce Demo: what a sort and a click replace

Sauce Demo is a React app. Choosing a sort order replaces the sort dropdown and every "Add to cart" button with new elements, and clicking "Add to cart" replaces that button with a "Remove" button. (We checked by keeping a reference to each element and reading its isConnected property after the action; the StaleElementReferenceException article has the details.)

The config:

const { defineConfig } = require("cypress");
 
module.exports = defineConfig({
  e2e: {
    baseUrl: "https://www.saucedemo.com",
    supportFile: false,
    video: false,
    screenshotOnRunFailure: false,
  },
});

And cypress/e2e/detached.cy.js, four mistakes and two fixes:

// Sauce Demo publishes this password on its own login page.
const PASSWORD = Cypress.expose("SAUCE_PASSWORD") ?? "secret_sauce";
 
beforeEach(() => {
  cy.visit("/");
  cy.get("[data-test='username']").type("standard_user");
  cy.get("[data-test='password']").type(PASSWORD, { log: false });
  cy.get("[data-test='login-button']").click();
  cy.get("[data-test='inventory-item-name']").should("have.length", 6);
});
 
it("chains a second select onto the first", () => {
  cy.get("[data-test='product-sort-container']").select("lohi").select("hilo");
  cy.get("[data-test='inventory-item-name']").first().should("have.text", "Sauce Labs Fleece Jacket");
});
 
it("chains a second click onto the first", () => {
  cy.get("[data-test='add-to-cart-sauce-labs-backpack']").click().click();
  cy.get("[data-test='shopping-cart-badge']").should("not.exist");
});
 
it("wraps an element kept from before the change", () => {
  cy.get("[data-test='add-to-cart-sauce-labs-backpack']").then(($button) => {
    cy.get("[data-test='add-to-cart-sauce-labs-backpack']").click();
    cy.wrap($button).should("have.text", "Remove");
  });
});
 
it("follows the error's advice: an alias for the button", () => {
  cy.get("[data-test='add-to-cart-sauce-labs-backpack']").as("button");
  cy.get("@button").click();
  cy.get("@button").click();
  cy.get("[data-test='shopping-cart-badge']").should("not.exist");
});
 
it("sorts twice through an alias", () => {
  cy.get("[data-test='product-sort-container']").as("sort");
  cy.get("@sort").select("lohi");
  cy.get("@sort").select("hilo");
  cy.get("[data-test='inventory-item-name']").first().should("have.text", "Sauce Labs Fleece Jacket");
});
 
it("starts each action from a query, and clicks the button that is there", () => {
  cy.get("[data-test='product-sort-container']").select("lohi");
  cy.get("[data-test='product-sort-container']").select("hilo");
  cy.get("[data-test='inventory-item-name']").first().should("have.text", "Sauce Labs Fleece Jacket");
  cy.get("[data-test='add-to-cart-sauce-labs-backpack']").click();
  cy.get("[data-test='remove-sauce-labs-backpack']").click();
  cy.get("[data-test='shopping-cart-badge']").should("not.exist");
});

One thing changed under us while writing it: our first draft read the password with Cypress.env("SAUCE_PASSWORD"), and Cypress 16.1.1 refused to run the spec: "Cypress.env() was removed in Cypress version 16.0.0. Please update to use Cypress.expose() for non-sensitive values, or cy.env() for sensitive values." Sauce Demo's password is published on its login page, so Cypress.expose() is the right one here.

We ran the spec three times in Electron and once in Chrome. Every run gave the same result:

  1) chains a second select onto the first
  2) chains a second click onto the first
  3) wraps an element kept from before the change
  4) follows the error's advice: an alias for the button
  √ sorts twice through an alias (1069ms)
  √ starts each action from a query, and clicks the button that is there (1103ms)

1 and 2. An action chained onto an action

select("lohi").select("hilo") failed at once:

CypressError: `cy.select()` failed because the page updated as a result of this command, but you tried to continue the command chain. The subject is no longer attached to the DOM, and Cypress cannot requery the page after commands such as `cy.select()`.
 
Common situations why this happens:
  - Your JS framework re-rendered asynchronously
  - Your app code reacted to an event firing and removed the element
 
You can typically solve this by breaking up a chain. For example, rewrite:
 
> `cy.get('button').click().should('have.class', 'active')`
 
to
 
> `cy.get('button').as('btn').click()`
> `cy.get('@btn').should('have.class', 'active')`
 
https://on.cypress.io/element-has-detached-from-dom

The first select() sorted the list, Sauce Demo rendered a new dropdown, and the second select() received the old one as its subject. click().click() on the Add to cart button failed with the same message for cy.click(). This is the case the new message describes well: the command named in it caused the update.

3. A wrapped element kept too long

The third test kept the jQuery element from a .then(), clicked the button with a fresh cy.get(), and then asserted on the kept element with cy.wrap($button). It failed after the 4-second retry:

CypressError: Timed out retrying after 4000ms: `cy.should()` failed because the page updated as a result of this command, but you tried to continue the command chain. The subject is no longer attached to the DOM, and Cypress cannot requery the page after commands such as `cy.should()`.

Two things are misleading here. The message blames cy.should(), which didn't update anything; the click did, one command earlier. And it says Cypress can't re-query "after commands such as cy.should()", when the real reason is cy.wrap(): a wrapped element has no query behind it, so there's nothing to re-run. When this message names an assertion or a command that can't change the page, look for a cy.wrap() of an element you kept from a .then() callback.

4. The alias the error suggests

The message's own advice is to break the chain with an alias: cy.get('button').as('btn').click(), then cy.get('@btn'). Since Cypress 12, an alias of a query re-runs that query, which is why the fifth test, sorting twice through @sort, passed: the selector still matched the new dropdown.

For the Add to cart button it didn't help:

AssertionError: Timed out retrying after 4000ms: Expected to find element: `[data-test='add-to-cart-sauce-labs-backpack']`, but never found it.

The alias re-ran cy.get("[data-test='add-to-cart-sauce-labs-backpack']"), and after the first click there was no such button: Sauce Demo had replaced it with [data-test='remove-sauce-labs-backpack']. An alias re-finds the same selector. When the action turns the element into a different one, the test has to ask for the different one, as the last test does.

When it happens during the command

The second message appears when the element disappears while Cypress is still waiting to act on it. To trigger it, vanish.mjs serves a Save button under a loading overlay; after 500 ms the overlay goes, and the button is replaced by a "Saved" message:

import http from "node:http";
// A Save button under a loading overlay. After 500 ms the overlay goes and the
// Save button is swapped for a "Saved" message, so Save is never clickable.
http.createServer((req, res) => {
  res.setHeader("content-type", "text/html");
  res.end(`<!doctype html><title>Vanish</title>
<div id="box"><button>Save</button></div>
<div id="overlay" style="position:fixed;inset:0;background:rgba(0,0,0,.3)">Loading…</div>
<script>
  setTimeout(() => {
    document.getElementById("overlay").remove();
    document.getElementById("box").innerHTML = "<p>Saved</p>";
  }, 500);
</script>`);
}).listen(4324, () => console.log("vanish app on http://localhost:4324"));
it("clicks a button that disappears while it is covered", () => {
  cy.visit("http://localhost:4324/");
  cy.contains("button", "Save").click();
});

Cypress found the button, waited because the overlay covered it, and then lost it:

CypressError: Timed out retrying after 4050ms: `cy.click()` failed because the page updated while this command was executing. Cypress tried to locate elements based on this query:
 
> cy.contains(button, Save)
 
We initially found matching element(s), but while waiting for them to become actionable, they disappeared from the page.

The message goes on to suggest breaking up a chain, which doesn't apply: there was no chain. The button really was gone, and no way of finding it would have worked. In a real app this is a test that clicks too early (here, before loading finished) or too late (after the app already moved on). Assert the state the user would wait for, such as the overlay being gone, before the click, or check what the app shows instead.

Re-rendering alone doesn't trigger it

To check whether re-rendering by itself causes either error, we reused the page from our Playwright version of this error, which replaces its Save button with an identical new one every N milliseconds, and clicked it with cy.contains("button", "Save").click() ten times at each rate:

// The re-render app replaces its Save button every N ms.
for (const every of [20, 50, 100, 1000]) {
  describe(`button replaced every ${every} ms`, () => {
    for (let run = 1; run <= 10; run++) {
      it(`clicks Save, run ${run}`, () => {
        cy.visit(`http://localhost:4323/?every=${every}`);
        cy.contains("button", "Save").click();
        cy.get("[role=status]").should("have.text", "1");
      });
    }
  });
}

All 40 passed, and every click was counted, even with a new button every 20 ms. When the element Cypress was waiting on was replaced, it re-ran the query and acted on the new one. (Playwright, against the same page, couldn't click at 20 ms at all: it waits for the element to stay in place for two animation frames first.) So in Cypress 12 and later, neither message means "the app re-renders". The first means you continued a chain past an action that changed the page; the second means the element was gone for good while Cypress waited.

How to fix it

  • Start each action from a query. cy.get(...).select(...) on its own line, then cy.get(...) again for the next action. Don't chain an action onto an action that changes the page, and don't chain an assertion onto one either if you need the new element.
  • Ask for the element that's there now. After "Add to cart", click [data-test='remove-sauce-labs-backpack'], not the old button by another name. An alias helps only while the same selector still matches.
  • Don't cy.wrap() elements across a change. The element passed into a .then() callback is fixed; wrapping it after the page changed gives Cypress nothing to re-query. Use a selector instead.
  • Wait for the state, not the element. If an element disappears while Cypress waits for it, assert what the page should show before acting: the spinner gone, the list loaded, the form enabled.

Our Cypress test reviewer flags both chained lines in the spec above (eslint-plugin-cypress calls the rule unsafe-to-chain-command), and checks for fixed waits, forced actions and APIs removed in Cypress 16, such as Cypress.env().

Exercise

Add two products to the Sauce Demo cart, open the cart, and remove the first one with cy.get("[data-test^='remove-']").first().click(). Predict whether removing a second product with cy.get("[data-test^='remove-']").first().click() again works, and whether cy.get("[data-test^='remove-']").first().click().click() does, then run both. Which message does the second one give, and which command does it name?

For the Selenium version of this error, see StaleElementReferenceException. If the element was covered rather than replaced, the click error is different; see element click intercepted.

Conclusion

Since Cypress 12.0.0, "this element is detached from the DOM" has become two messages. "The page updated as a result of this command, but you tried to continue the command chain" means an action changed the page and the chain kept using the old element: start the next action from a fresh query, and ask for the element that's there now. "The page updated while this command was executing" means the element disappeared while Cypress waited for it: assert the state the page should be in first. The message's own advice, an alias, only works when the same selector still matches. Re-rendering by itself didn't trigger either error in our runs.

Sources and further reading

Tools mentioned

CypressUI AutomationOpen source

Links go to each tool’s official site. How we choose and link tools

Revision history

First published.

Spotted a mistake? Report it — corrections land here.

Written and reviewed by

QA Vibes Editorial

Articles are written and reviewed by practicing QA and automation engineers. Every article lists its sources and shows when it was last updated.

Check your own tests

Cypress test reviewer

Paste a Cypress spec to find fixed waits, missing assertions, and APIs Cypress 16 removed.