Playwright architecture, selector reliability, and advanced interaction patterns.

Resolving Strict Mode Violations

Error: strict mode violation: getByRole('button', { name: 'Save' }) resolved to 3 elements is the single most common failure a team hits after moving from CSS queries to accessibility queries, and it is not a bug in your selector engine — it is Playwright refusing to guess. Every locator is strict: an action or a single-element assertion that finds more than one candidate throws instead of quietly operating on the first match. This page shows how to read the violation output, how to classify the four situations that produce duplicates, and how to narrow the query with exact, region scoping, role state options, and visibility filters without falling back to a positional index. It sits under getByRole & Accessibility Selectors in Reliable Selector Strategies for Playwright.

Root cause: resolution demands exactly one node

A Locator is a description of a query, not a captured element, so nothing is evaluated until you act on it. At that moment Playwright re-runs the query, collects every matching node, and compares the count against what the operation requires. Actions such as click(), fill(), and selectOption(), plus single-element assertions such as toBeVisible() and toHaveText(), all require exactly one node. Zero matches means the locator keeps auto-waiting until the timeout expires; two or more means an immediate strict mode violation, thrown without retry because waiting cannot make an extra element disappear on its own. The failure is therefore a design signal: the query describes a category of elements rather than one element.

How a locator resolves against the DOM A locator query produces a candidate set whose size decides between auto-waiting, running the action, or throwing a strict mode violation. locator query getByRole('button') candidate set matched in DOM order 0 matches auto-wait, then time out exactly 1 match the action runs 2 or more matches strict mode violation Only a count of one lets an action or a single-element assertion proceed.
The candidate count decides the outcome: zero keeps waiting, one proceeds, and anything above one throws immediately rather than retrying.

Minimal reproducible example

A profile settings screen opens an edit dialog. The toolbar behind the dialog keeps its own Save button, the dialog has a Save changes button, and an autosave panel offers Save draft. One role query with a name option matches all three, because accessible-name matching is a case-insensitive substring match by default.

import { test, expect } from '@playwright/test';

test('saves the edited profile', async ({ page }) => {
  await page.goto('/settings/profile');
  await page.getByRole('button', { name: 'Edit profile' }).click();

  // FAILS — three buttons expose an accessible name containing "Save":
  // Error: strict mode violation: getByRole('button', { name: 'Save' })
  //        resolved to 3 elements:
  //          1) <button id="save-toolbar">Save</button>
  //          2) <button id="save-dialog">Save changes</button>
  //          3) <button id="save-draft">Save draft</button>
  // await page.getByRole('button', { name: 'Save' }).click();

  // count() never enforces strict mode, so it reports the problem instead
  // of throwing — useful while you are still diagnosing.
  expect(await page.getByRole('button', { name: 'Save' }).count()).toBe(3);

  // Fix 1 — scope to the region that owns the control. The dialog role has
  // its own accessible name taken from the aria-labelledby heading.
  const dialog = page.getByRole('dialog', { name: 'Edit profile' });

  // Fix 2 — exact: true switches name matching from case-insensitive
  // substring to case-sensitive whole string, so "Save changes" drops out.
  const save = dialog.getByRole('button', { name: 'Save changes', exact: true });

  // Guard rail: assert uniqueness explicitly while developing the chain.
  await expect(save).toHaveCount(1);

  await save.click();
  await expect(page.getByRole('status')).toHaveText('Profile saved');
});

The two fixes are independent and both are worth applying. Scoping bounds the search to a subtree you control; exact removes the substring behaviour that silently widened the match. Neither introduces an index, so the test survives a fourth Save button appearing next sprint.

Read the violation output before editing anything

The error is far richer than its first line. Playwright prints the locator it evaluated, the number of nodes it resolved, an abbreviated outer HTML for each candidate, and — for each one — an aka line containing a locator that would have selected that specific node. Those suggestions are generated from the live DOM at the instant of failure, so treat them as evidence about what is on the page rather than as a recommended fix; a suggestion built on a generated id will not survive the next deploy.

Anatomy of the strict mode violation output An annotated terminal panel showing which part of the error names the failing query, which part gives the match count, and which lines suggest narrowed locators. what the runner prints on failure Error: strict mode violation: getByRole('button', { name: 'Save' }) resolved to 3 elements: 1) <button>Save</button> aka getByRole('button').first() 2) <button>Save changes</button> aka getByRole('button', { name: 'Save changes' }) 3) <button>Save draft</button> aka getByRole('button', { name: 'Save draft' }) Call log: waiting for getByRole('button') the failing query how many matched generated locators Each aka line is derived from the live DOM: evidence about the page, not a verdict.
The violation names the query, the candidate count, and one generated locator per match — read all three before changing a line of test code.

