PRACTICAL GUIDE / migrate Puppeteer to Playwright

Puppeteer to Playwright Migration: Replace Handles, Waits, and Browser Lifecycles

Move Puppeteer suites to Playwright by replacing handles, manual waits, shared browser state, and lifecycle hooks with locators, assertions, contexts, and fixtures.

By The Testing AcademyUpdated July 11, 202610 min read
All field guides
In this guide14 sections
  1. Choose the Destination Runtime
  2. Inventory Behavior Before APIs
  3. Replace Shared Browser Hooks with Fixtures
  4. Replace Handles with Locators
  5. Rewrite Selectors Around User Meaning
  6. Remove Waits by Responsibility
  7. Pair Navigation with Its Trigger When Needed
  8. Move Cookies and Sessions to Context Ownership
  9. Migrate Network and File Operations Intentionally
  10. Stage Cross-Browser Proof
  11. Diagnose Migration Failures
  12. Review the Tradeoffs
  13. Operational Checklist
  14. Conclusion: Migrate the Synchronization Model

What you will learn

  • Choose the Destination Runtime
  • Inventory Behavior Before APIs
  • Replace Shared Browser Hooks with Fixtures
  • Replace Handles with Locators

A Puppeteer suite does not become a Playwright suite when imports and method names compile. The important migration replaces persistent element handles with live locators, selector waits with observable assertions, shared pages with isolated browser contexts, and hand-built Jest lifecycle code with Playwright Test fixtures. Preserve the tested behavior while changing those foundations deliberately.

Start with an inventory and a characterization baseline. Identify browser launch helpers, shared login state, ElementHandle caches, $eval assertions, fixed sleeps, navigation races, cookie ownership, request interception, screenshots, and runner integrations. Migrate one coherent workflow at a time so a failure has a small set of possible causes.

Choose the Destination Runtime

The official Puppeteer migration guide covers both Playwright Library and Playwright Test. Library is a close fit for scripts that launch a browser, do work, and return a result. Test suites usually benefit from Playwright Test because it owns browser fixtures, isolation, assertions, projects, traces, retries, and reporting.

Do not mix the two lifecycle models casually. Launching a second browser inside every Playwright Test recreates infrastructure the runner already supplies. Conversely, a production scraper may not need test discovery or reporters and can remain a library program.

Animated field map

Puppeteer to Playwright Migration Flow

Inventory current behavior, rewrite synchronization and isolation boundaries, then prove the result in the required browser matrix.

  1. 01 / puppeteer inventory

    Puppeteer Inventory

    Map launch code, handles, waits, state sharing, network hooks, and reports.

  2. 02 / api mapping

    API Mapping

    Choose Playwright Test or Library and translate supported operations.

  3. 03 / locator wait rewrite

    Locator and Wait Rewrite

    Replace stale nodes and timing guesses with live queries and state assertions.

  4. 04 / context lifecycle

    Context Lifecycle

    Move shared browser state into isolated contexts, fixtures, and explicit auth state.

  5. 05 / cross browser proof

    Cross-Browser Proof

    Run characterized workflows in the browser projects required by product risk.

Inventory Behavior Before APIs

Build a migration table with the current scenario, data preconditions, expected result, synchronization mechanism, and state ownership. Record which tests depend on previous tests, a shared page, or a cached login. Those dependencies are more dangerous than simple API differences.

Capture representative passing and failing artifacts before changing the runner: screenshots for visual checkpoints, request logs for critical API boundaries, and the old report's test names. The goal is not byte-for-byte output equality. It is enough evidence to show that the migrated test still proves the same product rule.

Separate ordinary web tests from automation utilities. A PDF capture script and an end-to-end checkout test may share Puppeteer today but deserve different Playwright destinations.

Replace Shared Browser Hooks with Fixtures

Puppeteer suites often launch one browser in beforeAll, keep one page, and close it after all tests. That makes tests fast to start but lets cookies, local storage, open dialogs, route handlers, and navigation leak between cases. Playwright Test supplies a fresh context and page fixture per test by default.

TypeScript
// Typical Puppeteer and Jest ownership to retire.
import puppeteer, { type Browser, type Page } from "puppeteer";

let browser: Browser;
let page: Page;

beforeAll(async () => {
  browser = await puppeteer.launch();
  page = await browser.newPage();
});

afterAll(async () => {
  await browser.close();
});

The migrated test receives lifecycle-managed fixtures and declares only its behavior.

TypeScript
import { expect, test } from "@playwright/test";

