Smoketest

How to Fix the Playwright Strict Mode Violation

Your test just died with 'strict mode violation: locator resolved to 2 elements' and the click never happened. The error means your locator matched more than one element, and Playwright refuses to guess. Here are the four fixes in preference order, plus why the error message itself usually contains the answer.

10 min read

Your test was passing yesterday. Today it fails with strict mode violation: getByRole('button', { name: 'Save' }) resolved to 3 elements, and the click you wrote never happened. Someone added a second Save button, or a nav bar, or a modal, and now Playwright won't touch any of them.

A strict mode violation means your locator matched more than one element, and Playwright refuses to guess which one you meant. The fix, in preference order: tighten the accessible name with exact: true, narrow with .filter(), scope through a parent getByRole(), or use .first()/.nth() only when position is actually the semantics.

Everything in this post was verified on Playwright v1.61.1, the current release as of mid-2026.

The error, line by line

Here is the full runtime output from a page with this markup: <nav><button>Save</button></nav><main><button>Save</button><button>Save draft</button></main>.

Code
locator.click: Error: strict mode violation: getByRole('button', { name: 'Save' }) resolved to 3 elements:
    1) <button>Save</button> aka getByRole('navigation').getByRole('button', { name: 'Save' })
    2) <button>Save</button> aka getByRole('main').getByRole('button', { name: 'Save', exact: true })
    3) <button>Save draft</button> aka getByRole('button', { name: 'Save draft' })

Call log:
  - waiting for getByRole('button', { name: 'Save' })

Reading it top to bottom:

  • The first line echoes your locator and the match count. Three buttons satisfied getByRole('button', { name: 'Save' }).
  • The numbered lines show each matching element's HTML preview, followed by aka and a locator. Those aka locators are generated by Playwright's own selector engine, and each one uniquely identifies that specific element. If you can see which of the numbered elements you wanted, the fix is often literally copy-paste: take its aka locator and use it. The error lists at most 10 matches, then truncates with a ... line.
  • The call log shows what Playwright was waiting for when it gave up.

The most useful and least advertised fact in this whole error is that the aka lines are suggested fixes, not just diagnostics. Match 2 above, getByRole('main').getByRole('button', { name: 'Save', exact: true }), is exactly the locator we'll end up writing by hand in fix 4.

