Home / Blog / Playwright architecture
Playwright architecture: how Playwright drives a browser
Playwright launches the browser and drives it over a pipe, where the W3C WebDriver specification defines one HTTP request per command. The websocket in Playwright's own documentation belongs to a different link — a client attaching to a separate server process — and the local path every ordinary run takes uses no port at all. Attaching over CDP is Chromium-only and documented as lower fidelity. Firefox and WebKit are patched builds Playwright ships and pins to its version. Tests run in worker processes, each with its own browser.
There is no architecture page on playwright.dev. docs/why-playwright returns a 404,
and the mechanism sits in pieces across the browsers guide, the isolation guide, the auto-waiting
guide and the BrowserType API reference. Every claim below names the page it came
from, so the sentences you plan to repeat in a design review are ones you can check before you
repeat them.
What runs when you type npx playwright test?
The process you started is the test runner. Under it are worker processes, and the parallelism guide is specific about what those are: "All tests run in worker processes. These processes are OS processes, running independently, orchestrated by the test runner." The same page says what each one does first: "All workers have identical environments and each starts its own browser."
Under a browser are contexts, and under a context are pages.
Browsers therefore scale with workers, not with tests and not with spec files. Eight workers means eight browser processes, before any test launches one of its own. A worker that has just failed a test does not carry on: "Workers are always shutdown after a test failure to guarantee pristine environment for following tests", so the next test in that file runs under a worker that has started a browser of its own.
How many workers to run, and how to split a suite across CI jobs with --shard, is
covered in parallel runs and sharding.
There is no driver process in the Node tree. The question comes up because the other language bindings are often described as talking to a driver, and it is reasonable to assume JavaScript has the same thing with the seam hidden. We read the live process table on Windows with Playwright 1.62.1 while a browser was open: under the runner the tree is exactly three deep — runner, worker, browser. The browser's parent process is the worker. There is no Node process between them, and none anywhere on the machine running a driver entry point.
The same holds without the runner. A plain script that calls
chromium.launch() gets the browser as a direct child of itself, with no
intermediate process at all. In JavaScript the implementation is a module in the process that
imported it, so the thing the other bindings have to spawn is simply a function call here.
Why it matters when you are reading a stack trace or a CI log: there is no third party to blame and no fourth process to kill. Everything between your test and the browser is in the worker, so a crash in that layer is a crash in the worker, and cleaning up a run means cleaning up workers and browsers and nothing else.
How does Playwright talk to the browser?
Two protocols, each with its own specification, and the whole speed argument sits in the gap between them.
The W3C WebDriver specification defines a protocol over HTTP. Section 6: "WebDriver remote ends must provide an HTTP compliant wire protocol where the endpoints map to different commands". Section 6.2 fixes the unit: "Each HTTP request with a method and template defined in this specification represents a single command, and therefore each command produces a single HTTP response." Section 6.3 describes the far end as "an HTTP server reading requests from the client and writing responses, typically over a TCP socket".
Read that precisely, because the person on your team who knows the spec will. The request and the response come one per command, not the socket: §6.3 models the transmission as a connection and puts the details of how that connection is established out of scope, which leaves keep-alive to the implementation. The per-command cost under discussion is an HTTP request, a response and the serialisation of both.
Playwright's side is documented in the BrowserType reference.
The connect() method
"attaches Playwright to an existing browser instance created via BrowserType.launchServer in
Node.js", and the endpoint it attaches to comes from browserServer.wsEndpoint(),
which the BrowserServer
reference calls the "Browser websocket endpoint which can be used as an argument to
browserType.connect() to establish connection to the browser". One websocket, held for as long
as the client holds the browser.
That pair is the only place in the public API where this connection is a value you can hold and print:
import { chromium } from 'playwright';
async function main() {
const server = await chromium.launchServer();
console.log(server.wsEndpoint()); // the endpoint every later command travels over
const browser = await chromium.connect(server.wsEndpoint());
const page = await browser.newPage();
await page.goto('https://example.com');
console.log(await page.title());
await browser.close();
await server.close();
}
main();
Playwright 1.62 · TypeScript
Every command that follows — the navigation, the title read, the close — travels over the
websocket printed on the second line. The documentation describes connect() as
attaching to a browser created via launchServer in Node.js, and the two ends have to
agree on a version: "The major and minor version of the Playwright
instance that connects needs to match the version of Playwright that launches the browser (1.2.3
→ is compatible with 1.2.x)."
Most runs never call launchServer. npx playwright test launches the
browser for you, and no playwright.dev page read for this article says what that path puts on the
wire. The operating system says. Launch each engine on 1.62.1 and read the browser process's own
command line: Chromium is started with --remote-debugging-pipe, headless and headed
alike; Firefox with -juggler-pipe; WebKit with --inspector-pipe.
All three are pipes, and none of them opens a port. Chromium's flag is the Chrome
DevTools Protocol one, so on this link Chromium is driven over CDP — over a pipe, not over
--remote-debugging-port.
Which makes the websocket above the exception. Both links get called the transport, and they
are not the same link. Start a browser with
launchServer, connect to it, then ask the operating system which process is listening
on the port it printed: it is the Node process. The browser is listening on nothing, and it is
still being driven over --remote-debugging-pipe. The websocket runs between your
client and the Playwright server and exists only when there is a server. The link between
Playwright and the browser is a pipe in both arrangements.
One connection to one browser is still one browser doing one thing. Removing a per-command round trip does not turn a browser into a load generator: Playwright is not k6, JMeter or Gatling, and we do not sell load testing in it. What the difference is worth on a real suite depends on how many commands your tests issue, which is why the size of a move is estimated by reading the suite — how we scope and run a migration.
Does Playwright use CDP?
Yes for Chromium, in two documented places, and no for Firefox and WebKit.
The first place is browserType.connectOverCDP(), which
the BrowserType
reference introduces as a method that "attaches Playwright to an existing browser instance
using the Chrome DevTools Protocol". A note under it ranks the two connections against each
other: "This connection is significantly lower fidelity than the Playwright protocol
connection via browserType.connect(). If you are experiencing issues or attempting to use advanced
functionality, you probably want to use browserType.connect()." The note above it draws the
boundary: "Connecting over the Chrome DevTools Protocol is only supported for Chromium-based
browsers."
The same page carries a warning about launching the browser yourself: "Playwright maintains a curated list of arguments for launching the browser. If you launch the browser without Playwright and do not pass the exact same arguments, some of Playwright functionality may be broken upon connecting to the browser." The sample below therefore starts Chromium from Playwright's own executable path, and it still inherits the fidelity caveat:
import { chromium } from 'playwright';
import { spawn } from 'node:child_process';
async function waitForPort(url: string) {
for (let attempt = 0; attempt < 50; attempt++) {
try {
await fetch(url);
return;
} catch {
await new Promise(resolve => setTimeout(resolve, 200));
}
}
throw new Error('Chromium never opened the debugging port');
}
async function main() {
const chrome = spawn(chromium.executablePath(), [
'--remote-debugging-port=9222',
'--user-data-dir=/tmp/pw-cdp-profile',
'--headless',
]);
await waitForPort('http://localhost:9222/');
const browser = await chromium.connectOverCDP('http://localhost:9222');
const context = browser.contexts()[0];
const page = await context.newPage();
await page.goto('https://example.com');
console.log(await page.title());
await browser.close();
chrome.kill();
}
main();
The sample reads browser.contexts()[0] because a browser somebody else started
already has a default context, and the documentation points you at it: "The default browser
context is accessible via browser.contexts()." Reach for this path when the browser has to be
started outside Playwright, and take the note's advice everywhere else.
The second place is CDPSession, for protocol messages Playwright's API does not
wrap. Its reference page opens with
"The CDPSession instances are used to talk raw Chrome Devtools Protocol", and says
protocol methods are sent with session.send. It is an escape hatch into Chromium, and
a test that uses one has stopped being portable across the three engines.
Neither path exists for Firefox or WebKit: the attach method is documented as Chromium-only, and the other two engines are compiled and shipped by Playwright itself.
Why does Playwright ship its own browsers?
Because two of the three engines are modified. The browsers guide says so in the same words twice: "Playwright doesn't work with the branded version of Firefox since it relies on patches", and "Playwright doesn't work with the branded version of Safari since it relies on patches." Of WebKit it adds that "Playwright's WebKit is derived from the latest WebKit main branch sources, often before these updates are incorporated into Apple Safari and other WebKit-based browsers."
Chromium is the looser case. The same page says that "by default, Playwright uses open source
Chromium builds", and that "while Playwright can download and use the recent Chromium
build, it can operate against the branded Google Chrome and Microsoft Edge browsers available on
the machine (note that Playwright doesn't install them by default)". That is what the
channel option selects, with 'chrome' and 'msedge' naming
installed browsers instead of the downloaded build.
The pinning rule falls straight out of the patches: "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." A Playwright upgrade is a browser upgrade, and
PLAYWRIGHT_BROWSERS_PATH decides where those binaries land, which matters more in a
container than on a laptop.
Playwright's WebKit build is not Safari on an iPhone, and it is not the Safari on your colleague's Mac either. It is a build from WebKit main, so WebKit coverage is an argument about a rendering engine and never an argument about a real iOS device. Teams that need the device keep a small device-cloud suite for it.
What is a browser context, and why is it not a tab?
A tab is a page. A page lives inside a context, and a context is an isolated profile inside a running browser. The isolation guide describes contexts as "equivalent to incognito-like profiles" and says they are "fast and cheap to create and are completely isolated, even when running in a single browser." Each test gets "its own local storage, session storage, cookies etc.", and the runner does it for you: "Playwright creates a context for each test, and provides a default Page in that context."
Because they are cheap, one context per test is the default. Two of them inside a single browser, which is one worker's worth of the tree above:
import { test, expect } from '@playwright/test';
test('two contexts in one browser share no session state', async ({ browser }) => {
const first = await browser.newContext();
const second = await browser.newContext();
const a = await first.newPage();
await a.goto('https://example.com');
await a.evaluate(() => localStorage.setItem('cart', 'INV-1042'));
expect(await a.evaluate(() => localStorage.getItem('cart'))).toBe('INV-1042');
const b = await second.newPage();
await b.goto('https://example.com');
expect(await b.evaluate(() => localStorage.getItem('cart'))).toBeNull();
await first.close();
await second.close();
});
Both pages are in the same browser process, started by the same worker, and the second one cannot
see what the first wrote. More comes from
the BrowserContext
reference: "Non-persistent browser contexts don't write any browsing data to disk", and
"If a page opens another page, e.g. with a window.open call, the popup will belong to the parent
page's browser context." A popup therefore keeps the session its parent page established,
which is why a sign-in window opened from a checkout page needs no special handling.
This is the layer that makes the worker model safe. Workers are separate OS processes and cannot share state by accident; contexts do the same job one level down, inside a single browser, without paying for another browser.
Why does Playwright wait for elements on its own?
Playwright launched the browser and holds the connection to it, so the waiting sits under the API call rather than in your test. The auto-waiting guide states the arrangement: "Playwright performs a range of actionability checks on the elements before making actions to ensure these actions behave as expected. It auto-waits for all the relevant checks to pass and only then performs the requested action. If the required checks do not pass within the given timeout, action fails with the TimeoutError."
Which checks run depends on the action, and the guide publishes the full matrix. From it:
| Action | Checks the docs list for it | What it does not check |
|---|---|---|
locator.click() | Locator resolves to exactly one element; element is Visible, Stable, Receives Events and Enabled | Editable |
locator.fill() | Visible, Enabled, Editable | Stable, Receives Events |
locator.press() | None of the five | All five |
The definitions are as mechanical as the list. Stable means the element "has maintained the
same bounding box for at least two consecutive animation frames". Receives Events means it is
"the hit target of the pointer event at the action point", which is a hit test against
whatever overlay might be sitting on top of it. Visible means a non-empty bounding box without
visibility:hidden, and by that definition an element at opacity:0 counts
as visible.
Your test issues one call and gets one result. The retrying happens under it, so there is no loop
in your code polling the DOM and no timing constant for someone to double next time the build is
slow. Which timeout fired when it does fail, and how the expect retry interval differs
from the action timeout, is the subject of
auto-waiting and timeouts.
Watch the press() row on your own suite. None of the five checks apply to it, so a
key reaches an element that is present but disabled, or present but covered by a modal. Assert the
state you are relying on before you press.
What this architecture costs you
Each of the failures below is a consequence of something above, and each one shows up in CI rather than on the laptop where the suite was written.
CI kills the top of the process tree
The browser is a child process. browserServer.process() is documented as returning
the "Spawned browser application process", close() as one that
"Closes the browser gracefully and makes sure the process is terminated", and
kill() as one that "Kills the browser process and waits for the process to
exit." Those are the deliberate paths, and they are the only ones the documentation
describes.
A cancelled job, an agent timeout or an OOM killer is not a deliberate path, and what a signal to the runner does to the browsers below it depends on your runner, your container and your process supervisor. Check it on yours: cancel a run mid-suite and look at what is still resident on the agent afterwards. Do that before you believe anybody's answer, including one you read here.
The container and the version pin
Browsers pinned per Playwright version become an image problem the day the suite moves into CI.
The Docker guide is blunt about the mismatch:
"If the Playwright version in your Docker image does not match the version in your
project/tests, Playwright will be unable to locate browser executables." The image tag for the
version this page is written against is
mcr.microsoft.com/playwright:v1.62.0-noble, and the same page says to pin it.
From the same pair of pages, two constraints that catch people on the first container build. "Using --ipc=host is recommended when using Chromium. Without it, Chromium can run out of memory and crash." And from the CI guide: "Caching browser binaries is not recommended, since the amount of time it takes to restore the cache is comparable to the time it takes to download the binaries." The cache step in your workflow is probably buying nothing.
The context somebody reused
This one is a design error, and it usually arrives labelled as an optimisation. A context carries cookies and storage, so a fixture that creates one context and hands it to every test in a file has re-coupled tests the worker model had already separated. The symptom is a suite that passes in file order and fails once a run is filtered, sharded or retried.
Contexts are documented as fast and cheap to create, and the runner makes one per test unless somebody takes it away. Where the login itself is expensive, save the storage state once and load it into a fresh context per test.
Questions
Does Playwright use Selenium or WebDriver under the hood?
No. It is a different protocol, and nothing in a Playwright run speaks the W3C WebDriver wire protocol. The one place the two meet is the Selenium Grid integration, which the Playwright documentation labels experimental, restricts to Google Chrome and Microsoft Edge, and warns about in its own words: internally Playwright connects to the browser using a Chrome DevTools Protocol websocket, Selenium 4 currently exposes that capability, and the docs say this might not be the case in the future. That bridge is a way to reach browsers on a Grid you already own. It is not how Playwright drives a browser it launched.
Does Playwright use CDP for Firefox and WebKit?
No. The Chrome DevTools Protocol is a Chromium protocol, and the BrowserType reference states that connecting over it is only supported for Chromium-based browsers. Firefox and WebKit are patched builds that Playwright ships, and the documentation does not name a protocol for either of them. Neither does this page.
Can Playwright drive the Firefox or Safari already installed on my machine?
No. The browsers guide says Playwright does not work with the branded version of Firefox or the branded version of Safari, in both cases because it relies on patches. Chrome and Edge are the exception: Playwright can operate against the branded Google Chrome and Microsoft Edge installed on the machine, selected with the channel option, and it does not install them for you.
Is one browser context the same as one tab?
No. A tab is a page, and a page lives inside a context. One context can hold several pages that share cookies and storage with each other, and two contexts in the same browser share nothing. The documentation calls contexts incognito-like profiles and says they are completely isolated even when running in a single browser.
Tell us what the suite runs on today
Which framework the tests are written in, and roughly how many specs there are, is enough for us to have a useful conversation about moving them.