skip to content

Your Playwright test fails with 'Element is not attached to the DOM' after the issue board refreshes, so how do you find and fix the cause?

level: seniorimportance: should knowfreq 50%

answer

  1. The node left the document
  2. A captured reference, not a selector
  3. Look for where the handle is stored
  4. Page objects should expose locators
  5. Waiting longer cannot bring it back

basics

~20 s

That error comes from acting through an ElementHandle whose node was replaced. Find where the handle is created and stored, usually a page-object field or a hook, and replace it with a locator that re-resolves on every action.

solid answer

~50 s

`Element is not attached to the DOM` is raised when an action runs against a node that has left the document, so it points at a captured node rather than a bad selector. Locator actions re-resolve and retry inside the same timeout -- the action log shows `element was detached from the DOM, retrying` -- so the culprit is almost always a handle from `page.$()`, `page.waitForSelector()`, `locator.elementHandle()` or `page.evaluateHandle()` that was taken before the refresh and used after it. Trace the failing variable back to its creation, then ask what re-rendered in between. The fix is structural: store the locator, not the handle, and if a handle really is required, create, use and `dispose()` it inside one step. Extra waits, a longer action timeout or a test retry change nothing, because that node is gone for good.

code

typescript · 19 lines
typescript
import { test, expect, type Locator, type Page } from '@playwright/test';

class IssueDetail {
  readonly assignButton: Locator;

  constructor(page: Page) {
    // A description, not a node: safe to keep for the whole test.
    this.assignButton = page.getByRole('button', { name: 'Assign' });
  }
}

test('assign an issue after the board refreshes', async ({ page }) => {
  await page.goto('/issues/KAT-118');
  const detail = new IssueDetail(page);

  await page.getByRole('button', { name: 'Refresh' }).click();
  await detail.assignButton.click(); // resolves the freshly rendered button
  await expect(page.getByText('Assigned to you')).toBeVisible();
});

go deeper

for a junior

Recognise the message as meaning the element you grabbed earlier no longer exists, and reach for the locator you already have rather than adding a pause before the failing line.

for a middle

Explain the mechanism: a handle pins one node, a re-render replaces that node, and the action then has nothing to act on because no selector is re-run.

for a senior

Drive the diagnosis end to end -- find the handle's creation, identify the render that invalidated it, and fix the lifetime rather than the timing, including in shared page objects and hooks.

for a principal

Own the pattern at suite level: page objects expose locators only, handle use is short-lived and justified, and this error is triaged as a design defect rather than routine flake.

## What the message actually means `Element is not attached to the DOM` is raised when Playwright performs an action against a DOM node that is no longer in the document. It is a statement about a *node*, not about a selector, and that is the whole diagnostic value of it: something in the test is holding a node rather than a way to find one. Note what it is not. A selector that matches nothing produces a timeout naming the selector. A selector that matches several elements produces the strict-mode error with a candidate list. Only a captured node produces this one. ## Why a locator almost never raises it Locator actions re-resolve the selector each time and retry while they wait. If the node is swapped out mid-action, the action log shows `element was detached from the DOM, retrying` and the action resolves the selector again inside the same timeout. So a locator-only test that fails this way is unusual -- the far likelier reading of the error is that a handle is involved somewhere in the call path. ## Where handles hide in a suite Search for the four ways a handle is born, then for where the result is kept: - `page.$()` and `page.$$()`, often left over from a Puppeteer-shaped habit; - `page.waitForSelector()`, which returns a handle even though it reads like a wait; - `locator.elementHandle()` and `locator.elementHandles()`; - `page.evaluateHandle()`, whose result is a `JSHandle` that may be an element. And the storage patterns that turn a short-lived handle into a bug: - a field on a page object assigned once in a constructor or an `init()`; - a handle captured in a `beforeEach` hook and reused by each test; - a module-level variable holding "the currently selected issue row"; - a handle held across a `page.goto()`, which auto-disposes it on navigation. ## A diagnosis order that works 1. Read the failing step in the error output and note which call threw. If it is a method on something that is not a locator, you have your answer already. 2. Trace that variable back to its creation. One of the four calls above will be at the top of the chain. 3. Ask what happened to the page between creation and use -- a refresh poll, a mutation that re-renders a list, a route change, a toast that forces a parent re-render. 4. Confirm by moving the creation to immediately before the use. If the failure disappears, the lifetime is the bug, not the selector. ## The fix, and the fixes that do not work The fix is structural. Replace the stored handle with a stored locator: locators are inert descriptions, safe to build in a constructor and safe to keep for the life of the test. Where a handle is genuinely required, create it, use it and dispose it within a single step so no re-render can happen in between. | Attempted fix | Outcome | |---|---| | Store the locator instead of the handle | fixes it -- the query re-runs per action | | Add a fixed pause before the action | no effect -- the node is gone permanently | | Raise the action timeout | no effect -- nothing will re-attach that node | | Re-take the handle right before use | works, but only until the next re-render | | Retry the whole test | hides it intermittently and wastes CI time | ## Keeping the class of failure out Page objects expose locators, never handles. If a helper needs several properties of one element, use `locator.evaluate()`, which resolves the element, runs your function and disposes the temporary handle itself. Reserve `ElementHandle` for a deliberate, commented, short-lived DOM read on a surface that is not re-rendering -- and treat any handle that outlives the statement that created it as the defect this error is reporting.

  • How would you prove the failure is a handle lifetime problem rather than a slow render?
    Move the creation of the reference to the line immediately before the action. If the failure disappears, the node was being captured too early and something re-rendered in between; a slow render would still fail there, and would fail with a timeout naming the selector rather than a detached-node error.
  • Can a test that uses only locators ever produce this error?
    It is rare, because locator actions re-resolve and retry within the timeout. The realistic source is a handle somewhere in the call path -- often inside a helper that takes `locator.elementHandle()` before doing its work -- so the search is for handle creation, not for the locator at the failing line.

saying these in an interview costs you the question

  • Add a fixed wait before the action to let it settle
  • Increase the timeout so the element can re-attach
  • Turn on test retries and move on
  • The selector must be wrong
  • Cache the element in beforeEach so it is ready