test("account owner can update the billing email", async ({ page }) => {
  await page.goto("/settings/billing");
  await page.getByLabel("Billing email").fill("billing@example.test");
  await page.getByRole("button", { name: "Save billing settings" }).click();
  await expect(page.getByRole("status"))
    .toHaveText("Billing settings saved");
});

Move login reuse into storage state or a typed fixture, not a global page. Keep data setup isolated so parallel workers do not mutate one account's settings concurrently.

Replace Handles with Locators

An ElementHandle identifies a particular node. React, Vue, and other UI systems can replace that node between the lookup and action. A locator stores the strategy and resolves an up-to-date element whenever it is used. It also fails when an action matches multiple elements, revealing ambiguous selectors.

TypeScript
// Puppeteer: a handle and extracted value can become stale or race a render.
const saveHandle = await page.$("button.save");
if (!saveHandle) throw new Error("Save button missing");
await saveHandle.click();
const message = await page.$eval(".toast", (node) => node.textContent);
expect(message).toContain("Saved");
TypeScript
// Playwright: use user-facing identity and a retrying browser assertion.
const save = page.getByRole("button", { name: "Save settings" });
await save.click();
await expect(page.getByRole("status")).toHaveText("Saved");

Do not translate page.$(selector) to page.locator(selector).elementHandle() unless a low-level static DOM traversal truly requires a handle. That preserves the weakness while changing the spelling.

Rewrite Selectors Around User Meaning

Puppeteer CSS selectors can run in Playwright, but migration is the right time to replace structural chains with roles, labels, text, and explicit test ids. The locator should identify the control the way the scenario understands it. Keep CSS for stable implementation contracts where semantics are unavailable.

Strictness may expose a selector that always matched two responsive copies while Puppeteer silently used one. Fix the identity with an accessible name or scoped parent. Avoid .first() unless position itself is part of the requirement.

Migrate selectors in a shared page or component object only after checking each call site. A generic .submit selector may refer to different actions on several pages and need separate domain names.

Remove Waits by Responsibility

Classify each Puppeteer wait before deleting it. A wait for selector visibility before a click is often redundant because locator actions perform actionability checks. A fixed sleep should become an assertion on the state it was guessing. A wait for a background export, message delivery, or eventual API record remains necessary, but it should observe that domain condition directly.

TypeScript
// Avoid carrying timing guesses into the new suite.
await page.getByRole("button", { name: "Start import" }).click();
await expect(page.getByTestId("import-status"))
  .toHaveText("Completed", { timeout: 45_000 });
await expect(page.getByRole("row", { name: /customers imported/i }))
  .toContainText("250");

Do not adopt networkidle as a universal replacement. Applications can keep analytics, polling, or sockets active after the user-visible state is ready. A product assertion usually communicates the readiness boundary better.

Pair Navigation with Its Trigger When Needed

Some actions change the URL or create a new page. Start the observation before the action so a fast event cannot be missed, then assert the destination's user-visible state. Use waitForURL when URL change is the contract and a page event when a popup is expected.

TypeScript
await Promise.all([
  page.waitForURL(/\/orders\/ORD-[0-9]+$/),
  page.getByRole("button", { name: "Place order" }).click(),
]);

await expect(page.getByRole("heading", { name: "Order confirmed" }))
  .toBeVisible();

If the click stays on the same URL and updates content, wait on that content instead. Retaining a navigation wait for a non-navigation action creates a guaranteed timeout.

Move Cookies and Sessions to Context Ownership

Cookie methods that lived on a Puppeteer page map to the browser context in Playwright. More importantly, context ownership lets every test start with isolated cookies, local storage, and session storage. Use storageState for reusable authenticated state and separate contexts for simultaneous roles.

TypeScript
import { test } from "@playwright/test";

test("admin and customer see different order controls", async ({ browser }) => {
  const adminContext = await browser.newContext({
    storageState: "playwright/.auth/admin.json",
  });
  const customerContext = await browser.newContext({
    storageState: "playwright/.auth/customer.json",
  });

  try {
    const adminPage = await adminContext.newPage();
    const customerPage = await customerContext.newPage();
    await Promise.all([
      adminPage.goto("/orders/ORD-1042"),
      customerPage.goto("/orders/ORD-1042"),
    ]);
    // Assert role-specific controls in their respective pages.
  } finally {
    await adminContext.close();
    await customerContext.close();
  }
});

Authentication files can contain sensitive state. Keep them out of source control and regenerate them through an observable setup flow.

Migrate Network and File Operations Intentionally

