Debug Playwright Strict-Mode Locator Failures
Strict mode is Playwright refusing to guess which element you meant. The error already lists the candidates and suggests a fix for each. Here is the order to work through, and why an index is almost never the right answer.

filter() and chaining before first(), nth(), or a test id — and treat nth() as a last resort the docs themselves discourage.Strict mode is not an obstacle Playwright puts in your way. It is the framework refusing to guess which of several elements you meant, at the one moment when guessing would be cheapest and most dangerous. The fix is almost never to pick an index. It is to say which element you meant in the same terms a user would.
What the error actually says
A strict-mode failure prints the matched candidates. Most of the debugging information you need is already on your screen:
Error: strict mode violation: getByRole('button', { name: 'Save' }) resolved to 3 elements:
1) <button type="submit" class="btn-primary">Save</button> aka getByRole('form', { name: 'Profile' }).getByRole('button')
2) <button type="submit" class="btn-primary">Save</button> aka getByRole('form', { name: 'Notifications' }).getByRole('button')
3) <button type="button" hidden class="btn-primary">Save</button> aka getByTestId('row-template').getByRole('button')
Three things are worth noticing before you change a line of code.
First, Playwright suggests a disambiguating locator for each candidate on the aka line. That suggestion is usually the answer, and it is usually a container scope rather than an index.
Second, candidate 3 is hidden. A hidden row template matching your locator is a different bug from two real forms matching it, and it wants a different fix.
Third, candidates 1 and 2 are genuinely indistinguishable by their own markup. Two buttons with the same accessible name, same role, and same classes. If your test cannot tell them apart from the accessibility tree, neither can a screen-reader user tabbing through the page. That is worth saying out loud in the pull request, because it is a product finding, not a test finding.
Which operations enforce strictness
Strictness applies to operations that must resolve to exactly one DOM element. Actions like click(), fill(), and press() throw when the locator matches more than one. Operations that are inherently about sets do not:
const saveButtons = page.getByRole('button', { name: 'Save' });
await saveButtons.count(); // fine - 3
await saveButtons.all(); // fine - three Locator handles
await expect(saveButtons).toHaveCount(2); // fine - asserts on the set
await saveButtons.click(); // throws: strict mode violation
This is why count() is such a useful debugging probe. You can measure the ambiguity before and after a change without triggering the failure you are investigating.
The order to try fixes in
Work down this list. Each step is more specific than the last, and the first four keep the locator anchored to something a user can perceive.
1. Add the accessible name
The single most common cause is a role locator without a name. getByRole('button') matches every button on the page; it is a category, not an element.
// Ambiguous: matches every button
page.getByRole('button');
// Specific: role plus accessible name
page.getByRole('button', { name: 'Save profile' });
// Exact match when one name is a substring of another
// (by default 'Save' also matches 'Save and close')
page.getByRole('button', { name: 'Save', exact: true });
// Anchored but case-insensitive: pass a regular expression
page.getByRole('button', { name: /^save$/i });
The exact option is worth understanding precisely, because the default is more permissive than most people expect. With exact unset, name matching is case-insensitive and matches a substring — so { name: 'Save' } matches "Save and close" too. Setting exact: true makes it case-sensitive and whole-string. Passing a regular expression ignores exact entirely, which is how you get anchoring and case-insensitivity together. Whitespace is normalised in every case: runs of spaces collapse, newlines become spaces, and leading and trailing whitespace is ignored.
2. Scope to the owning container
When two controls are legitimately identical because they belong to two different regions, name the region. This is the fix that matches how a user actually disambiguates them — by looking at which form they are in.
const profileForm = page.getByRole('form', { name: 'Profile' });
await profileForm.getByRole('button', { name: 'Save' }).click();
// The same idea for a table row, which is where this comes up most often
await page
.getByRole('row', { name: '[email protected]' })
.getByRole('button', { name: 'Revoke access' })
.click();
Chaining is the workhorse here. Each link narrows the search to descendants of the previous match, so the final locator stays readable and survives layout changes that an index-based locator would not.
3. Filter by content or descendant
When the container has no accessible name to grab, filter the set by something inside it. filter() accepts hasText, hasNotText, has, and hasNot:
// By text somewhere inside the element, including descendants
await page.getByRole('listitem').filter({ hasText: 'Orange' }).click();
// By the presence of a descendant matching another locator
await page
.getByRole('listitem')
.filter({ has: page.getByTestId('sale-badge') })
.getByRole('button', { name: 'Add to cart' })
.click();
// By the absence of one - useful for skipping archived rows
const activeRows = page.getByRole('row').filter({ hasNot: page.getByText('Archived') });
await expect(activeRows).toHaveCount(2);
hasText matches a substring anywhere inside the element, case-insensitively. That is convenient and occasionally too permissive; pass a regular expression when you need to anchor it.
4. Filter out the elements a user cannot see
Candidate 3 in our error was a hidden row template. Component libraries, virtualised lists, and carousels routinely keep offscreen or hidden copies in the DOM. If the duplicates are genuinely invisible:
await page.getByRole('button', { name: 'Save' }).filter({ visible: true }).click();
Use this deliberately rather than reflexively. It is the right fix for a hidden template. It is the wrong fix for a modal that is open when it should not be, because it will hide that bug rather than surface it.
5. A test id, when the UI is genuinely ambiguous
If two controls are indistinguishable to assistive technology and you cannot change that today, a test id is a legitimate, explicit escape hatch:
await page.getByTestId('profile-save').click();
Prefer adding the test id to the container rather than the control, so the locator still reads as "the save button inside the profile card" and still breaks if that button disappears:
await page.getByTestId('profile-card').getByRole('button', { name: 'Save' }).click();
When you do this, file the accessible-naming gap. A test id resolves the test; it does not resolve the ambiguity for the person using a screen reader.
6. Last resort: positional selection
Playwright's documentation is unusually blunt about this. You can opt out of strictness with first(), last(), and nth(), but the docs state these "are not recommended because when your page changes, Playwright may click on an element you did not intend."
There is a narrow case where positional selection is honest: when order is part of the specification and you assert that order.
const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
// The order is the thing under test, so state it
await expect(rows).toHaveCount(3);
await expect(rows.nth(0)).toContainText('Most recent');
await rows.nth(0).getByRole('button', { name: 'Open' }).click();
The difference between this and a drive-by .first() is the assertion above it. If sort order changes, this test fails with a message about ordering instead of silently operating on the wrong row.
Assert uniqueness before you act
The cheapest way to keep a locator honest over time is to state its cardinality where you define it. This converts a future strict-mode violation — which surfaces at the click, with a stack trace pointing at an action — into a clear assertion failure pointing at the locator.
const saveProfile = page
.getByRole('form', { name: 'Profile' })
.getByRole('button', { name: 'Save' });
await expect(saveProfile).toHaveCount(1);
await saveProfile.click();
This is also the before-and-after evidence to attach to the change. Record the match count from the failing locator and the match count from the replacement. Two numbers, both reproducible, both meaningful to a reviewer who was not involved in the debugging.
Causes worth recognising on sight
or() matching both branches
The or() combinator is a frequent and surprising source of strict-mode violations. It is designed for "whichever of these appears" — but if both appear, it matches both:
const newEmail = page.getByRole('button', { name: 'New' });
const dialog = page.getByText('Confirm security settings');
// Throws if the dialog and the button are both on screen
await expect(newEmail.or(dialog)).toBeVisible();
// The documented fix for this specific case
await expect(newEmail.or(dialog).first()).toBeVisible();
This is the one place where first() is the documented answer rather than a shortcut, because the intent really is "either of these, I do not care which."
Portals and stacked modals
Dialogs rendered through a portal land at the end of <body>, outside the DOM subtree you scoped to. A closed-but-not-unmounted modal from a previous step then matches your locator alongside the live one. Scope to getByRole('dialog') and assert there is exactly one before interacting with its contents.
Duplicate landmarks
A page with two <nav> elements and no aria-label on either gives you two navigation roles that nothing can tell apart. The test fix and the accessibility fix are the same edit: label the landmarks.
Use the trace, not guesswork
Rather than iterating on selector guesses, let the tooling enumerate the candidates for you:
# Step through with the inspector and try locators against the live DOM
npx playwright test tests/profile.spec.ts --debug
# Record a locator by pointing at the element
npx playwright codegen https://example.com
# Open the trace from a CI failure and read the DOM snapshot at the failing step
npx playwright show-trace trace.zip
In the trace viewer, the DOM snapshot at the failing action is authoritative in a way that a local reproduction is not — it is the actual page state in the environment where it failed. When a strict-mode violation only happens in CI, that snapshot almost always shows a second element that never renders locally.
Let the model propose the locator; let the assertion decide
A model is good at reading a printed candidate list and proposing a scoped replacement locator. That is a pattern-matching task over text you already have, and it is a genuine time-saver.
It is not able to tell you which of two identical Save buttons is the one your test means. That is a question about the product's intent, and the answer lives with the person who wrote the requirement. Accepting a model's guess here produces a test that passes and protects nothing — the most expensive failure mode in a suite, because it is invisible.
A workable boundary: let the model propose, and let toHaveCount(1) plus a human reviewer decide. If a proposed locator cannot be justified in one sentence that mentions the user's task, it is not ready to merge.
Apply this now
Take one strict-mode violation in your suite. Record the match count of the current locator. Rebuild it using the highest step on the list above that works — accessible name, then container scope, then filter. Record the new match count, assert it with toHaveCount(1), and put both numbers in the pull request.
If the only fix that worked was nth(), that is a finding rather than a failure. Write down what made the two elements indistinguishable, and route it to whoever owns that component.
Frequently asked questions
Can I turn strict mode off?
Not globally, and that is deliberate. Strictness is per-operation and you opt out one locator at a time with first(), last(), or nth(). A global switch would convert every future ambiguity into a silent wrong-element interaction.
Why does count() work when click() throws?
Playwright distinguishes operations that need exactly one element from operations that are about a set. count(), all(), and toHaveCount() are set operations, so multiple matches are expected rather than ambiguous.
Why does it only fail in CI?
Usually a second element renders there and not locally: a cookie banner, a feature flag defaulting differently, a slower load leaving a skeleton row mounted, or seeded data producing two rows where your local database has one. Read the DOM snapshot in the trace rather than reproducing locally.
Is getByTestId bad practice?
No, but it is a different trade. It is stable and explicit, and it is invisible to users, so it cannot tell you when a control has become unreachable by name. Use it when the UI is genuinely ambiguous, and log the naming gap when you do.
Primary references
- Playwright — Locators: strictness, the recommended locator order, filtering, and the explicit caution against
first(),last(), andnth() - Playwright — Locator API: exact signatures for
filter(),and(),or(),count(), andall() - Playwright — Other locators: when CSS and XPath are appropriate, and their trade-offs
- Playwright — Trace viewer: reading DOM snapshots from a failing CI run