Your test finds a row, clicks a filter, then tries to read that row again. Selenium stops with this:
stale element reference: element is not attached to the page document
The row is right there on screen. You can see it. The test still fails.
Most people meet this error early and treat it as a Selenium bug or bad luck. It is neither. Once you know what the message actually says, the fix is obvious and you stop hitting it.
What the error really means
When you call findElement, Selenium does not hand you the element. It hands you a reference, a small id that points at one specific element in the browser's current page.
const row = await driver.findElement(By.css('[data-testid="row"]'));
row is not the row. It is a numbered ticket for the row.
If the page re-draws that part of the screen, the browser throws away the old element and builds a new one. Your ticket now points at something that no longer exists. Selenium tells you so, in the most literal way it can: the element is not attached to the page document any more.
The error is Selenium being honest that your reference expired. It is not hiding a deeper problem, and it does not mean your selector is wrong.
What makes an element go stale
Four things, and they cover almost every real case.
The page navigated. Any full page load replaces everything. Every reference you were holding is dead.
A part of the screen re-rendered. This is the common one in modern apps. You filter a list, sort a table, or the app refreshes data in the background. React, Vue and Angular routinely throw away rows and build new ones that look identical.
The element was removed and added back. A dialog that closes and reopens is not the same dialog to the browser, even though it looks the same to you.
You stored a reference too early. The classic version:
// finds all rows now
const rows = await driver.findElements(By.css('[data-testid="row"]'));
await driver.findElement(By.css('[data-testid="filter"]')).click();
// the list re-rendered; every reference in rows is stale
await rows[0].getText(); // throws
The loop version of this is the one that catches everyone: you collect a list of elements, then do something inside the loop that changes the page, and every later item in the list is dead.
The fixes that actually work
Fix 1: find the element again, right before you use it
This is the real fix, and most of the time it is the only one you need.
// instead of holding a reference across an action
await driver.findElement(By.css('[data-testid="filter"]')).click();
await waitForRowsToSettle(driver);
const row = await driver.findElement(By.css('[data-testid="row"]')); // fresh
await row.getText();
Keep the gap between finding an element and using it as short as you can. A reference that never crosses an action can never go stale.
Fix 2: do not hold a list across a change
If you need to act on several rows and each action re-renders the list, re-find the list each time instead of iterating over old references.
const rowSelector = By.css('[data-testid="row"]');
for (let i = 0; i < 3; i++) {
const rows = await driver.findElements(rowSelector); // fresh every pass
await rows[i].findElement(By.css('[data-action="archive"]')).click();
await waitForRowsToSettle(driver);
}
Fix 3: wait for the new state, not for time
A sleep looks like it fixes staleness, because it lets the re-render finish. It is still a guess, and on a slow day it fails again. Wait for something you can name instead:
await driver.wait(until.elementLocated(By.css('[data-testid="row"]')), 10000,
'The contact rows never re-appeared after filtering');
If you are unsure whether the list has settled, wait for the count to stop changing rather than for the first row to exist. A list that renders in stages will hand you a half-built page otherwise.
Fix 4: retry, but only for this one error
A small retry helper is reasonable for an action that races with a re-render:
async function clickStably(driver, locator, attempts = 3) {
for (let i = 0; i < attempts; i++) {
try {
const el = await driver.findElement(locator);
await el.click();
return;
} catch (err) {
// Only swallow staleness, and only while attempts remain.
if (err.name !== 'StaleElementReferenceError' || i === attempts - 1) throw err;
}
}
}
Notice what it does not do: it does not catch every error. A blanket try/catch around a click will hide a genuinely broken selector, a missing element and a real bug, and you will never learn about any of them. Catch this one error by name or do not catch at all.
In Java, the fluent wait has this built in. ignoring(StaleElementReferenceException.class) tells the wait to keep polling through staleness instead of failing on it.
What not to do
Do not add a sleep and move on. It works on your laptop and fails on the build server, which is the worst combination because it looks fixed.
Do not wrap everything in a retry. Staleness handled everywhere becomes staleness understood nowhere, and it hides real failures.
Do not blame the selector. This error means the element was found. Rewriting the selector changes nothing, and it is where most people waste their first hour.
A note on the name
In Java, C# and Python it is StaleElementReferenceException. In the JavaScript bindings the class is StaleElementReferenceError, which matters when you check err.name as the retry helper above does. Same error, different spelling per language.
Why Playwright and Cypress never show you this
Worth knowing, because it explains the difference between the tools rather than just declaring one better.
In Playwright and Cypress, a locator is a description, not a captured element. When you write page.getByTestId('row'), you have written down how to find the row. The tool looks it up again every time it needs it, so a re-render is invisible to your test.
Selenium's findElement hands you the actual element instead. That is the trade: you get a real handle you can hold and reuse, and holding it is exactly what makes it go stale.
Neither design is wrong. Knowing which one you are using tells you whether re-finding is something you must do yourself.
Remember this
The error means your reference expired, not that your selector is broken. Find elements late, never hold them across an action that changes the page, wait for the new state rather than for a number of seconds, and if you retry, retry only this error.
The Selenium track covers this and the waiting patterns around it in depth: why elements go stale.
Reference: Selenium's guidance on waits.