Map request interception to page.route or browserContext.route based on the desired scope. Make route handlers narrow and always fulfill, continue, abort, or fall back. Translate file upload to locator setInputFiles and downloads to the download event pattern rather than manipulating file inputs through handles.

Preserve the original test's purpose. If Puppeteer intercepted every API call only to stabilize data, consider API setup or a dedicated mock server. If the interception tests client behavior under an error, keep it close to that scenario and assert both the request and visible response.

Stage Cross-Browser Proof

First run the migrated slice in one project until lifecycle and synchronization failures are resolved. Then enable the browsers required by the product's support policy. Do not claim cross-browser confidence from a config entry alone; inspect failures and retain per-project traces.

Different rendering, input, and browser APIs may expose product defects or assumptions in the old Chromium-only suite. Prefer standards-based locators and state assertions. Use browser-specific skips only for a documented unsupported capability, with an issue or rationale.

Diagnose Migration Failures

Strict mode violations reveal non-unique selectors. A locator timeout after removing a wait may indicate the test observed the wrong readiness signal, not that auto-waiting is broken. State that passes alone but fails in the suite points toward shared accounts, server data, or a fixture scope mistake.

Popup and navigation timeouts often mean the event listener started after the action or the action no longer triggers that event. Cookie differences point toward page-versus-context ownership. Unexpected route behavior may come from handlers registered at both page and context scope. Cross-browser-only failures require separating capability differences from brittle implementation assertions.

Review the Tradeoffs

A mechanical library-first migration reduces immediate runner change but delays the benefits of fixtures and reports. Moving directly to Playwright Test changes more infrastructure at once but establishes isolation earlier. Choose by suite size, current runner integrations, and ability to verify small batches.

Rewriting every selector during the first pass can expand review risk. Leaving all selectors untouched preserves known fragility. A balanced sequence migrates lifecycle and synchronization for one workflow, improves selectors that cause ambiguity, then performs broader cleanup with stable characterization tests.

Operational Checklist

  • Classify each file as a test suite or standalone automation program.
  • Record scenario behavior and shared-state dependencies before editing.
  • Replace global browser and page hooks with runner fixtures.
  • Replace ElementHandles and $eval checks with locators and assertions.
  • Remove fixed sleeps and redundant selector waits by responsibility.
  • Observe navigation, popups, and domain jobs through their real signals.
  • Move cookie and session ownership to browser contexts.
  • Scope route handlers and file operations deliberately.
  • Stabilize one project before adding the required browser matrix.
  • Compare reports, traces, and business outcomes for each migrated batch.

Conclusion: Migrate the Synchronization Model

Select the destination runtime, freeze the expected behavior, and migrate a narrow workflow from the outside inward: runner lifecycle, context isolation, locators, waits, then browser coverage. Keep explicit waits only for real navigation or domain state, and let web-first assertions describe the final condition. The migration is complete when tests no longer depend on stale nodes, shared pages, or elapsed-time guesses.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 11, 2026 / Reviewed July 11, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Playwright documentation

    Microsoft

    Canonical API, locator, fixture, browser, and test-runner behavior.

  2. 02
    Playwright best practices

    Microsoft

    Official guidance for resilient tests, isolation, and user-facing locators.

  3. 03
    WebDriver standard

    W3C

    The browser automation protocol specification used by major automation stacks.

FAQ / QUICK ANSWERS

Questions testers ask

Should I migrate Puppeteer code to Playwright Library or Playwright Test?

Use Playwright Library for a standalone automation program that owns its runner and lifecycle. For a test suite, Playwright Test usually provides the more complete target through isolated fixtures, assertions, retries, projects, traces, and reporters.

Why replace Puppeteer ElementHandles with Playwright Locators?

An ElementHandle points to one DOM node and can become stale after a render. A Locator stores the query and resolves the current element for each operation while adding strictness, auto-waiting, and web-first assertion support.

Can I delete every explicit wait during migration?

No. Remove waits that only compensate for element actionability or fixed timing, then retain explicit observation of navigation and asynchronous business state. Playwright cannot infer that a report job or replicated record is complete.

How should shared Puppeteer pages migrate?

Prefer the Playwright Test `page` fixture, which gives each test an isolated browser context. Shared pages can preserve hidden state and order dependencies, so keep them only for a deliberately serial workflow with a documented reason.

When should cross-browser projects be enabled?

First make behavior stable in one Playwright project, then add browsers for product risks that matter. Triage differences by semantics, browser capability, test assumptions, and genuine defects instead of forcing identical implementation details.