Introduction
"Error: elementHandle.click: Element is not attached to the DOM" means Playwright tried to act on a node the page had already removed. Frameworks such as React, Vue and Angular do that all the time: when state changes, they often throw away a button or a list item and render a new one that looks the same. A reference to the old node is now a reference to nothing.
The usual answer is "use locators, not element handles", and it's mostly right. This page measures how right. It reproduces the error on Sauce Demo, then runs every kind of call against a local page that replaces its button on a timer, to see which calls can still hit the error. Everything was run on 13 October 2026 with Playwright 1.63.0, its Chromium 153, and Node.js 20.20.2. Selenium's version of this error, StaleElementReferenceException, has its own page with the same Sauce Demo examples.
What Sauce Demo replaces
We kept a reference to each element, acted, and checked the element's isConnected property afterwards:
- Choosing a sort order replaced the sort dropdown and every "Add to cart" button.
- Clicking "Add to cart" replaced that button with a new "Remove" button.
- The product cards and names were not replaced by sorting. React moved the same nodes into the new order.
Element handles: the error on Sauce Demo
This spec uses element handles the way older Playwright code, and code ported from Puppeteer, often does. The config sets testIdAttribute: "data-test" for Sauce Demo and an actionTimeout of 10 seconds. Save it as tests/handles.spec.ts:
import { test, expect } from "@playwright/test";
// Sauce Demo publishes this password on its own login page.
const PASSWORD = process.env.SAUCE_PASSWORD ?? "secret_sauce";
test.beforeEach(async ({ page }) => {
await page.goto("https://www.saucedemo.com/");
await page.getByPlaceholder("Username").fill("standard_user");
await page.getByPlaceholder("Password").fill(PASSWORD);
await page.getByRole("button", { name: "Login" }).click();
await expect(page.getByTestId("inventory-item-name")).toHaveCount(6);
});
test("sorts twice with one handle", async ({ page }) => {
const sort = await page.$("[data-test='product-sort-container']");
await sort!.selectOption("lohi");
await sort!.selectOption("hilo");
await expect(page.getByTestId("inventory-item-name").first()).toHaveText("Sauce Labs Fleece Jacket");
});
test("adds then removes with one handle", async ({ page }) => {
const button = await page.$("[data-test='add-to-cart-sauce-labs-backpack']");
await button!.click();
await button!.click();
await expect(page.getByTestId("shopping-cart-badge")).toHaveCount(0);
});
test("a handle taken from a locator", async ({ page }) => {
const sort = await page.getByTestId("product-sort-container").elementHandle();
await sort!.selectOption("lohi");
await sort!.selectOption("hilo");
await expect(page.getByTestId("inventory-item-name").first()).toHaveText("Sauce Labs Fleece Jacket");
});
test("handles from $$, read after sorting", async ({ page }) => {
const names = await page.$$("[data-test='inventory-item-name']");
await page.getByTestId("product-sort-container").selectOption("lohi");
expect(await names[0].textContent()).toBe("Sauce Labs Onesie");
});All four failed, in all three of our runs:
x 4 tests\handles.spec.ts:14:5 › sorts twice with one handle (1.1s)
x 5 tests\handles.spec.ts:21:5 › adds then removes with one handle (1.1s)
x 6 tests\handles.spec.ts:28:5 › a handle taken from a locator (1.0s)
x 7 tests\handles.spec.ts:35:5 › handles from $$, read after sorting (1.1s)The first three with the error:
Error: elementHandle.selectOption: Element is not attached to the DOM
Call log:
- attempting select option action
- waiting for element to be visible and enabled
15 | const sort = await page.$("[data-test='product-sort-container']");
16 | await sort!.selectOption("lohi");
> 17 | await sort!.selectOption("hilo");
| ^The first selectOption worked and made the app render a new dropdown; the second was made on the old one. The button test failed the same way at the second click, elementHandle.click: Element is not attached to the DOM. Note the third test: locator.elementHandle() gives you a handle too. Starting from a locator doesn't help once you've turned it into a handle.
The fourth didn't throw. The name nodes were moved, not replaced, so the handles were still attached, and the first one was still the Backpack:
Error: expect(received).toBe(expected) // Object.is equality
Expected: "Sauce Labs Onesie"
Received: "Sauce Labs Backpack"A list of handles keeps the order it was found in. So a handle that survives a re-render can be worse than one that doesn't: it fails on a wrong value instead of an error that names the problem.
Locators: the same steps
tests/fixed.spec.ts does the same things with locators:
import { test, expect } from "@playwright/test";
// Sauce Demo publishes this password on its own login page.
const PASSWORD = process.env.SAUCE_PASSWORD ?? "secret_sauce";
test.beforeEach(async ({ page }) => {
await page.goto("https://www.saucedemo.com/");
await page.getByPlaceholder("Username").fill("standard_user");
await page.getByPlaceholder("Password").fill(PASSWORD);
await page.getByRole("button", { name: "Login" }).click();
await expect(page.getByTestId("inventory-item-name")).toHaveCount(6);
});
test("sorts twice with one locator", async ({ page }) => {
const sort = page.getByTestId("product-sort-container");
await sort.selectOption("lohi");
await sort.selectOption("hilo");
await expect(page.getByTestId("inventory-item-name").first()).toHaveText("Sauce Labs Fleece Jacket");
});
test("adds, then removes with the button that replaced it", async ({ page }) => {
const backpack = page.getByTestId("inventory-item").filter({ hasText: "Sauce Labs Backpack" });
await backpack.getByRole("button", { name: "Add to cart" }).click();
await expect(page.getByTestId("shopping-cart-badge")).toHaveText("1");
await backpack.getByRole("button", { name: "Remove" }).click();
await expect(page.getByTestId("shopping-cart-badge")).toHaveCount(0);
});
test("reads the names after sorting", async ({ page }) => {
await page.getByTestId("product-sort-container").selectOption("lohi");
await expect(page.getByTestId("inventory-item-name").first()).toHaveText("Sauce Labs Onesie");
});All three passed in each of three runs:
ok 1 tests\fixed.spec.ts:14:5 › sorts twice with one locator (1.2s)
ok 2 tests\fixed.spec.ts:21:5 › adds, then removes with the button that replaced it (1.1s)
ok 3 tests\fixed.spec.ts:29:5 › reads the names after sorting (1.0s)A locator is a description of how to find an element, not the element. Each call finds it again, so the second selectOption used the new dropdown. The second test also shows the other half of the fix: after "Add to cart", the element to click is a different button, "Remove", and the test asks for that one. Clicking the "Add to cart" locator a second time doesn't throw "not attached"; it waits for an "Add to cart" button that no longer exists, and in our run failed with locator.click: Timeout 10000ms exceeded.
Can a locator still hit it?
Sauce Demo replaces elements once per action, so a locator found the new element every time. To see what happens when the element is replaced while Playwright works on it, we made a page that replaces its Save button with an identical new one every N milliseconds, as a framework re-rendering on a timer would. Save it as rerender.mjs and start it with node rerender.mjs:
import http from "node:http";
// /?every=N replaces the Save button with an identical new one every N ms, as a re-rendering framework would.
http.createServer((req, res) => {
const every = Number(new URL(req.url, "http://localhost").searchParams.get("every") ?? 50);
res.setHeader("content-type", "text/html");
res.end(`<!doctype html><title>Re-render</title>
<div id="box"><button>Save</button></div><p role="status">0</p>
<script>
let clicks = 0;
const make = () => { const b = document.createElement("button"); b.textContent = "Save";
b.onclick = () => (document.querySelector("[role=status]").textContent = String(++clicks)); return b; };
document.querySelector("#box").replaceChildren(make());
setInterval(() => document.querySelector("#box").replaceChildren(make()), ${every});
</script>`);
}).listen(4323, () => console.log("re-render app on http://localhost:4323"));This script, churn.mjs, makes each call 40 times at each rate and counts the failures:
// Runs each call 40 times against a Save button that is replaced every N ms,
// and counts how many failed and with what message.
import { chromium } from "playwright";
const browser = await chromium.launch();
const page = await browser.newPage();
const save = () => page.getByRole("button", { name: "Save" });
const calls = {
"locator.click()": () => save().click({ timeout: 3000 }),
"locator.screenshot()": () => save().screenshot({ timeout: 3000 }),
"locator.scrollIntoViewIfNeeded()": () => save().scrollIntoViewIfNeeded({ timeout: 3000 }),
"page.$() then click()": async () => (await page.$("button")).click({ timeout: 3000 }),
};
for (const every of [20, 50, 100, 250, 1000]) {
await page.goto(`http://localhost:4323/?every=${every}`);
for (const [name, call] of Object.entries(calls)) {
const failures = {};
for (let i = 0; i < 40; i++) {
try {
await call();
} catch (error) {
const message = error.message.split("\n")[0];
failures[message] = (failures[message] ?? 0) + 1;
}
}
console.log(`every ${every} ms | ${name} | ${JSON.stringify(failures)}`);
}
}
await browser.close();The results, failures out of 40 at each rate:
| Call | 20 ms | 50 ms | 100 ms | 250 ms | 1,000 ms |
|---|---|---|---|---|---|
locator.click() |
40, all timeouts | 0 | 0 | 0 | 0 |
locator.screenshot() |
40 | 34 | 11 | 9 | 1 |
locator.scrollIntoViewIfNeeded() |
40 | 33 | 19 | 5 | 1 |
page.$() then click() |
40 | 33 | 20 | 7 | 2 |
Every failure in the last three rows was "Element is not attached to the DOM", as locator.screenshot: Element is not attached to the DOM, locator.scrollIntoViewIfNeeded: Element is not attached to the DOM and elementHandle.click: Element is not attached to the DOM. The handle in the last row was found fresh with page.$() immediately before each click, and it still went stale before the click landed in 2 of 40 calls at one re-render a second.
Locator clicks retried, and never threw it
locator.click() never threw "not attached" at any rate. When the node it had found was replaced mid-click, it started over. Its call log at 20 ms shows it:
locator.click: Timeout 2000ms exceeded.
Call log:
- waiting for getByRole('button', { name: 'Save' })
2 × locator resolved to <button>Save</button>
- attempting click action
- waiting for element to be visible, enabled and stable
- element was detached from the DOM, retrying
2 × locator resolved to <button>Save</button>
- attempting click action
- waiting for element to be visible, enabled and stable
- element is not stable
- retrying click action"Element was detached from the DOM, retrying" is the same event that throws for a handle, handled for you. At 20 ms, though, it never got a click in: before clicking, Playwright waits for the element to be stable, in the same place for two animation frames, and a node replaced every 20 ms never was. Every click timed out. In a separate run of 20 clicks per rate with a 3-second limit, 4 of 20 clicks succeeded at 30 ms, 18 of 20 at 40 ms, and all 20 from 50 ms up. If your clicks time out with "element was detached from the DOM, retrying" over and over in the call log, the page is re-rendering continuously, and that's usually a bug in the app, not in the test.
Two locator methods didn't retry
locator.screenshot() and locator.scrollIntoViewIfNeeded() threw the error at every rate, even at one re-render a second. Both found the element, then failed when it was replaced before they finished, instead of finding it again. So "locators never throw not attached" is not true in Playwright 1.63.0. It's true for the actions we measured, not for these two.
In a separate check at 5 ms, with 20 calls each, these locator methods never failed: textContent, innerText, getAttribute, evaluate, boundingBox, isVisible, focus, press, dispatchEvent, and expect(...).toBeVisible(). hover() timed out every time, like click(). click({ force: true }), which skips the stability check, failed 9 times in 20: 8 with "Element is not visible" and once with a timeout.
What worked for screenshots
Two ways to take the screenshot survived the re-rendering:
expect(locator).toHaveScreenshot()retries until it gets a stable screenshot. It never threw "not attached". On the button replaced every second it passed 10 of 10; at 20 and 100 ms it timed out after 5 seconds without a stable image, which is a fair answer for an element that never holds still.- A
toPass()retry aroundlocator.screenshot()passed 20 of 20 at 100 ms, where the plain call failed 11 of 40:
import { test, expect } from "@playwright/test";
test("a screenshot of a button replaced every 100 ms, retried with toPass", async ({ page }) => {
await page.goto("http://localhost:4323/?every=100");
const save = page.getByRole("button", { name: "Save" });
await expect(async () => {
await save.screenshot({ path: "test-results/save.png" });
}).toPass({ timeout: 5_000 });
});For visual comparisons, toHaveScreenshot() is the tool anyway; our visual regression guide covers it. Use the toPass() form only when you need the image itself, and give it a timeout.
How to fix it
- Replace element handles with locators.
page.$(),page.$$(),locator.elementHandle()andpage.waitForSelector(), which also returns a handle, keep one node. Locators find the element again on every call. Our Playwright test reviewer flagspage.$()andpage.$$()for that reason. - Ask for the element that's there now. After an action that replaces a button with a different one, locate the new one, as the fixed test does with "Remove".
- Assert lists after the change, with the list locator.
expect(locator).toHaveText([...])or.first()after sorting, not an array of handles from before. - Wait for the page to settle before a screenshot or a scroll. These two locator methods don't retry when the node is replaced. Use
toHaveScreenshot(), wrap the call intoPass(), or first assert the state that ends the re-rendering. - Treat endless detach-and-retry as a question for the app. A component that replaces itself every few milliseconds is doing work nobody asked for. Ask the developers why before you raise a timeout; the timeout errors guide shows how to read which limit a click ran into.
Exercise
Start rerender.mjs and click the Save button with a locator at ?every=40. Predict how many of 20 clicks succeed with a 3-second limit, then run it, and compare your result with ours. Then read the call log of one that failed and count the "detached" retries.
If an action fails because the page itself is gone, rather than one element, see Target page, context or browser has been closed. If a click waits because something is on top of the element, see element click intercepted.
Conclusion
"Element is not attached to the DOM" means Playwright held one node and the page replaced it. Element handles hit it whenever the app re-renders; locators mostly don't, because they find the element again and retry, as the call log's "element was detached from the DOM, retrying" shows. Mostly: locator.screenshot() and scrollIntoViewIfNeeded() threw it in our runs, and a page that re-renders faster than every 50 ms can't be clicked at all. Use locators, ask for the element that's there after the change, and give screenshots of moving parts a retry.
Sources and further reading
- Playwright: Locators, on why locators find the element again on every action.
- Playwright: ElementHandle, which recommends locators instead.
- Playwright: Actionability checks, on "stable" and the other checks before a click.
- Playwright: expect(...).toPass(), for retrying a block of code.
- Playwright: Visual comparisons, for
toHaveScreenshot(). - Sauce Demo, where the handle examples were run.