Four situations account for almost every violation. Substring name matches, as above, where a short name is a prefix of longer ones. Layered UI, where a dialog, drawer, or toast renders a control that already exists on the page behind it. Repeated components, where a table or card grid legitimately renders the same button once per record. Hidden duplicates, where responsive layouts ship a mobile and a desktop copy of the same navigation and hide one with CSS. The narrowing that fixes each is different, so classify before you edit.

Step-by-step fix

  1. Classify the duplicates from the printed candidates. Compare the aka lines: if the names differ only by a suffix, you have a substring match; if the outer HTML is identical, you have repeated components; if one candidate sits inside a role="dialog" and another does not, you have layered UI. Do not touch the locator until you can name which of the four you are in, because applying the wrong narrowing produces a chain that passes today and breaks on the next data change.
  2. Tighten the accessible name with exact or a regular expression. getByRole('button', { name: 'Save' }) matches case-insensitively as a substring by default. Adding exact: true makes it case-sensitive and whole-string, which removes "Save changes" and "Save draft" in one edit. When the name carries a dynamic fragment, pass an anchored regular expression instead — { name: /^Save \(\d+\)$/ } — since exact and a regular expression are mutually exclusive.
  3. Scope to the region that owns the control. Chain from a landmark or container before naming the element: page.getByRole('dialog', { name: 'Edit profile' }), page.getByRole('navigation', { name: 'Primary' }), or a component root exposed with getByTestId(). Scoping is the durable answer to layered UI because it encodes the relationship the user perceives — the Save button of this dialog — instead of a coincidence of document order. It also keeps chains readable when they move into the classes described in Page Object Model Design.
  4. Separate controls with role state options. getByRole() accepts checked, selected, pressed, expanded, disabled, and level. Two buttons named Filters that differ only in aria-expanded are distinguished by { name: 'Filters', expanded: true }; two headings named Overview are separated by { level: 2 }. These options query the computed accessibility tree, so they express the same distinction a screen-reader user hears, which is the argument made in Why getByRole Beats CSS Selectors in Modern Apps.
  5. Remove hidden duplicates rather than indexing past them. Role queries already skip nodes that are absent from the accessibility tree, so display: none and aria-hidden="true" copies never enter the candidate set. Text and CSS queries do match hidden nodes, so getByText('Reports') finds both halves of a responsive navigation. Add .filter({ visible: true }) (Playwright 1.51 and later) to drop the hidden copy, or move the query to getByRole() and let the accessibility tree do the filtering.
  6. Narrow repeated components by their content. When a grid genuinely renders one Cancel button per row, the distinguishing signal lives inside the row: getByRole('row').filter({ hasText: 'ORD-4821' }), then chain the button off that row. The full vocabulary of content and structural narrowing is covered in Scoping Locators with filter() and has; the point here is that a filter keeps the query semantic while an index binds it to render order.
  7. Prove uniqueness, and keep first() and nth() as the last resort. Add await expect(chain).toHaveCount(1) while building the locator so an over-broad query fails loudly at the assertion instead of silently at the click. Reach for first(), last(), or nth() only when candidates are genuinely interchangeable — identical skeleton placeholders, repeated grid cells with no distinguishing content — and always pair the index with a scoped ancestor so it indexes a small ordered set rather than the whole document.
import { test, expect } from '@playwright/test';

test('picks the visible navigation link and the expanded control', async ({ page }) => {
  await page.goto('/dashboard');

  // Responsive layouts ship a mobile nav and a desktop nav; one is display:none.
  // The text engine matches hidden nodes too, so this resolves to two elements.
  const anyReports = page.getByText('Reports', { exact: true });
  await expect(anyReports).toHaveCount(2);

  // filter({ visible: true }) drops the hidden copy without using an index.
  await expect(anyReports.filter({ visible: true })).toHaveCount(1);

  // The role engine ignores nodes hidden from the accessibility tree, so the
  // duplicate never reaches the candidate set in the first place.
  const link = page
    .getByRole('navigation', { name: 'Primary' })
    .getByRole('link', { name: 'Reports', exact: true });
  await link.click();
  await expect(page).toHaveURL(/\/reports$/);

  // Two toolbar buttons share the name "Filters"; aria-expanded separates them.
  await page.getByRole('button', { name: 'Filters', expanded: true }).click();

  // Repeated rows: narrow by the content that identifies the record.
  const row = page.getByRole('row').filter({ hasText: 'ORD-4821' });
  await row.getByRole('button', { name: 'Cancel', exact: true }).click();
  await expect(row).toContainText('Cancelled');
});
Choosing the narrowing for a duplicate match A decision tree routing hidden duplicates, similar accessible names, and repeated rows to visibility filters, exact names, and content filters. locator matched 2+ nodes classify, then narrow are the extras hidden? names overlap? one per record? filter visible: true name with exact: true filter by hasText or query by role or a role state option or scope, then nth() Use an index only after scoping, and only when the candidates are interchangeable.
Each class of duplicate has its own narrowing; the positional index is the bottom row because it is the option that ages worst.

