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.
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.
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
- Classify the duplicates from the printed candidates. Compare the
akalines: 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 arole="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. - Tighten the accessible name with
exactor a regular expression.getByRole('button', { name: 'Save' })matches case-insensitively as a substring by default. Addingexact: truemakes 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+\)$/ }— sinceexactand a regular expression are mutually exclusive. - 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 withgetByTestId(). 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. - Separate controls with role state options.
getByRole()acceptschecked,selected,pressed,expanded,disabled, andlevel. Two buttons named Filters that differ only inaria-expandedare 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. - Remove hidden duplicates rather than indexing past them. Role queries already skip nodes that are absent from the accessibility tree, so
display: noneandaria-hidden="true"copies never enter the candidate set. Text and CSS queries do match hidden nodes, sogetByText('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 togetByRole()and let the accessibility tree do the filtering. - 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. - Prove uniqueness, and keep
first()andnth()as the last resort. Addawait 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 forfirst(),last(), ornth()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');
});
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 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.