Introduction
By day 80 of your Playwright journey, you've written dozens of test suites and probably hit a few walls. Playwright is powerful and reliable, but its error messages can sometimes feel cryptic when you're deep in debugging mode. We've compiled the most common Playwright errors and fixes to help you recognize patterns, diagnose root causes, and get back to shipping tests faster.
In this guide, we'll walk through four critical error scenarios you'll encounter in production automation: strict mode violations, timeout issues, target closure, and missing browser installations. Each section includes real code examples and actionable fixes drawn from our team's experience building and maintaining test suites across e-commerce, SaaS, and trading platforms.
Strict mode violation: locator matched many elements
Playwright's strict mode is one of its best safety features, yet it's also one of the first walls new test authors hit. When you write a locator that matches multiple elements on the page, Playwright throws this error by default:
Error: strict mode violation: locator('button').click is ambiguous and matched 2 elements
This happens because Playwright wants you to be explicit. If your locator could match five buttons, Playwright can't guess which one you meant. Here's a real scenario: you're testing a checkout flow and you write a quick test to click "Continue":
await page.locator('text=Continue').click();
On your local environment, there's one Continue button. In staging, the page loads a footer with another Continue link. Now your test fails in CI with a strict mode violation.
Step-by-step fix
First, identify exactly which element you need. Use the inspector or add a test log to see how many elements match:
const buttons = await page.locator('text=Continue').all();
console.log(`Matched ${buttons.length} elements`);
Next, make your locator more specific. Instead of relying on text alone, combine it with role or scope:
// Good: scoped within the form
await page.locator('form').locator('text=Continue').click();
// Better: use role and exact text
await page.getByRole('button', { name: 'Continue' }).click();
// Best: combine role and a parent container
await page.locator('[data-testid="checkout-form"]')
.getByRole('button', { name: 'Continue' })
.click();
Use data attributes for stability. If your team controls the markup, add data-testid or similar attributes to elements you'll test. This removes ambiguity and makes tests resilient to styling or layout changes:
// In your component
<button data-testid="checkout-continue">Continue</button>
// In your test
await page.getByTestId('checkout-continue').click();
If you truly need to interact with multiple elements, use all() explicitly and iterate:
const buttons = await page.locator('button:has-text("Add")').all();
for (const btn of buttons) {
await btn.click();
}
Timeout waiting for locator: wrong locator or hidden element
The "Timeout waiting for selector" error is the second most common headache. It usually means one of three things: your locator doesn't exist, it's hidden, or the page is still loading.
Error: Timeout 30000ms exceeded waiting for locator('input[name="email"]')
Picture this: you're testing a modal that appears after a button click. Your test clicks the button, then immediately tries to fill the email input inside the modal. But the modal has a 300ms CSS animation, and your locator search doesn't wait long enough.
Step-by-step fix
First, verify the locator is correct. Open the dev tools and manually search for it in the browser console:
// In browser console
document.querySelector('input[name="email"]') // null or an element?
If it returns null, your selector is wrong. Test it in Playwright's inspector:
npx playwright test --debug
// Use the locator tool and try your selector
If the element exists but Playwright still times out, it's likely hidden. Check visibility:
const element = await page.locator('input[name="email"]');
const isVisible = await element.isVisible();
console.log(`Visible: ${isVisible}`);
If it's hidden, wait for it to become visible before interacting:
// Increase timeout for this specific locator
await page.locator('input[name="email"]').waitFor({ state: 'visible', timeout: 5000 });
await page.locator('input[name="email"]').fill('test@example.com');
// Or wait for the parent container first
await page.locator('[data-testid="email-modal"]').waitFor({ state: 'visible' });
await page.locator('input[name="email"]').fill('test@example.com');
For dynamic content (spinners, lazy-loaded data), wait for the right signal. Don't wait for a loading state to disappear; wait for the actual content to appear:
// Bad: waiting for absence of a spinner
await page.waitForSelector('.spinner', { state: 'hidden' });
// Good: waiting for the data
await page.waitForSelector('[data-testid="product-list"] .product-item');
If you're working with async operations (API calls, navigation), use waitForLoadState:
await page.goto('https://example.com/products');
await page.waitForLoadState('networkidle');
await page.locator('[data-testid="filter-button"]').click();
Target closed: page closed before action finished
This error occurs when Playwright tries to perform an action on a page that has already closed. It's common in multi-page workflows or when navigation happens unexpectedly:
Error: Target page, context or browser has been closed
Imagine a payment flow: you click "Submit," the page processes the request, and then redirects. But your test is still trying to interact with the original page after it's been closed.
Step-by-step fix
Check if your page or context is being closed prematurely. Add defensive checks before actions:
if (page.isClosed()) {
console.error('Page was closed unexpectedly');
// handle or fail gracefully
}
If you're working with popups or new pages, keep a reference and manage their lifecycle:
// Wait for the popup to open
const popupPromise = page.waitForEvent('popup');
await page.locator('[data-testid="open-login"]').click();
const loginPage = await popupPromise;
// Use loginPage, then close it explicitly
await loginPage.locator('input[name="password"]').fill('secret');
await loginPage.locator('button[type="submit"]').click();
await loginPage.close();
For navigation that closes the original page, wait for the new page or context before continuing:
// This navigation will close the current page and open a new one
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle' }),
page.locator('[data-testid="checkout-submit"]').click()
]);
// If using a new page/context:
const [newPage] = await Promise.all([
context.waitForEvent('page'),
page.locator('[data-testid="external-link"]').click()
]);
await newPage.close();
If you're running parallel tests, ensure test isolation. Each test should have its own page or context:
test('checkout flow', async ({ page, context }) => {
// Use page and context from fixtures
// They're automatically created and cleaned up per test
});
test('login flow', async ({ page, context }) => {
// Separate page and context instance
});
Browser not installed: run playwright install
This error appears when you try to run tests but Playwright's browser binaries haven't been downloaded:
Error: Chromium is not installed. Run `npx playwright install`
It's straightforward but easy to miss in CI/CD pipelines or when onboarding new team members.
Step-by-step fix
Install browsers locally:
npx playwright install
If you only need specific browsers:
npx playwright install chromium
npx playwright install firefox
In CI pipelines, install system dependencies before running tests. For GitHub Actions:
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run tests
run: npm run test:e2e
The --with-deps flag installs required system libraries (libatk, libgbm, etc.) automatically on Linux, saving setup time.
For Docker, bundle browser installation into your image to avoid repeated downloads:
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
RUN npx playwright install --with-deps
COPY . .
CMD ["npm", "run", "test:e2e"]
Common mistakes
- Not using data attributes: Relying solely on class names or element structure makes tests brittle. Add stable selectors to your components from the start.
- Ignoring Playwright's built-in locator methods:
getByRole(),getByLabel(), andgetByTestId()are more resilient than CSS or XPath in most cases. - Setting global timeouts too high: A 30-second global timeout can hide real issues. Use specific, scoped timeouts for individual actions instead.
- Assuming synchronous page behavior: Always use
waitForNavigation(),waitForLoadState(), or event listeners for async operations. Never guess timing. - Running tests in headed mode without understanding why: If a test passes headed but fails headless, it's usually a race condition or missing wait state. Debug systematically.
Key takeaways
- Strict mode violations force you to write explicit, maintainable locators. Embrace
getByRole()andgetByTestId()over generic selectors. - Timeout errors are rarely random; they indicate hidden elements, wrong selectors, or missing wait signals. Use the inspector and logs to diagnose.
- Page closure errors happen in multi-page workflows. Keep references to pages, wait for navigation signals, and use test fixtures for isolation.
- Browser installation is a one-time setup step, but automate it in CI and Docker to prevent surprises on the team.
- Defensive coding and explicit waits reduce flakiness more than increasing timeouts. Build visibility checks and load-state awareness into your tests from the start.






Comments
Loading comments…