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.
The same error, whichever language you use
The message comes from the browser driver, so the text is identical everywhere. The path in front of it is not, and that is usually what people paste into a search.
Python puts it in selenium.common.exceptions and prefixes the text with Message::
Traceback (most recent call last):
File "test_contacts.py", line 21, in test_filter_keeps_rows
rows[0].text
selenium.common.exceptions.StaleElementReferenceException: Message: stale element reference: element is not attached to the page document
Stacktrace:
Java uses the org.openqa.selenium package:
org.openqa.selenium.StaleElementReferenceException: stale element reference: element is not attached to the page document
(Session info: chrome=126.0.6478.127)
C# uses OpenQA.Selenium.StaleElementReferenceException.
JavaScript ends its classes in Error rather than Exception, so it is StaleElementReferenceError. That spelling matters later, when we check the error by name in a retry helper.
The cause and the fixes are the same in all four. The problem is in the page, not in your language.
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 same mistake in Python:
# finds all rows now
rows = driver.find_elements(By.CSS_SELECTOR, '[data-testid="row"]')
driver.find_element(By.CSS_SELECTOR, '[data-testid="filter"]').click()
# the list re-rendered; every reference in rows is stale
rows[0].text # raises StaleElementReferenceException
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();
driver.find_element(By.CSS_SELECTOR, '[data-testid="filter"]').click()
wait_for_rows_to_settle(driver)
row = driver.find_element(By.CSS_SELECTOR, '[data-testid="row"]') # fresh
row.text
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;
}
}
}
The same helper in Python:
from selenium.common.exceptions import StaleElementReferenceException
def click_stably(driver, by, selector, attempts=3):
for i in range(attempts):
try:
driver.find_element(by, selector).click()
return
except StaleElementReferenceException:
# Only swallow staleness, and only while attempts remain.
if i == attempts - 1:
raise
Notice what neither version does: it does not catch every error. A blanket try/catch or a bare except: 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.
Both languages can also do this inside the wait itself, which is cleaner than a hand-written loop.
In Python, WebDriverWait takes an ignored_exceptions argument:
wait = WebDriverWait(
driver,
timeout=10,
ignored_exceptions=[StaleElementReferenceException],
)
In Java, the fluent wait has the same idea: 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.
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.
Related: NoSuchElementException and element not interactable, the other two errors every Selenium suite meets, and the same problem in Cypress.
Reference: Selenium's guidance on waits.