Cypress 'Detached from DOM' Errors: A Diagnostic Guide
Cypress 'detached from DOM' failures are stale references, not missing elements. A cause table, ordered checks, and fixes that keep the query next to the action.
09 Sept 2026, 22:02 UTC

The failure you are looking at
Your test passes locally, then fails in CI with a message like cy.click() failed because this element is detached from the DOM. The element is not missing — Cypress found it. The problem is that the reference Cypress holds no longer points at a node in the document.
The practical takeaway: treat this as a stale-reference problem, not a timing problem. The durable fix is to keep the query and the action in the same command chain so Cypress can re-query, and to remove any test code that captures an element and uses it later.
Version note: the behavior described here reflects Cypress 9 through 13. Error wording and retry semantics have been stable, but confirm details against the docs for your installed version before relying on them.
What "detached" actually means
When Cypress runs cy.get('[data-testid="save"]'), it queries the DOM and yields a jQuery-wrapped reference to that node. If the application then re-renders and replaces the node — a React or Vue state update, a changed list key, a conditional branch that unmounts and remounts, or an animation that swaps elements — the old reference is detached. Cypress detects this and fails rather than clicking a node that is no longer on the page.
Cypress can re-run the query in some detachment cases, but the window is narrow. It cannot help when the reference has been captured in your test code, and it cannot help when the node is replaced while the action is in flight. Confirm the retry behavior for your version rather than assuming it covers your case.
Cause and first check
| What the Command Log shows | Likely cause | First check |
|---|---|---|
| Query and action in one chain, still fails | Node replaced during the action | Does the click handler remove or replace the node? |
A .then() or variable between query and action | Stale reference captured in test code | Re-query inside the callback |
| Fails only after a state change or route transition | Framework re-render replacing nodes | List keys and conditional rendering |
| Fails only in CI or under load | Slower render; animation still running | Transitions on the element or an ancestor |
Fails after cy.get('@alias').then(...) | Element captured, not re-queried | Use cy.get('@alias') fresh |
Ordered checks
- Read the Command Log. Find the failing command and note whether the query immediately precedes it. A
.then(), acy.wrap(), or a variable in between is a strong signal that the reference was captured too early. - Reproduce with a deliberate state change. Trigger the re-render that you suspect, then attempt the action. If the failure becomes reliable, you have your cause.
- Inspect the component. Does the target sit inside a conditional branch that unmounts? Does its list key change on update? Either one replaces the node.
- Check animations and transitions. A transition on the element or an ancestor can swap or repaint nodes while the action is pending, especially on slower CI machines.
- Check the test for stored elements. Search the spec for
cy.get(...).then(($el) =>and for variables assigned outside a chain.
Fixes, tied to what you found
Finding: a stored reference in test code
// Avoid: the reference is captured once and reused.
cy.get('[data-testid="row"]').then(($row) => {
cy.wrap($row).click(); // still the old node if the row re-rendered
});
// Prefer: query and act in one chain so Cypress can re-query.
cy.get('[data-testid="row"]').click();
cy.wrap($row) re-wraps the same node; it does not re-query the DOM. That distinction is the whole fix.
Finding: a re-render triggered by a prior action
cy.get('[data-testid="refresh"]').click();
cy.get('[data-testid="status"]').should('contain', 'Ready');
cy.get('[data-testid="save"]').click();
Each cy.get starts a fresh query, so the click targets the node that exists at that moment. The should assertion also retries until the status text appears, which keeps the test from racing the render.
Finding: animations or transitions
// cypress/support/e2e.js
beforeEach(() => {
cy.document().then((doc) => {
const style = doc.createElement('style');
style.setAttribute('data-cy', 'disable-animations');
style.textContent =
'*, *::before, *::after { transition: none !important; animation: none !important; }';
doc.head.appendChild(style);
});
});
This changes rendering only inside the test browser. It does not change the application build and does not persist outside the test run. If your app depends on transition-end events, verify that the test still exercises the intended path.
Finding: unstable list keys
This is an application-side fix, not a test-side one. Ask the component owner for stable keys, or for a data-testid on a node that survives the update. Re-querying in the test will keep the suite green, but the underlying re-render still churns the DOM for real users.
Finding: aliases used with .then()
cy.get('@alias') re-runs the original query. cy.get('@alias').then(($el) => ...) captures whatever node existed at that moment. Confirm alias re-query behavior for your installed version before relying on it.
What does not fix it
cy.wait(1000)before the action. It shifts the race rather than removing it; if the re-render lands after the wait, the failure returns.- Raising
defaultCommandTimeout. This extends how long Cypress waits for a query to succeed. It does not prevent a node from being replaced mid-action. { force: true }. This skips actionability checks such as visibility and disabled state. It does not make a detached node clickable.- Storing the result of
cy.get()in a variable outside the chain and reusing it later.
When to escalate
- The click handler itself removes the element (a dismiss button, a row that disappears on save). Detachment is expected here. Assert on the outcome — the row is gone, the dialog is closed — instead of clicking and then re-querying the same node.
- Re-renders replace nodes with no user-visible reason. This belongs to the team that owns the component. Stable keys or a durable
data-testidis the real fix. - The failure appears only under CI load and you cannot reproduce it locally. Capture the Command Log, video, or screenshot from the failing run before changing test code. Treat it as a render-timing issue until the evidence says otherwise.
- You have removed stored references and disabled animations and it still fails. The node is probably replaced during the action itself. That needs a different test design, not a longer timeout.
Verifying the fix
- Run the spec repeatedly, not once:
npx cypress run --spec cypress/e2e/checkout.cy.jsseveral times, or run it in CI where the original failure appeared. A single green local run is weak evidence for a race condition. - Open the Command Log or the recorded video and confirm the query command is re-executed immediately before the action. If the query still runs once and the action follows a gap, the stale reference is still there.
- Negative check: on a scratch branch, deliberately trigger a re-render of the target element immediately before the click. The test should now pass where the old pattern failed. If it does not, your fix is not addressing the detachment.
Limitations
- Retry-ability covers the query chain. It cannot rescue a node that is detached while an action is executing.
- All waits are bounded by
defaultCommandTimeout(4000 ms by default in recent versions — verify your config). A re-render that takes longer will still fail. - The animation-disabling snippet is a test-environment shim. It can hide a real user-visible flicker, so keep at least one test running with animations enabled if that matters to you.
- Framework behavior differs: React, Vue, and third-party widgets replace nodes under different conditions. The checks above are a starting point, not a complete model of your app.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.