One more behavior worth knowing: a strict violation does not burn your full timeout. Once multiple matches exist in the DOM, the violation surfaces as a non-retriable error inside the action retry loop, so both click() and web-first assertions fail in milliseconds instead of hanging for the configured timeout. (If the duplicate element only appears after a later render, the action retries normally until the page settles, so don't rely on the fast failure as a timing guarantee.)

The assertion variant of the error wraps the same message in a different header:

Code
expect(locator).toBeVisible() failed

Locator: getByRole('button', { name: 'Save' })
Expected: visible
Error: strict mode violation: getByRole('button', { name: 'Save' }) resolved to 3 elements:
    ...

It is the same violation with the same fixes.

Why strict is the default, and why your old suite never hit this

From the Playwright locators documentation: "Locators are strict. This means that all operations on locators that imply some target DOM element will throw an exception if more than one element matches."

The reasoning is straightforward. If your locator matches three buttons and Playwright silently clicked the first one, your test would pass while exercising the wrong element. Worse, it would keep passing until a layout change reordered the matches, at which point it would start clicking something else entirely, still green. Strict mode converts that silent misfire into a loud, immediate failure with a list of candidates. It is a feature.

Operations that legitimately work on multiple elements are exempt. await page.getByRole('button').count() is fine with many matches, as are .all() and expect(locator).toHaveCount(n). Strictness only applies to operations that imply a single target, like click(), fill(), or toBeVisible().

If your team migrated an older suite and strict violations "suddenly appeared," here is why: the deprecated page-level methods like page.click(selector) are non-strict by default. They accept a { strict: true } option, but almost nobody passed it. Locators flipped the default. Your old suite wasn't cleaner, it was quietly clicking the first match all along.

Fix 1: tighten the accessible name

The name option in getByRole matches case-insensitively and by substring by default, per the getByRole docs. That substring behavior is the source of a lot of surprise matches: { name: 'Save' } matches "Save draft" too.

The first move is exact: true (available since v1.28), which makes the name match case-sensitive and whole-string:

save.spec.ts

TypeScript
// Before: 3 matches ("Save" in nav, "Save" in main, "Save draft")
await page.getByRole('button', { name: 'Save' }).click();

// After: exact match drops "Save draft"
await page.getByRole('button', { name: 'Save', exact: true }).click();

On our example page this is progress but not a fix. The exact match eliminates "Save draft", but the nav and main buttons are both named exactly "Save", so the locator still resolves to 2 elements and still throws. That's the honest limit of fix 1: it handles substring collisions, and it does nothing for genuine duplicates. When two elements share the same exact accessible name, you need one of the fixes below. A regular expression name is also an option for pattern matching, though note exact is ignored when the name is a regex.

Fix 2: narrow with .filter()

.filter() (available since v1.22) is the right tool when the duplicates live in repeated structures: table rows, list items, cards. Each repeated block contains an identical button, and the thing that distinguishes them is nearby text.

Given this markup: <ul><li><span>Apple</span><button>Buy</button></li><li><span>Banana</span><button>Buy</button></li></ul>

buy.spec.ts

TypeScript
// Before: resolved to 2 elements
await page.getByRole('button', { name: 'Buy' }).click();

// After: filter the row by its text, then find the button inside it
await page
  .getByRole('listitem')
  .filter({ hasText: 'Banana' })
  .getByRole('button', { name: 'Buy' })
  .click();

This reads the way a human would disambiguate: "the Buy button in the Banana row." It also survives reordering. If Banana moves to the top of the list tomorrow, the test still clicks the right button, which is exactly what an index-based fix would get wrong.

Playwright's own error output for the broken version suggests this shape. The aka line for the first match reads getByRole('listitem').filter({ hasText: 'AppleBuy' }).getByRole('button'). The generator concatenates the row's text without spaces, so treat the suggestion as a template and swap in the distinctive part yourself.

.filter() takes more than hasText. Check your Playwright version before using the newer options: has (a locator that must exist inside the match) shipped with .filter() in v1.22, hasNot and hasNotText arrived in v1.33, and visible: true in v1.51. All are documented on the Locator class page. The visible filter is handy for the common case where a hidden mobile menu duplicates every button in the desktop nav.

Fix 3: .first() and .nth(), with justification only

.first(), .last(), and .nth(index) (all available since v1.14, nth is zero-based) make the violation go away instantly, which is why they're the most common fix on Stack Overflow and the most common source of tests that pass while clicking the wrong thing.

The docs are unusually blunt about this. Per the strictness section, these methods "are not recommended because when your page changes, Playwright may click on an element you did not intend."

There is one legitimate use: when position genuinely is the semantics. "The first row of a table sorted by date" is a positional claim, so .first() expresses it correctly. Even then, pin the set size first so a rendering change can't silently shift what "first" means:

table.spec.ts

TypeScript
const rows = page.getByRole('row');
await expect(rows).toHaveCount(6);
await rows.first().click();

If you can't finish the sentence "I want the Nth one because...", you don't want .nth(), you want fix 2 or fix 4.

Fix 4: scope through a parent getByRole

Back to the Save button page, where exact: true still left us with 2 matches. The two remaining buttons are identical in name and role. What distinguishes them is where they live: one in the navigation landmark, one in main content. So say that:

save.spec.ts

TypeScript
// Still 2 matches after exact: true
await page.getByRole('button', { name: 'Save', exact: true }).click();

// Scoped to the main landmark: 1 match, clicks
await page
  .getByRole('main')
  .getByRole('button', { name: 'Save', exact: true })
  .click();

This is the exact locator the error's aka line handed us in the first section. Chaining locators scopes the search: getByRole('main') resolves to the <main> landmark, and the second getByRole only searches inside it.

Scoping through landmark roles (navigation, main, banner, dialog) is the structural fix, and it aligns with how Playwright wants you to think about pages in general. The docs put it directly: "The getByRole locator reflects how users and assistive technology perceive the page." A screen reader user disambiguates the two Save buttons the same way, by which landmark they're in. For combining conditions rather than nesting them, locator.and() and locator.or() exist too.

When the test is right and the page is wrong

Sometimes the correct response to a strict mode violation is not a better locator. It's a bug report against the page.

If two buttons in the same context have the identical accessible name, a screen reader user browsing the page's element list hears "Save button" twice with nothing to tell them apart. Playwright had no way to pick between them, and neither does a person navigating by rotor. The strict violation just made that ambiguity visible in CI.

To be precise about the standards question: duplicate accessible names are not automatically a WCAG failure. Success Criterion 2.4.6 (Headings and Labels, Level AA) requires that labels describe topic or purpose, not that they be unique. So treat a duplicate-name violation as an ambiguity smell worth flagging, not a compliance finding. In practice, giving the second button a distinct accessible name ("Save draft" instead of a second "Save") fixes your test and the screen reader experience in one change. Strict mode is, in effect, a free ambiguity linter that runs on every test.

Read the aka lines before writing anything

The debugging order that wastes the least time:

  1. Read the numbered matches. Identify which element you actually meant.
  2. Look at its aka locator. It is unique by construction, and often it's the finished fix.
  3. If the aka locator leans on generated text or looks fragile, translate its shape into your own terms: exact: true for substring collisions, .filter({ hasText }) for repeated rows, a parent getByRole for landmark duplicates.
  4. Reach for .first()/.nth() only when you can justify the position, and pin the count with toHaveCount when you do.

The error message is longer than most because it's trying to hand you the solution. Most of the time, it succeeds.

FAQ

What does "strict mode violation" mean in Playwright?

It means a locator used in a single-element operation, like click() or toBeVisible(), matched more than one element on the page. Playwright throws instead of guessing which one you meant. The error lists every matching element (up to 10) along with a unique suggested locator for each, labeled aka.

How do I fix "locator resolved to 2 elements"?

Make the locator match exactly one element. In preference order: add exact: true to the accessible name, narrow with .filter({ hasText }) when duplicates sit in repeated rows or cards, scope through a parent like getByRole('main'), or use .first()/.nth() only when position is genuinely the meaning.

Can I turn off strict mode in Playwright?

Not for locators, and you shouldn't want to. Strictness is built into the locator API. The deprecated page-level methods like page.click(selector) are non-strict by default, which is why older suites never hit this error, but they were silently clicking the first match. Multi-element operations like count(), all(), and toHaveCount() work fine on many matches.

Is it bad to use .first() in Playwright?

The docs mark .first(), .last(), and .nth() as not recommended, because a page change can silently shift which element they select. They're acceptable when position is the actual semantics, like the first row of a sorted table. Guard them with expect(rows).toHaveCount(n) so the set size is pinned before you index into it.

Why does getByRole match "Save draft" when I asked for "Save"?

Because the name option matches by substring and case-insensitively by default. "Save" is a substring of "Save draft", so both buttons match. Pass exact: true (available since Playwright v1.28) for a case-sensitive, whole-string match. Note that exact is ignored when you pass a regular expression as the name.

Share this post

The QA column is no longer where the sprint goes to die.

Move one ticket. Watch it come back tested. Then decide.

Keep reading

All posts →