How Playwright Runs Without a Browser Window
Table of contents
When a Playwright test runs in CI, no browser window opens. No screen, no cursor, no visible UI. Yet it navigates pages, clicks buttons, fills forms, and takes screenshots. This is not a trick or a simulation. It is a real browser engine running without its display layer.
To understand how this works, it helps to understand what a browser is actually made of.
Part 1: A Browser Is Two Things, Not One
Most people think of a browser as a single program. Open Chrome, see a window. Close it, it is gone. But under the hood, a browser is two mostly separate systems working together.
The engine
The browser engine is the part that does the actual work of the web. It parses HTML into a DOM tree. It parses CSS and computes which styles apply to which elements. It runs JavaScript. It handles network requests, cookies, storage, service workers, and timers. It computes layout: the position and size of every element on the page.
The three major engines are Blink (Chrome, Edge), Gecko (Firefox), and WebKit (Safari). Playwright ships its own patched builds of Chromium, Firefox, and WebKit, and the engine is the core of each one.
The rendering layer
The rendering layer takes the computed layout and paints it as pixels. It rasterizes text, draws borders, composites layers, applies GPU effects, and sends the final frame to the operating system window manager, which puts it on your screen.
This is the part you see. It is also the part that headless mode removes.
Why the separation matters
The engine does not need the rendering layer to function. It can parse HTML, run JavaScript, compute layout, and fire network requests without ever producing a single pixel. Headless mode is simply running the engine with the rendering layer switched off.
Everything your test cares about, the DOM, the JS state, the network responses, the computed styles, lives in the engine. The rendering layer is only for human viewing.
Part 2: The Chrome DevTools Protocol
Playwright does not control the browser by simulating mouse movements or keyboard input at the OS level. That approach, used by older tools, is slow, fragile, and deeply tied to screen coordinates.
Instead, Playwright speaks directly to the browser engine using the Chrome DevTools Protocol, or CDP.
What CDP is
CDP is a JSON-RPC protocol that Chromium exposes over a WebSocket. It was built for the Chrome DevTools panel (the developer tools you open with F12), but it is also how Playwright sends commands directly to the engine.
When Playwright tells a browser to navigate to a URL, it sends a message like this over a WebSocket:
{
"id": 1,
"method": "Page.navigate",
"params": {
"url": "https://example.com"
}
}
The browser executes the navigation and sends back a response. No screen involved.
What CDP can do
CDP exposes almost everything the browser engine does internally:
Page.navigate navigate to a URL
DOM.querySelector find an element in the DOM
Runtime.evaluate run arbitrary JavaScript in the page context
Input.dispatchMouseEvent fire a mouse event at an element
Network.setRequestInterception intercept and modify network requests
Page.captureScreenshot render a frame offscreen and return it as bytes
Playwright wraps all of this in a clean API. When you write page.click('button'), Playwright finds the button via DOM queries, scrolls it into view, fires mouse events via CDP, and waits for the page to settle. None of this requires a visible window.
Firefox and WebKit
Firefox uses a similar remote protocol called the Firefox Remote Debugging Protocol. WebKit uses the WebKit Inspector Protocol. Playwright abstracts all three behind the same API, so the same test code runs on all three engines without change.
Part 3: How a Headless Browser Starts
When Playwright launches a browser, it runs a subprocess. For Chromium in headless mode, the command looks roughly like this:
chromium-browser \
--headless \
--remote-debugging-port=9222 \
--no-sandbox \
--disable-gpu
The --headless flag tells Chromium to skip the display layer entirely. --remote-debugging-port opens the WebSocket that Playwright will connect to.
From the Playwright side in TypeScript:
import { chromium } from '@playwright/test';
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto('https://example.com');
Under the hood, chromium.launch() starts the subprocess, waits for the WebSocket port to be ready, and connects to it. browser.newPage() sends a CDP command to create a new browser tab. page.goto() sends Page.navigate. All of it is WebSocket messages.
No window opens at any point. The browser process exists, it is running a full JS engine, but there is no frame on any screen.
Part 4: What Runs and What Does Not
The common concern with headless testing is that the browser might behave differently without a screen. In practice, the differences are narrow and well-understood.
What runs exactly the same
JavaScript execution. V8 runs fully. setTimeout, Promise, fetch, localStorage, sessionStorage, IndexedDB, WebSocket all work. Code that reads document.cookie or writes to window.history works.
CSS and layout. The CSS engine computes styles. getComputedStyle() returns correct values. getBoundingClientRect() returns correct dimensions. Flexbox and Grid layout work. Media queries based on viewport size work.
Network. Every request goes through the real network stack. Cookies are sent, redirects are followed, TLS is verified. If the test calls a real API, it gets a real response.
DOM events. Click events, input events, focus events, scroll events all fire correctly. Event listeners execute.
Service workers and web workers. Both run.
What does not run the same way
GPU-dependent rendering effects. Certain CSS effects that rely on GPU compositing (backdrop-filter, some filter values, hardware-accelerated video decoding) may behave differently or not at all. This rarely affects functional tests.
System fonts. Headless mode on a Linux CI server may not have the same fonts as a macOS developer machine. Text that wraps at a certain width locally may wrap differently in CI. This matters for pixel-perfect screenshot comparison tests, not for functional tests.
OS-level dialogs. The browser’s native file picker, alert dialogs triggered by window.alert(), and browser-level authentication prompts are OS window elements. Headless mode cannot interact with them. Playwright has specific APIs (page.on('dialog')) to handle JS-level dialogs programmatically.
The practical summary: if the test is checking behavior, headless works. If the test is doing pixel-for-pixel visual comparison of rendered output, small differences may appear across platforms.
Part 5: Why Headless Is Faster
Headless tests run meaningfully faster than headed tests. A test suite that takes 4 minutes headed often finishes in 90 seconds headless. The reasons are specific.
No GPU pipeline
In headed mode, every frame goes through a rendering pipeline: rasterize (turn vector shapes into pixels), composite (stack layers), encode (prepare for display), send to the OS. This pipeline runs continuously, typically at 60 frames per second, even between user interactions.
In headless mode, none of this runs between interactions. The browser skips the entire paint pipeline for frames that nothing is observing.
No frame buffer allocation
Each headed browser tab allocates a frame buffer in GPU memory: a block of memory the size of the viewport in pixels, used to hold the rendered frame. A 1280x720 viewport allocates roughly 3.5MB of GPU memory per tab. With 10 parallel test workers each running multiple tabs, this adds up.
Headless tabs do not allocate frame buffers. Memory pressure is lower, which also means the OS spends less time managing it.
Layout is still computed, but not painted
This is a subtle point. The browser still runs layout in headless mode, because layout is needed to answer DOM queries (getBoundingClientRect, offsetWidth). But it stops before the paint step. Computing layout is fast. Painting is what takes time.
Concrete example
// This test navigates, fills a form, submits, and checks a success message.
// Headed (local, macOS): ~820ms
// Headless (local, macOS): ~310ms
// Headless (CI, Linux): ~240ms
test('place order', async ({ page }) => {
await page.goto('/checkout');
await page.fill('#email', 'user@example.com');
await page.fill('#card', '4242424242424242');
await page.click('button[type=submit]');
await expect(page.locator('.success')).toBeVisible();
});
The difference is not artificial. It is the rendering pipeline not running.
Part 6: Screenshots and Video in Headless Mode
If headless mode removes the rendering layer, how does page.screenshot() work?
On-demand offscreen rendering
When a screenshot is requested, Playwright sends a CDP command: Page.captureScreenshot. The browser engine does something it does not do during normal headless execution: it triggers a single render pass offscreen, produces a frame, encodes it as PNG or JPEG, and returns the bytes.
This is different from the continuous render loop in headed mode. It is one frame, produced on demand, without a window or GPU frame buffer. The result is a pixel-accurate capture of what the page looks like at that moment.
// Works correctly in headless mode
const screenshot = await page.screenshot({ fullPage: true });
Video recording
Video recording works similarly. Playwright uses an offscreen renderer to capture frames at a set interval (by default, enough to produce a smooth video) and encodes them into a video file.
// playwright.config.ts
use: {
video: 'on-first-retry', // record video when a test fails and retries
}
The video is a real recording of the page state, not a simulation. It shows exactly what the page looked like at each moment during the test, even though no window was ever visible.
The small cost
On-demand rendering is slightly more expensive than capturing a frame that was already rendered. In a headed browser, the frame is already in the GPU buffer; capturing it is cheap. In headless mode, a render pass has to be triggered first.
For tests that take many screenshots (visual regression suites), this cost adds up. For tests that only screenshot on failure, it is negligible.
Part 7: Traces — More Than a Screenshot
Playwright’s trace recorder goes further than screenshots or video. It captures a complete timeline of everything that happened during the test.
// playwright.config.ts
use: {
trace: 'on-first-retry',
}
A trace file contains:
- A timeline of every action (click, navigate, fill)
- DOM snapshots at each step, reconstructed from CDP data
- Network requests and responses with headers and bodies
- Console logs and JavaScript errors
- Screenshots at key moments
The DOM snapshots are not pixel captures. They are serialized DOM trees that the Playwright Trace Viewer reconstructs into a visual representation. This means you can “see” what the page looked like at any point during a headless test run, with zero visual rendering at test time.
npx playwright show-trace trace.zip
This opens a browser-based viewer where the DOM at each step is rendered locally in your browser. You are not watching a recording of headless pixels. You are watching your browser render the serialized DOM from the test run.
Summary
A browser engine and a rendering layer are two separate systems. Headless mode runs the engine and skips the rendering layer. Everything your test needs, the DOM, JavaScript, network, layout, and events, lives in the engine. The rendering layer only exists to put pixels on a screen for a human to see.
Playwright speaks to the engine directly over CDP, a WebSocket protocol that exposes the full internals of the browser. It never needs a screen because it never goes through the display layer in the first place.
Screenshots and video work by triggering on-demand offscreen renders: single frames produced when requested, not a continuous display loop. Traces work by capturing serialized DOM snapshots that can be replayed locally.
The speed advantage of headless comes from skipping the GPU pipeline, the frame buffer allocation, and the continuous render loop. The behavior difference is narrow: GPU-dependent effects, system fonts, and OS-level dialogs may differ. Functional behavior does not.
Understanding this is why CI environments run headless by default, and why headed mode is a debugging tool rather than a testing environment.