June 25, 2026
How to Handle Dynamic Elements in Playwright
Learn how to handle dynamic elements in Playwright with stable locators, auto-waiting, retries, and debugging patterns for dynamic UIs.
If you have spent any time automating modern web apps, you already know that the UI rarely sits still. Buttons move after state changes, list items re-render, IDs get regenerated, and a spinner disappears just long enough to make a test fail. That is exactly why learning how to handle dynamic elements in Playwright matters, not just for getting tests to pass once, but for keeping them stable in CI.
Playwright is strong at dealing with dynamic UIs because it was designed around auto-waiting, browser-level interactions, and locator-first assertions. But the tool is not magic. If your selectors are brittle, if you reach for waitForTimeout() too often, or if you assume a node in the DOM is the same thing the user sees on screen, you will still create flaky tests.
In this article, I will walk through the practical patterns I use as an SDET when a page is constantly changing under me. We will look at locator strategy, waiting behavior, retry-friendly assertions, and the cases where a lower-code platform such as Endtest can reduce selector maintenance by using agentic AI and self-healing execution. I will stay focused on Playwright first, because that is where most teams start.
What makes an element dynamic?
A dynamic element is not just one with a changing ID. In practice, I group dynamic UI behavior into a few buckets:
- Elements that are added or removed after async work finishes
- Elements whose text changes based on state, locale, or user role
- Lists that re-render when filtering, sorting, or paginating
- Components that reuse DOM nodes, such as virtualized tables
- Controls whose attributes change on every build or session
- Elements hidden behind animations, overlays, or transitions
A login button with a stable data-testid is easy. A menu item that appears only after you hover, then changes DOM position during an animation, is where tests start breaking.
The main mistake I see is people treating the DOM like a static document. Modern apps are often built with React, Vue, Angular, Svelte, or a custom component system that re-renders pieces of the page in response to state changes. Your test should expect that.
Start with the right mental model in Playwright
Playwright works best when you think in terms of locators, not element handles. A locator is a live query, meaning it re-evaluates against the page when used. That makes it much better for dynamic content than caching a node and hoping it stays valid.
The official docs explain the core model well, especially around locators and auto-waiting in the Playwright intro.
If the UI is unstable, prefer a locator that can be re-resolved over a direct handle that can go stale.
This is one of the biggest reasons Playwright handles dynamic elements better than many older Selenium patterns. You do not usually need to manually wait for an element to be attached, visible, stable, and enabled if you are using the right APIs. But you still need to choose locators carefully.
Prefer user-facing locators first
The most stable selectors are usually those that reflect how a user would identify the element.
Good options:
getByRole()getByLabel()getByText()when text is stable and unique enoughgetByPlaceholder()getByTestId()when semantic selectors are not available
Example:
import { test, expect } from '@playwright/test';
test('submits a form', async ({ page }) => {
await page.goto('/signup');
await page.getByLabel('Email address').fill('qa@example.com');
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByText('Account created')).toBeVisible();
});
This style is usually much more stable than div:nth-child(3) > button or a long CSS chain. It also maps better to accessibility, which is a nice side effect and often a sign that the app has cleaner semantics.
When data-testid is the right answer
I am not religious about semantic locators. If the UI is highly dynamic and visible text changes with locale, A/B tests, or content personalization, data-testid can be the most practical solution.
Use data-testid when:
- The visual label changes often
- You have multiple similar buttons with the same role and text
- You need a selector that survives copy changes
- The element is custom and has weak accessibility metadata
Example:
typescript
await page.getByTestId('checkout-submit').click();
If your product team can commit to stable test IDs, that is often worth the small implementation cost. It is still better than relying on DOM structure.
Use Playwright waits the way they are intended
A lot of flaky behavior comes from misunderstanding waits. Playwright already waits for many actions automatically, but only if you use the action or assertion APIs correctly.
Auto-waiting covers many common cases
When you do this:
typescript
await page.getByRole('button', { name: 'Save' }).click();
Playwright will wait until the button is actionable, which usually means it is visible, enabled, and stable enough to click. That eliminates a huge class of manual waits.
Similarly, assertions like this:
typescript
await expect(page.getByText('Saved')).toBeVisible();
will retry until the timeout is reached, instead of failing immediately.
Avoid waitForTimeout() unless you are debugging
Hard sleeps are the fastest way to make dynamic tests brittle. They are either too short or too long, and both outcomes are bad. Too short means flakes. Too long means slow CI and wasted runtime.
Instead of this:
typescript
await page.waitForTimeout(2000);
prefer a condition-based wait:
typescript
await expect(page.getByText('Order complete')).toBeVisible();
Or, if the state is not exposed in the UI yet, wait on a network or app condition:
typescript
await page.waitForResponse(response =>
response.url().includes('/api/orders') && response.status() === 200
);
The key is to wait for a meaningful event, not an arbitrary amount of time.
Handle dynamic elements with explicit assertions, not guesswork
A good test does not ask, “Has enough time passed yet?” It asks, “Has the system reached the state I need?”
For example, after filtering a product list, I usually assert on the list contents rather than the click itself.
typescript
await page.getByRole('textbox', { name: 'Search' }).fill('keyboard');
await expect(page.getByRole('listitem')).toHaveCount(3);
await expect(page.getByText('Mechanical Keyboard')).toBeVisible();
If a dynamic menu is hidden behind a hover, I assert the menu state after the hover action:
typescript
await page.getByRole('button', { name: 'More options' }).hover();
await expect(page.getByRole('menu')).toBeVisible();
The main idea is to make the test fail on the real product behavior, not on a timing accident.
Working with loading spinners and async content
Loading spinners are one of the most common sources of false failures. A test often clicks something before the UI is ready, or tries to assert text before the response is rendered.
A reliable pattern is:
- Perform the user action
- Wait for the relevant network or UI state
- Assert on the final result
Example:
typescript
await page.getByRole('button', { name: 'Refresh' }).click();
await expect(page.getByTestId('loading-spinner')).toBeHidden();
await expect(page.getByText('Latest results')).toBeVisible();
If the spinner is not stable enough to target directly, wait for the thing that replaces it. The user does not care whether the spinner vanished, they care whether the content appeared.
Be careful with toBeHidden()
A hidden element is not always the same as a removed one. In some apps, a spinner stays in the DOM but becomes invisible. In others, it is fully detached. Both are valid. Your assertion should match the implementation your team expects to support.
Dynamic lists, tables, and virtualized UIs
Tables and infinite scroll pages are where dynamic element handling gets more complicated. The DOM may only contain visible rows, and rows can be recycled as you scroll.
Use row-scoped locators
Instead of targeting a cell globally, scope the locator to the row you care about.
typescript
const row = page.getByRole('row', { name: /Order #12345/ });
await expect(row).toContainText('Paid');
await row.getByRole('button', { name: 'View' }).click();
This keeps the test focused on a meaningful unit of UI, and it is much more robust than relying on index positions.
Handle virtualization carefully
With virtualized lists, offscreen items may not exist in the DOM at all. If you use locator.nth() on a recycled list, you can get surprising results. In those cases, scroll or search by content.
typescript
await page.getByText('Inventory item 987').scrollIntoViewIfNeeded();
await expect(page.getByText('Inventory item 987')).toBeVisible();
If the app loads more rows as you scroll, wait for the count or the specific target item to appear after scrolling.
Retries are not a substitute for stability, but they help when used correctly
Playwright Test supports retries, and they can be useful for isolating transient failures in CI. But retries should not be the first line of defense for poor selectors or broken synchronization.
Use retries when:
- The failure is caused by a known intermittent dependency
- You are validating a flaky external integration separately
- You want to classify whether a failure is persistent or transient
Do not use retries to hide:
- Wrong locators
- Weak timing assumptions
- Broken test setup
- UI states your test never actually waits for
A simple retry setup in playwright.config.ts might look like this:
import { defineConfig } from '@playwright/test';
export default defineConfig({ retries: 1, use: { trace: ‘on-first-retry’ } });
The trace on retry is especially helpful for dynamic UI failures because it lets you inspect what the DOM looked like when the test started failing.
Debugging dynamic element failures
When a test fails on a moving element, I usually ask three questions:
- Did the selector match the intended element?
- Was the element visible and actionable at the moment of interaction?
- Did the UI re-render between locating and acting?
Use locator debugging and traces
Playwright traces, screenshots, and video can reveal whether the app was still loading or whether the locator was too broad.
A few practical debugging tactics:
- Run the test in headed mode
- Pause before the flaky step
- Check whether the selector points to multiple elements
- Inspect whether the UI changed after network activity
- Compare the failing run with a passing run
If a locator is matching multiple elements, tighten it. If it matches the right element but still fails, the issue may be timing or actionability.
Build selectors that survive UI change
The best selector strategy is not about finding the shortest selector, it is about finding one that keeps working when the DOM changes.
Prefer these properties in order
- Role and accessible name
- Stable labels or visible text
data-testid- Stable attributes such as
aria-label - Scoped CSS when no better alternative exists
Avoid these when possible
- Deep CSS chains
- Auto-generated classes
nth-child()as a primary strategy- XPath tied to page structure
- Text that is known to localize or change with marketing copy
A bad selector might look like this:
typescript
await page.locator('div.main > div.container > div:nth-child(2) > button').click();
A better one might be:
typescript
await page.getByRole('button', { name: 'Submit order' }).click();
If you cannot avoid structure-based selectors, at least scope them to a stable container:
typescript
const checkout = page.getByTestId('checkout-panel');
await checkout.getByRole('button', { name: 'Submit order' }).click();
Dealing with elements that appear conditionally
Sometimes an element is dynamic because it exists only for certain users, features, or states. A common example is a beta flag or admin-only control.
In that case, tests should make the condition explicit.
if (await page.getByRole('button', { name: 'Admin settings' }).isVisible()) {
await page.getByRole('button', { name: 'Admin settings' }).click();
}
But I prefer a more deterministic approach when possible. If the test expects the control to exist for a role, set up that role in the test fixture and assert on the presence directly.
typescript
await expect(page.getByRole('button', { name: 'Admin settings' })).toBeVisible();
Conditional checks can hide product bugs if you overuse them. Use them only when the variability is intentional.
How dynamic elements differ from stale element problems in Selenium
If you come from Selenium, you may recognize a familiar pattern, stale element reference errors. Playwright avoids many of those by design because locators are re-resolved, not stored as fragile element handles.
That said, you can still create similar problems if you store unnecessary state or use direct element handles too early.
For comparison, a Selenium-style problem might look like this:
from selenium import webdriver
browser = webdriver.Chrome() button = browser.find_element(“css selector”, “button.save”)
UI rerenders here
button.click()
In Playwright, the equivalent is usually safer because the locator is evaluated at action time.
The practical shift is this, do not think “I found the element once, so I own it forever.” Think “I can reliably find the same user-facing control whenever I need it.”
A few patterns I use in real test suites
Pattern 1, assert before and after a state change
typescript
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Saved')).toBeVisible();
Pattern 2, scope the search to a container
typescript
const modal = page.getByRole('dialog', { name: 'Edit profile' });
await modal.getByLabel('Display name').fill('Alex');
await modal.getByRole('button', { name: 'Update' }).click();
Pattern 3, wait for the content that actually matters
typescript
await page.getByRole('button', { name: 'Load more' }).click();
await expect(page.getByText('Showing 40 of 40')).toBeVisible();
These are small habits, but they add up to a much more stable suite.
When Playwright is not enough, maintenance matters
There is a point where teams realize the issue is not just how to write a locator, but how much maintenance they want to own. If every UI refactor requires updating a large selector set, your automation cost starts to climb.
This is where I think tools with self-healing capabilities become interesting, especially for teams that want less framework ownership and more resilience to DOM changes. Endtest is a credible option here because it is positioned as a Playwright alternative with a managed platform approach, and it uses agentic AI across the test lifecycle.
In particular, Endtest’s self-healing tests can recover when a locator stops resolving, then continue the run with a replacement based on surrounding context such as attributes, text, structure, and neighbors. That is not the same thing as blindly guessing, the platform logs the healed locator so reviewers can inspect what changed.
For teams that are tired of manually maintaining selectors after every UI reshuffle, that can be a better fit than more code. It is especially useful when non-developers need to participate in test creation, or when the organization wants editable, platform-native steps instead of another layer of handwritten automation glue.
Endtest versus Playwright, how I would frame the choice
If your team has strong engineering ownership, a code-first Playwright stack is still a great choice. It gives you flexibility, control, and close integration with your existing test framework and CI/CD pipeline.
If your team wants less selector maintenance, fewer framework decisions, and AI-assisted healing when the DOM changes, Endtest is worth a serious look. It is designed for teams that want end-to-end coverage without having to own the full stack of runners, browser setup, and selector upkeep. That is not just a different price point, it is a different operational model.
For a practical comparison, I would read the Endtest vs Playwright breakdown alongside your own team constraints. Ask yourself:
- Do we want code-level control, or platform-managed workflows?
- Who is responsible for updating tests when the UI changes?
- How often do locator changes cause flaky runs?
- Do non-developers need to maintain tests directly?
If your answer leans toward code ownership, Playwright remains excellent. If your answer leans toward maintenance reduction and broader team access, Endtest may be the simpler long-term path.
CI/CD tips for dynamic UI tests
Dynamic element handling does not stop in local runs. CI is where weak tests are usually exposed.
A few useful habits:
- Run traces on failure or first retry
- Keep browser versions pinned through your toolchain
- Separate genuinely flaky tests from deterministic smoke tests
- Fail fast on selector regressions, but investigate timing failures before increasing retries
- Keep tests isolated so one animation-heavy page does not poison the whole suite
A minimal GitHub Actions job might look like this:
name: playwright-tests
on: [push, pull_request]
jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx playwright install –with-deps - run: npx playwright test
This does not solve dynamic elements by itself, but it gives you a consistent environment so the remaining failures are more likely to be real.
My practical checklist for dynamic elements
Before I call a Playwright test stable, I usually check the following:
- Does the test use user-facing locators first?
- Are waits condition-based rather than time-based?
- Is the locator scoped to the right container?
- Does the assertion verify the real outcome?
- Would the selector survive a small DOM refactor?
- Are retries only hiding transient issues, not structural problems?
- Would a
data-testidmake this significantly more stable?
If I cannot answer yes to most of those, the test will probably come back to bite me in CI.
Closing thoughts
The easiest way to handle dynamic elements in Playwright is not by adding more waits, it is by choosing better locators and asserting on real application state. Playwright gives you the primitives to do that well, especially with locator re-evaluation, auto-waiting, and strong assertions. Most flaky tests still trace back to human choices, brittle selectors, arbitrary sleeps, or unclear expectations.
If you own the test code and want maximum control, Playwright is still a strong fit. If you want to reduce manual selector maintenance and let an agentic platform heal locator changes for you, Endtest is a practical alternative worth evaluating. I would not treat that as a theoretical preference, I would make it a team decision based on maintenance cost, ownership, and how much code your organization wants to carry.
The next time a dynamic UI breaks a test, do not start by adding a sleep. Start by asking what the user can reliably see, what the app is really waiting on, and whether your selector reflects that reality.