Home / Blog / Playwright vs Puppeteer
Playwright vs Puppeteer: should your script become a test suite?
Puppeteer 25.9.0 is a JavaScript library for driving Chrome or Firefox. Playwright 1.62 is a library of the same shape plus a test runner, a third browser engine and four language bindings. If your script scrapes a page, renders a PDF or takes a screenshot, keep Puppeteer. If it decides whether a product works, you want the runner.
We build Playwright suites for a living, which is the bias to hold in mind while you read this. Every claim below about either tool names the page it came from.
Puppeteer's documentation calls it a JavaScript library, and its guide list has no test runner in it. Playwright ships a library of much the same shape and a runner built on top. So a lot of what gets argued as a framework comparison is one question about your own code: when the run fails, does anything read the result?
What is the difference between Puppeteer and Playwright?
Most of the surface is shared. Both open a browser from a Node process, navigate, click, type, intercept requests and write a PNG to disk. Underneath that, the choice in front of somebody holding working Puppeteer code has three options in it rather than two.
- Puppeteer is a library. Its documentation opens with the definition:
"Puppeteer is a JavaScript library which provides a high-level API to control Chrome or
Firefox over the DevTools Protocol or WebDriver BiDi." You supply a runner if you want
one. Puppeteer's FAQ, answering whether it replaces Selenium, points instead at community
projects that make "things like testing more convenient", and names
jest-puppeteeras an example. - Playwright Library is the npm package
playwright. You pick a browser, launch it, get a page, and run the file withnode. It is the same shape of program as a Puppeteer script, and usually the cheapest move a Puppeteer user can make. - Playwright Test is the package
@playwright/test, run withnpx playwright test. Playwright's documentation draws the line itself: "Playwright Library provides unified APIs for launching and interacting with browsers, while Playwright Test provides all this plus a fully managed end-to-end Test Runner and experience." That page also enumerates what the runner puts on top: "Configuration Matrix and Projects, Parallelization, Web-First Assertions, Reporting, Retries, Easily Enabled Tracing and more".
Two npm packages, two kinds of program. playwright is a library your code calls.
@playwright/test is a program that calls your code, decides what to run, runs it in
parallel and writes a report at the end. A Puppeteer user choosing between them is choosing how
much of that job they want to keep owning.
Here is one job -- open a page at a fixed viewport and put a PNG on disk -- written three ways. Samples on this page were written against Playwright 1.62 and Puppeteer 25.9.0, and every one of them was run before publication.
// shot.mjs -- node shot.mjs
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto('https://example.com/');
await page.screenshot({ path: 'home.png' });
await browser.close();
Puppeteer 25.9.0 · JavaScript, run with node
// shot.mjs -- node shot.mjs
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto('https://example.com/');
await page.screenshot({ path: 'home.png' });
await browser.close();
Playwright 1.62 · JavaScript, run with node
// tests/home.spec.ts -- npx playwright test
import { test, expect } from '@playwright/test';
test('the home page still names the product', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto('https://example.com/');
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Example Domain');
});
Playwright 1.62 · TypeScript, run with npx playwright test
The first two are the same kind of program. You open a browser, do something, close it, and a file appears. The third opens nothing and closes nothing -- Playwright's documentation says of the runner, "No explicit close of built-in fixtures; the Test Runner will take care of it" -- and what it produces is a pass or a fail with a reason attached rather than a PNG.
Going from the first block to the second costs an import and two method names. Going to the third changes what the program is for.
When should you keep using Puppeteer?
Keep it when what you want out of the run is a file.
Puppeteer's own feature list is the best guide here, and it leads with jobs where nothing has to be decided at the end:
- Generating screenshots and PDFs. PDF generation has a guide of its own on
pptr.dev, and it is the one item on this list where Playwright is narrower. On 1.62.1page.pdf()returns a file under Chromium and throws under both Firefox and WebKit, with the same string each time:page.pdf: PDF generation is only supported for Headless Chromium. A full read of theclass-pagereference carries no browser restriction on the method at all. The message is also wrong about its own condition: the same call underheadless: falseproduced a valid PDF, so what the method needs is Chromium, not headlessness. If PDFs are the point of the run, that is a reason to keep Puppeteer. - Crawling a single-page application and generating pre-rendered content for server-side rendering.
- Capturing a timeline trace to diagnose a performance problem.
- Testing Chrome extensions.
- A script one person runs by hand, which CI will never touch and which never has to explain itself.
For every one of those, the run produces an artifact. A test runner exists to turn a run into a pass or a fail with an explanation attached, and where nobody is waiting on that verdict the runner is furniture somebody has to maintain. The same list does include UI testing and building an automated testing environment, and that entry is where this page's question starts.
Running four hundred browsers at once is a different subject. Playwright measures one real browser doing one thing well; it is not k6, JMeter or Gatling, and nothing on this site is sold as a load test.
Is Puppeteer only for Chrome?
No. Puppeteer 25.9.0 drives Chrome and Firefox. Its FAQ dates the change: "From Puppeteer v23.0.0 onwards Puppeteer provides support for both Chrome and Firefox." Its supported browsers page carries a table mapping each Puppeteer release to the Chrome for Testing and Firefox builds it works with.
// browsers.mjs -- node browsers.mjs
import puppeteer from 'puppeteer';
// WebDriver BiDi is the default protocol when Puppeteer launches Firefox.
const firefox = await puppeteer.launch({ browser: 'firefox' });
const firefoxPage = await firefox.newPage();
await firefoxPage.goto('https://example.com/');
console.log(await firefoxPage.title());
await firefox.close();
// Chrome defaults to CDP, so BiDi has to be asked for by name.
const chrome = await puppeteer.launch({ browser: 'chrome', protocol: 'webDriverBiDi' });
const chromePage = await chrome.newPage();
await chromePage.goto('https://example.com/');
console.log(await chromePage.title());
await chrome.close();
Puppeteer 25.9.0 · JavaScript, run with node
The protocol underneath differs by browser, and Puppeteer says which is which: "When launching Firefox with Puppeteer, the WebDriver BiDi Protocol is enabled by default. When launching Chrome, CDP is still used by default since not all CDP features are supported by WebDriver BiDi yet." What Playwright does with its own connection is explained separately.
The BiDi path is not finished, and the same page publishes the gaps. Various emulations,
CDP-specific features, accessibility, coverage and tracing are all listed as unsupported, along
with a long tail of individual methods, and a script that reaches one gets an
UnsupportedOperation error. Firefox support with none of that attached is not the
support Puppeteer documents.
WebKit is where the browser-coverage difference sits. Puppeteer's supported browsers page names
Chrome and Firefox and nothing else, and the cheat sheet in Playwright's migration guide carries
a row reading "WebKit is not supported by Puppeteer" against
playwright.webkit.launch(). Playwright's browsers documentation states its own
coverage: "Playwright can run tests on Chromium, WebKit and Firefox browsers as well as
branded browsers such as Google Chrome and Microsoft Edge."
That WebKit build is a rendering engine, not a device. Playwright's documentation is exact about the limit: "Playwright doesn't work with the branded version of Safari since it relies on patches. Instead, you can test using the most recent WebKit build." If you buy iOS coverage from a device cloud, a third engine in CI does not replace it. Neither library drives a native iOS or Android application, and we do not test them.
What does moving a Puppeteer script to Playwright look like?
Microsoft maintains a guide for exactly this job.
Migrating from Puppeteer on
playwright.dev holds a cheat sheet of around twenty rows mapping Puppeteer calls
onto Playwright Library calls: page.setViewport to page.setViewportSize,
page.type to locator.fill, cookies moving from the page to the browser
context. Use it. A copy of it here would be stale by the next release, and the people who
maintain that table are the people who change the API.
The guide opens on four principles: "Most Puppeteer APIs can be used as is", "The use of ElementHandle is discouraged, use Locator objects and web-first assertions instead", "Playwright is cross-browser" and "You probably don't need explicit wait". The second is the only one that changes how a file is written. Here is a Jest test built the way Puppeteer test code usually is:
// home.test.js -- npx jest
import puppeteer from 'puppeteer';
describe('the home page', () => {
let browser;
let page;
beforeAll(async () => {
browser = await puppeteer.launch();
page = await browser.newPage();
});
it('still names the product', async () => {
await page.goto('https://example.com/');
await page.waitForSelector('h1');
const text = await page.$eval('h1', e => e.textContent);
expect(text).toContain('Example Domain');
});
afterAll(() => browser.close());
});
Puppeteer 25.9.0 with Jest 30 · JavaScript
And the same check under the runner:
// tests/home.spec.ts -- npx playwright test
import { test, expect } from '@playwright/test';
test('still names the product', async ({ page }) => {
await page.goto('https://example.com/');
await expect(page.locator('h1')).toContainText('Example Domain');
});
Playwright 1.62 · TypeScript, run with npx playwright test
The Jest version reads a string out of the browser at one moment and then asserts on that string
in Node. The waitForSelector above it exists to make the moment late enough to be
true, which is why nobody deletes it. The Playwright version
never brings the text into Node. It hands the condition to the assertion, and
toContainText is in Playwright's list of matchers that "will retry until the
assertion passes, or the assertion timeout is reached". Left alone that budget is 5 seconds,
and the wait on the line above has nothing left to do.
What does Playwright cost you that Puppeteer does not?
Playwright arrives with furniture, and all of it is yours to keep working.
Three browser engines where you had one Chrome. npm i puppeteer
downloads a recent Chrome for Testing, and Puppeteer's install page publishes the size:
"~170MB macOS, ~282MB Linux, ~280MB Windows", plus a chrome-headless-shell
binary alongside it. Playwright brings down all three of its engines, and the disk they take per
machine is counted on our Playwright explainer, which is
where that sum belongs.
Browser binaries pinned to the Playwright version. The browsers documentation
states it plainly: "Each version of Playwright needs specific versions of browser binaries to
operate", and "every time you update Playwright, you might need to re-run the
install CLI command". In a container that means the image tag and the version
in package.json have to agree. Puppeteer carries a version of the same problem: its
FAQ says "Every Puppeteer release is tightly bundled with a specific browser release",
and it ships npx puppeteer browsers install for the case where a package manager
blocked the download. So this one is a difference of degree.
A config file and a runner where there was one script.
npm init playwright@latest asks whether you want TypeScript or JavaScript, what to
call the tests folder, whether to add a GitHub Actions workflow and whether to install the
browsers, then leaves behind playwright.config.ts, a tests/ folder and
an example spec. For a suite that is the right place to start. For a cron job that renders an
invoice it is a project nobody asked for.
The pinning bites on an upgrade. Somebody bumps Playwright by a
minor, the pull request is green on a laptop that has already run
npx playwright install, and the pipeline fails on an image built against the old
version with a message naming a build number nobody recognises:
browserType.launch: Executable doesn't exist at
.../chromium_headless_shell-1234/chrome-headless-shell. Playwright prints the fix inside the
same error. The habit that prevents it is moving the image tag in the same commit as the version
bump, and a two-line script never needed that habit.
A team that owns a suite pays for this furniture once and then stops noticing it. A script that renders an invoice every night pays at every upgrade and gets nothing back for it.
Should you move your Puppeteer script to Playwright?
Check these against the repository in front of you.
Move it when something reads the result: CI runs it, a failure blocks a merge, and a person has to work out why. When more than one person maintains it, or will. When you have started hand-rolling retries, fixtures or a report, which is a test runner being written by hand while a finished one sits on npm. And when you need WebKit, or a binding in Python, Java or .NET.
Keep it when the output is a file -- a PDF, a PNG, a scraped row -- and nobody is waiting on a verdict. Keep it when it is one file, one person, one browser, and it has not broken in a year. Puppeteer 25.9.0 is current, and its FAQ says the Chrome Browser Automation team maintains it.
There is a third answer for code that is a test where the rewrite will not fit in this quarter. Playwright Library is close enough to Puppeteer's shape that the migration guide's first principle is "Most Puppeteer APIs can be used as is". Swap the import, fix the method names the cheat sheet lists, keep Jest, and take the runner later when there is room for it.
If the answer here is that the code stopped being a script some time last year, the next question is what it should have been designed as, and that is how we build one from scratch.
Questions
Is Playwright better than Puppeteer?
At being a test framework, yes, because Puppeteer is not one. Puppeteer's own documentation describes a JavaScript library for controlling Chrome or Firefox, and a runner is something you bolt on. At being a library for driving a browser the two are close enough that Playwright's migration guide says most Puppeteer APIs can be used as is. So the useful question is not which tool wins. It is whether the code you have produces a file somebody looks at or a verdict somebody has to act on.
Does Puppeteer support Firefox?
Yes. Puppeteer's FAQ says support for both Chrome and Firefox arrived in v23.0.0, and in 25.9.0 you launch it with puppeteer.launch({ browser: 'firefox' }). WebDriver BiDi is the default protocol for Firefox, while Chrome still defaults to CDP. The BiDi path does not cover everything Puppeteer can do over CDP: its own page lists various emulations, CDP-specific features, accessibility, coverage and tracing among the gaps, and throws UnsupportedOperation when a script reaches one. WebKit is not supported at all.
Can I use Playwright without its test runner?
Yes. Install the playwright package, launch a browser and run the file with node, the same way a Puppeteer script runs. Playwright's documentation lists what you give up: no built-in web-first assertions, explicit closing of the context and the browser, and none of the runner's configuration matrix, parallelisation, reporting, retries or tracing. The timeouts differ too. The library defaults to 30 seconds for most operations, while under the runner most operations do not time out and the test itself carries a 30-second budget.
Is Playwright faster than Puppeteer?
We have run no benchmark of our own, so there is no figure on this page and no multiplier. Both libraries drive a real browser from a Node process, and neither project publishes a comparison against the other. A result worth quoting would take one real workload, run it under both, hold the application, the machine, the browser build and the network constant, say which of those it held, and report how often each run had to be repeated.
What to tell us about the script
What it does today, how many of them you are running, and whether anything reads the result when one of them fails. That last answer usually settles the question this article is about before anybody gets on a call.