Troubleshooting variants

The violation appears only in CI or only on the mobile project

A run that passes on your machine and throws resolved to 2 elements in the pipeline is almost always a viewport difference. CI defaults to a different window size than your local headed run, and a responsive layout that hides one navigation at 1280px may show both at 375px, or render an extra sticky action bar. Reproduce it by running the failing spec with the same project and viewport the pipeline uses — npx playwright test --project=mobile-safari — rather than trusting the local default. Once reproduced, fix it by scoping to the specific navigation landmark or by adding .filter({ visible: true }), never by branching the test on viewport width. Related cross-engine differences are covered in Cross-Browser Execution.

The violation is intermittent around a route change or dialog close

An exit animation keeps the outgoing component mounted for a few hundred milliseconds while the incoming one is already in the DOM, so for that window two dialogs, two headings, or two toasts match. Because a strict mode violation throws without retrying, the test fails the moment it lands inside that window and passes whenever it does not — a textbook flake, of the kind catalogued in Detecting and Fixing Flaky Playwright Tests. The fix is to gate the next action on the transition completing: await expect(page.getByRole('dialog')).toHaveCount(1) retries until the old node is gone, unlike a bare action. Broader synchronization patterns live in Handling Dynamic Content.

The transient window where two dialogs coexist A timeline showing the outgoing dialog remaining mounted during its exit animation while the new dialog is already attached. click Save new dialog mounts old dialog exiting old node removed two dialogs in the DOM: strict mode fails one dialog A retrying assertion such as toHaveCount(1) waits out the window; a bare click does not.
The violation only fires while both components are attached, which is why it reads as a random flake rather than a broken selector.

The candidates look identical in the error output

When every printed candidate has the same tag, the same name, and no distinguishing text, the difference is usually structural rather than textual: one lives inside an iframe boundary, one inside a shadow root, or one inside a container whose accessible name you have not used yet. Open the failing step in the Playwright Trace Viewer and run the query against the DOM snapshot to see where each match sits in the tree. Elements inside embedded documents need a frame boundary crossed first, as described in Automating Elements Inside Nested Iframes, while duplicated web components usually need a chained host locator per Shadow DOM Traversal.

Verification

Confirm the repair three ways. First, keep await expect(locator).toHaveCount(1) in the test while you iterate; it is a retrying assertion, so it survives a slow render, and it fails with a clear count rather than an ambiguous click error. Second, run the fixed spec with npx playwright test --repeat-each=10 --project=chromium so that a transient duplicate around an animation surfaces before the change reaches the shared pipeline. Third, open the recorded trace and use the locator picker on the DOM snapshot: type the final chain and confirm the picker highlights exactly one node with a match count of one. As a codebase-level check, grep the suite for .first(), .last(), and .nth( — each occurrence should have a scoped ancestor and a comment justifying why the candidates are interchangeable, and anything else is a latent violation waiting for new data. Pair that with the retrying-assertion discipline from Web-First Assertions so uniqueness is asserted, not assumed.

Frequently Asked Questions

Can I turn strict mode off in Playwright?

No, and that is deliberate. Locators are strict by construction — there is no configuration flag that makes an ambiguous locator silently act on the first match. The behaviour exists because the alternative, which older frameworks chose, is a test that clicks the wrong button and reports success. If you truly want the first of several interchangeable elements, say so explicitly with .first(), which turns an implicit gamble into a documented decision that a reviewer can question.

Is .first() ever an acceptable fix?

Yes, in a narrow case: when the candidates are genuinely equivalent and any of them would satisfy the test — repeated skeleton placeholders, cells in a uniform grid, one of many identical spacer controls. Even then, scope it first so the index runs over a small ordered set inside a known container rather than over the whole document. What makes it a poor default is that it converts a loud, early failure into a quiet, wrong action the day the page starts rendering an extra match ahead of the one you meant.

Why does a name option match longer labels too?

The name option for getByRole() matches the accessible name case-insensitively as a substring with whitespace normalized, so 'Save' also matches "Save changes" and "SAVE DRAFT". Pass exact: true for case-sensitive whole-string matching, or supply an anchored regular expression when part of the name is dynamic. Note that exact has no effect when the value is a regular expression, and that whitespace is still trimmed and collapsed even in exact mode.

Which locator methods do not enforce strict mode?

Anything whose contract is plural. count(), all(), allTextContents(), and allInnerTexts() operate over the whole candidate set, as does expect(locator).toHaveCount(n) and the array form of expect(locator).toHaveText([...]). Use them to inspect an ambiguous locator while you are diagnosing it, and to iterate deliberately over repeated elements. Every action and every single-element assertion remains strict, so the plural methods are diagnostic tools rather than an escape hatch.

Back to overview