You click a button, and Playwright stops with this:
Error: strict mode violation: getByRole('button', { name: 'Delete' })
resolved to 3 elements
The instinct is to add .first() and move on. Please do not, at least not yet. This error is Playwright doing you a favour, and the two minutes you spend reading it usually save a much worse afternoon later.
What strict mode is
Playwright refuses to act when your locator matches more than one element. That is strict mode, and it is on by default.
Think about what the alternative would be. If a tool silently used the first match, then this test:
await page.getByRole('button', { name: 'Delete' }).click();
would delete something. Maybe the row you meant. Maybe a different one. It would pass either way, and you would never know it was clicking the wrong thing until a customer told you.
That is the worst thing a test can do: pass while being wrong. Strict mode makes that failure loud instead of silent. The error is not in your way; it is the tool refusing to guess on your behalf.
Read the error before you change anything
The message tells you exactly what matched. Playwright lists the elements it found:
resolved to 3 elements:
1) <button aria-label="Delete">…</button> aka getByRole('row', { name: 'Dana Fox' }).getByRole('button')
2) <button aria-label="Delete">…</button> aka getByRole('row', { name: 'Sam Reyes' }).getByRole('button')
3) <button aria-label="Delete">…</button> aka getByRole('row', { name: 'Alex Kim' }).getByRole('button')
Read that list before touching the code. It usually answers the question by itself, and notice that Playwright even suggests a more specific locator for each match.
The four causes, and how to tell them apart
1. The thing genuinely repeats. A table with a Delete button on every row. Your locator is not wrong, it is just not specific enough yet. This is the common case.
2. Your text match is too loose. getByText matches substrings by default, so getByText('Save') also matches "Save and close" and "Autosave is on".
3. The page renders the same thing twice. Responsive layouts often render a mobile version and a desktop version of the same menu, hiding one with CSS. Both are in the page, so both count. This one surprises people, because only one is visible on screen.
4. A component is mounted twice. A dialog that is present but closed, or a list rendered inside a hidden tab panel.
The fixes, in the order to try them
First: narrow by what the element is
Usually the best answer. Give the locator the information a person would use.
// too broad
page.getByRole('button');
// specific, and readable
page.getByRole('button', { name: 'Save contact' });
Second: scope to the container it lives in
For repeated things, say which one by naming the row it belongs to. This is the correct fix for the table case, and it reads like the sentence a tester would say out loud.
await page
.getByRole('row')
.filter({ hasText: 'Dana Fox' })
.getByRole('button', { name: 'Delete' })
.click();
Chaining scopes the search: the second part only looks inside the row the first part found. Two rows can never be confused again.
Third: make the text match exact
If the cause is a substring match:
page.getByText('Save', { exact: true });
Fourth: filter out what you did not mean
When a hidden duplicate is the problem, ask for the one a user can see:
page.getByRole('navigation').filter({ visible: true }).getByRole('link', { name: 'Contacts' });
What about .first()?
.first(), .last() and .nth(1) all switch strict mode off for that locator. Sometimes that is correct: if you genuinely want "the first result in a list of search results", say so, and .first() is honest.
But reaching for it to make an error go away converts a real warning into a test that acts on an arbitrary element. You have not fixed anything. You have told the tool to stop telling you.
A useful test before using it: can you explain, in one sentence, why any of the matches would be acceptable? If yes, .first() is fine. If you are only using it to get past the error, the locator is the thing to fix.
The habit worth building
When you see this error, ask "which one did I mean, and how would a person describe it?" A person would not say "the first Delete button". They would say "the Delete button on Dana Fox's row". Write that, and the ambiguity disappears for good, including after someone reorders the table.
That is also why strict mode makes suites more durable over time. Locators written to satisfy it describe intent, and intent survives redesigns in a way positions never do.
Remember this
Strict mode is a feature. It fails loudly now so your test cannot quietly act on the wrong element later. Narrow by role and name, scope to the container, use exact text when needed, and keep .first() for the cases where any match really would do.
The Playwright track goes deeper on locator strategy and how to make an app testable: keeping selectors in one place.
Reference: Playwright locators.