Chapter 17 – Playwright Advanced Tips and Optimization

As your Playwright test suite grows, it’s essential to make it fast, stable, and maintainable. Slow or flaky tests not only frustrate developers but also undermine confidence in automation. In this chapter, we’ll explore how to optimize Playwright tests for performance and reliability, and how to design scalable test architectures for large projects.

We’ll cover both test optimization techniques and advanced framework design strategies, helping you build professional-grade Playwright frameworks ready for enterprise-level automation.

1. Why optimization matters

When your test suite expands from a handful of tests to hundreds, issues like slow execution, flakiness, and duplicated setup logic become major bottlenecks.

Optimizing Playwright involves improving:

  • Speed – by reducing redundant actions and running tests in parallel.
  • Stability – by handling waits, selectors, and test data correctly.
  • Scalability – by designing modular, reusable test components.

2. Speeding up test execution

2.1 Run tests in parallel

Playwright runs tests in parallel by default, but you can control the number of workers.

export default {
  workers: 'auto', // Uses all available CPU cores
};

Run with custom workers:

npx playwright test --workers=4

Tip: For CI pipelines, balance worker count based on available resources to avoid overloading containers.

2.2 Reuse browser contexts

Opening and closing browsers repeatedly slows down tests. Instead, reuse contexts within the same browser instance.

export default {
  use: {
    launchOptions: {
      headless: true,
    },
    contextOptions: {
      viewport: { width: 1280, height: 720 },
    },
  },
};

Or explicitly reuse the browser across tests:

const context = await browser.newContext();
const page = await context.newPage();

2.3 Disable unnecessary resources

You can block non-essential assets like images, fonts, or analytics to reduce network overhead.

await page.route('**/*', route => {
  return route.request().resourceType() === 'image'
    ? route.abort()
    : route.continue();
});

2.4 Use headless mode

Running tests in headless mode improves speed significantly:

export default {
  use: { headless: true },
};

Headed mode is best reserved for debugging or visual validations.

3. Improving test stability

3.1 Use reliable selectors

Avoid flaky selectors that depend on layout or text changes. Prefer stable data-testid attributes.

await page.locator('[data-testid="submit-button"]').click();

Avoid: page.locator('button:nth-child(3)')

3.2 Avoid fixed waits

Never use static timeouts like waitForTimeout(3000). Instead, rely on Playwright’s auto-waiting or explicit conditions.

await expect(page.locator('#status')).toHaveText('Done');

3.3 Use retries for flaky tests

Playwright allows automatic retries for unstable environments (like CI).

export default {
  retries: 2,
};

Retries are especially useful for tests involving network delays or external APIs.

3.4 Isolate test data

Flaky tests often result from shared or inconsistent test data. Use fixtures to isolate data setup.

import { test as base } from '@playwright/test';

export const test = base.extend({
  user: async ({}, use) => {
    await use({ name: 'Test User', email: 'test@example.com' });
  },
});

This ensures each test runs with a clean, independent dataset.

4. Structuring scalable Playwright framework

A scalable Playwright framework should follow clean design principles. Here’s an ideal folder structure:

/playwright-tests
 ┣ tests
 ┃ ┣ login.spec.js
 ┃ ┣ dashboard.spec.js
 ┃ ┗ api.spec.js
 ┣ pages
 ┃ ┣ LoginPage.js
 ┃ ┣ DashboardPage.js
 ┣  fixtures
 ┃ ┗ authFixture.js
 ┣  utils
 ┃ ┣ helpers.js
 ┃ ┣ config.js
 ┣ reports
 ┣ playwright.config.js
 ┗ package.json

4.1 Page Object Model (POM)

Use the Page Object Model (POM) to keep selectors and logic organized.

export class LoginPage {
  constructor(page) {
    this.page = page;
    this.usernameField = page.locator('#username');
    this.passwordField = page.locator('#password');
    this.loginButton = page.locator('#login');
  }

  async login(username, password) {
    await this.usernameField.fill(username);
    await this.passwordField.fill(password);
    await this.loginButton.click();
  }
}

This makes your tests readable and reduces duplication.

4.2 Use fixtures for reusable setup

Fixtures let you define reusable browser or data contexts.

export const test = base.extend({
  loggedInPage: async ({ browser }, use) => {
    const context = await browser.newContext();
    const page = await context.newPage();
    await page.goto('/login');
    await page.fill('#username', 'user');
    await page.fill('#password', 'pass');
    await page.click('#submit');
    await use(page);
    await context.close();
  },
});

