How to fix Playwright "Element is not attached to the DOM"?
Playwright"Element is not attached to the DOM" surfaces when a Playwright action targets an element that was in the DOM when queried but was removed or replaced before the action executed. This is almost always caused by React, Vue, or Angular re-rendering a component between your element lookup and your action on the result. It is fundamentally a stale reference problem — the handle points to a node that no longer exists in the live document tree.
Common mistake
test('edits first comment', async ({ page }) => {
await page.goto('https://app.example.com/post/42');
// page.$() returns an ElementHandle — snapshot at query time
const editBtn = await page.$('.comment:first-child .edit-btn');
// Meanwhile, app re-renders comments after lazy-loading more
await page.waitForTimeout(1000);
// editBtn is now detached — DOM was rebuilt by re-render
await editBtn.click();
});
Using page.$(), page.$$(), or locator.elementHandle() and storing the result for later use is the root cause in most cases.
The fix
Replace ElementHandle lookups with locators, which re-query the DOM on every action:
import { test, expect } from '@playwright/test';
test('edits first comment', async ({ page }) => {
await page.goto('https://app.example.com/post/42');
// Wait for comments to fully load
await expect(page.getByRole('article').first()).toBeVisible();
// Scope the action — locator resolves fresh on click()
const firstComment = page.getByRole('article').first();
await firstComment.getByRole('button', { name: 'Edit' }).click();
// Confirm edit mode activated
await expect(firstComment.getByRole('textbox')).toBeVisible();
});
For lists where specific item identity matters more than position:
test('edits comment by author', async ({ page }) => {
await page.goto('https://app.example.com/post/42');
const adaComment = page.getByRole('article', { name: /Ada Lovelace/ });
await adaComment.getByRole('button', { name: 'Edit' }).click();
await expect(adaComment.getByRole('textbox')).toBeVisible();
await adaComment.getByRole('textbox').fill('Updated comment text');
await adaComment.getByRole('button', { name: 'Save' }).click();
await expect(adaComment.getByText('Updated comment text')).toBeVisible();
});
Why it works
Playwright's Locator is a description of how to find an element, not a reference to a specific DOM node. Every time you call an action (.click(), .fill(), .textContent()) on a locator, Playwright queries the current live DOM. If the component re-rendered between action calls, the next action finds the new node rather than throwing on a stale one. Additionally, Playwright's action methods include built-in actionability checks that wait for the element to be stable before acting, further reducing the window for detachment.
Tips
- Migrate away from page.$() and page.$$() entirely in new test code — there is no scenario where a locator doesn't serve the same purpose more reliably.
- If you genuinely need to verify an element's text before and after an action, use locator.textContent() called twice — don't hold the result of locator.elementHandle().
- Components that animate out and back in (modal open/close) can cause a brief detachment window — use expect(locator).toBeVisible() before acting to let the animation complete.
- When the detachment is caused by iframe re-mounting rather than component re-rendering, see frame was detached.