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 enough
  • getByPlaceholder()
  • 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:

  1. Perform the user action
  2. Wait for the relevant network or UI state
  3. 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:

  1. Did the selector match the intended element?
  2. Was the element visible and actionable at the moment of interaction?
  3. 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-testid make 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.