4.3 Use configuration files wisely

Centralize configurations in playwright.config.js.

export default {
  testDir: './tests',
  timeout: 30 * 1000,
  use: {
    baseURL: 'https://app.example.com',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
};

4.4 Modularize utilities

Keep utility functions (like random data generators, API calls, and assertions) in /utils/.

export function generateRandomEmail() {
  return `user_${Date.now()}@example.com`;
}

5. Reducing flakiness with advanced waiting strategies

Playwright already auto-waits for most conditions, but sometimes explicit waiting is necessary.

await page.waitForLoadState('networkidle');
await expect(page.locator('#confirmation')).toBeVisible();

Use waitForFunction() for custom dynamic waits:

await page.waitForFunction(() => window.appReady === true);

Avoid arbitrary timeouts – always wait for meaningful states.

6. Using API testing to speed up setup

Instead of UI logins, use API requests to authenticate faster.

const token = await request.post('/api/auth', {
  data: { username: 'user', password: 'pass' },
});
await context.addCookies([{ name: 'token', value: token, url: 'https://app.example.com' }]);

This skips slow UI steps, improving execution time dramatically.

7. Profiling and performance analysis

You can measure test execution times to find bottlenecks.

const start = Date.now();
await page.goto('/dashboard');
const end = Date.now();
console.log(`Load time: ${end - start}ms`);

Or integrate with tracing tools:

npx playwright show-trace trace.zip

Combine this with trace: 'on' to analyze performance visually.

8. Scaling with parallel and shard execution

For large suites, split tests across multiple machines.

Example command:

npx playwright test --shard=1/3  # Run first third of tests

This allows CI systems to distribute test workloads efficiently.

9. Optimizing retries and timeouts

  • Set short timeouts for fast checks, longer for network-heavy tests.
  • Use retries for transient failures only.
export default {
  timeout: 30000, // Global timeout
  expect: { timeout: 5000 }, // Assertion timeout
  retries: 1,
};

Avoid overusing retries – it may mask real issues.

10. Version control best practices

  • Exclude Playwright test artifacts from version control:
# .gitignore
/playwright-report
/test-results
/trace.zip
  • Commit test data, configs, and page objects for reproducibility.
  • Keep tests atomic, independent and repeatable.

11. Advanced debugging and logging

Enable detailed logging to diagnose issues.

DEBUG=pw:api npx playwright test

Or use custom console logging:

page.on('console', msg => console.log('BROWSER LOG:', msg.text()));

12. Test data management strategies

Use dynamic data generation to avoid conflicts:

import faker from 'faker';
const user = { email: faker.internet.email(), name: faker.name.firstName() };

Or maintain reusable datasets in JSON:

const users = require('./data/users.json');

Always clean up created test data after tests finish.

13. Integrating linting and pre-commit checks

Maintain code quality by integrating linting and pre-commit hooks.

npm install eslint husky lint-staged --save-dev

package.json:

"husky": {
  "hooks": {
    "pre-commit": "npm run lint"
  }
}

This ensures consistent, clean test code before every commit.

Best practices summary

  1. Use Page Object Model and fixtures for scalability.
  2. Avoid fixed waits, rely on Playwright’s auto-wait.
  3. Use parallel and shard execution to speed up large suites.
  4. Block unnecessary resources for faster runs.
  5. Keep selectors stable and descriptive.
  6. Isolate test data using fixtures.
  7. Regularly monitor test durations to spot bottlenecks.
  8. Store artifacts separately to keep repositories clean.
  9. Automate cleanup and setup for consistency.
  10. Use trace viewer to debug slow or flaky tests.

Exercise

Task 1: Implement reusable fixtures for logged-in sessions.
Task 2: Use data-driven testing to run scenarios dynamically.
Task 3: Enable parallel and shard execution in your CI.
Task 4: Optimize selectors and replace static waits.
Bonus: Measure test performance using timestamps and trace viewer.

Cheat Sheet

OptimizationTechnique
Faster testsParallel workers, headless mode
StabilityReliable selectors, retries, isolated data
SpeedBlock non-essential resources
ReusabilityPage Object Model, fixtures
DebuggingTrace Viewer, console logs
DataUse Faker or JSON datasets
CI optimizationShard tests, collect artifacts

Summary

In this chapter, you learned how to:

  • Speed up Playwright tests using parallel execution and efficient design.
  • Stabilize tests with smart waits, retries, and isolated data.
  • Organize your framework with scalable patterns like POM and fixtures.
  • Optimize CI pipelines with sharding and performance monitoring.