Browser automation tools control a real browser from code. The cost appears when you run them at concurrency against sites you don’t own. Comparisons list features in a table, such as protocol support and language bindings. None of that says what a tool costs at runtime. Where a memory number does appear, it measures Chromium, not the tool. This guide benchmarks the best browser automation tools against one pinned Chrome build, then reports where they differ and where they don’t.
TL;DR
- On a 20-card page, Chromium costs 416 to 532 MB and 1.3 to 1.65 seconds of CPU time whichever client controls it. Importing the client adds about 7 to 80 MB.
- A different engine changes those numbers. Lightpanda ran the same extraction in a 10 MB browser with 1 process and 0.06 s of CPU time, though it identifies itself in the User-Agent.
- Latency scales with round trips. Playwright uses 43 to 46 messages against Puppeteer’s 318 to 320. One evaluation replaces them when the data is rendered and in one frame.
- Neither contexts nor separate Chrome browsers change your fingerprint. In the test, 3 browsers with 3 user data directories produced 1 canvas hash.
The 3 protocols, and what changed
The difference between the 3 protocols grows with latency.
WebDriver Classic, 1 round trip per command. It’s request and response over HTTP, and those round trips are where Selenium and Playwright differ most.
Chrome DevTools Protocol, now Chromium only. It’s bidirectional JSON over a single WebSocket. That limit became real when Mozilla removed CDP from Firefox in version 141 and Selenium removed its Firefox CDP support in 4.29.0.
WebDriver BiDi, still a draft. It’s the cross-browser option, though people overstate its status. The specification, republished 23 September 2026, remains a W3C Working Draft rather than a Recommendation, and Puppeteer still uses CDP on Chrome. Used directly, BiDi made the fewest protocol calls of any client benchmarked below, but migrating to an unfinished standard risks a second rewrite.
How this was measured
Every number comes from a benchmark I ran on one machine, an Apple M3 with 8 cores and 16 GB RAM. It ran Node v25.9.0 and Google Chrome for Testing 153.0.8010.47 in --headless=new mode, with matching chromedriver. macOS supplies phys_footprint, and a Linux container would count the same memory differently. Every script below runs from the same repository, browser-automation-bench, which you can reproduce from a clean checkout:
git clone https://github.com/triposat/browser-automation-bench
cd browser-automation-bench && npm install
npx @puppeteer/browsers install chrome@153.0.8010.47 --path ./.browsers
npx @puppeteer/browsers install chromedriver@153.0.8010.47 --path ./.browsers
curl -fsSL https://pkg.lightpanda.io/install.sh | bash # optional fifth row, a nightly build
LIGHTPANDA_PATH=~/.local/bin/lightpanda REPEATS=5 node bench.mjs # wherever the installer put it
The benchmark reads a page of 20 product cards, 2 fields each, in 2 ways. The walk reads one locator at a time, and the other way batches all 40 reads into one evaluation. Each tool ran in its own process, 5 times.
The results depend on 3 measurement choices. Browser memory comes from phys_footprint rather than a sum of ps RSS, because summing RSS across a Chromium process tree counts the shared binary image once per process, overstating it 2.3x to 3.0x. CPU time comes from Chrome’s SystemInfo.getProcessInfo. A proxy between client and browser supplies the protocol counts, so they record what passed between them.
These choices have 3 limits. The BiDi row uses a raw client written directly on chromedriver’s socket, so its messages were counted in-process rather than at the proxy. The added latency is injected in software, cross-checked against traffic shaping within 2%. The test page is small, so the memory and CPU time claims were re-tested on a 600-card page with 3,607 nodes, and so were the message counts.
The fingerprint findings below measure what a site can detect on test pages and public pages, not whether it blocks you.
What a framework actually costs you
The benchmark produces this cost table:
| Client | Engine | Import cost | Browser memory | Procs | CPU | 20-card walk | Messages |
| Playwright 1.63.0 | Chromium, CDP | 76.4 MB | 416 to 532 MB | 9 | 1.33 s | 74 ms | 46 |
| Puppeteer 25.11.0 | Chromium, CDP | 15.3 MB | 416 to 532 MB | 9 | 1.32 s | 77 ms | 320 |
| Selenium 4.49.0 | Chromium, WebDriver Classic | 9.9 MB | 416 to 532 MB | 8 | 1.47 s | 162 ms | 81 |
| raw BiDi client | Chromium, WebDriver BiDi | 26 MB | 416 to 532 MB | 9 | 1.44 s | 47 ms | 41 |
| Lightpanda nightly | own engine, partial CDP | 15.3 MB | 10 MB | 1 | 0.06 s | 48 ms | 318 |
| Cypress 16.1.0 | bundled Electron | 30 MB npm, 641 MB binary | n/a | n/a | n/a | n/a | n/a |
Each value is 1 run of 5 repeats, except browser memory, which is the range across 5 separate runs and the same whichever Chromium client drives it.
Import cost is the client RSS added by importing the library, and the BiDi row’s 26 MB, measured in a separate run, includes selenium-webdriver, which spawns chromedriver and negotiates the socket before the client takes control. Selenium’s messages are HTTP requests rather than socket frames. Cypress runs in the page, so its import cost is install size on disk, and the rest of its row isn’t comparable. Every row extracted the same 20 records.
The Lightpanda row is the only build here that can’t be pinned. Its installer fetches the nightly build, so your binary won’t be the one measured.
Here is the run the table came from, printed by the tool:

The same workload on 5 tools and 2 engines
Read the browser column first. Every Chromium row shows the same 416 to 532 MB range, while Lightpanda, a different engine, shows 10 MB. Chromium uses that memory for its process tree. Any table giving each framework its own browser-memory number measures Chromium but reports it as the client’s.
Browser memory also varies more than anything else here, across 5 independent 5-repeat runs:
| Client | Browser memory across 5 runs | Range |
| Playwright | 427, 425, 423, 434, 428 | 11 MB |
| Puppeteer | 508, 416, 511, 423, 524 | 108 MB |
| Selenium | 437, 436, 435, 510, 445 | 75 MB |
| raw BiDi | 443, 446, 532, 448, 460 | 89 MB |
The ranges overlap, so none of these clients is reliably lighter than another.
Playwright stayed inside an 11 MB range, while every other client measured about 90 MB above its own usual value at least once. Those ranges mostly overlap, and where they don’t, the difference is under 10 MB. These 5 runs can’t show a difference between the clients, and this measurement doesn’t produce a browser-memory number precise enough to rank frameworks. Walk time varied 9 to 17 percent across the same runs and CPU time 5 to 12, and only walk time still ranks the clients, because only its per-client ranges don’t overlap.
The clients differ in import cost, 76.4 MB for playwright-core against 10 to 26 MB for the others. With a browser connected, the client column in the run above keeps that order, with 151.5 MB for Playwright against Puppeteer’s 86.9 and Selenium’s 81.1. A worker costs that much when the browser runs elsewhere.
Only a different engine changes the browser number, and Lightpanda is one, written in Zig with enough CDP support for a Puppeteer client to control it. On the same workload, its browser used 10 MB in 1 process, though the client controlling it still cost 86.9 MB. On public sites, it matched Chrome’s DOM on react.dev. It rendered fewer nodes on angular.dev, 385 against 485, and more on vuejs.org, 754 against 619.
Lightpanda gets that saving by supporting less, and a site can detect the difference. It has no WebGL and no window.chrome. It always reports 4 cores, and its User-Agent is Lightpanda/1.0.
Browser fingerprinting also reads the network connection, not only the page. Chrome’s JA4 and HTTP/2 fingerprints, hashes of its TLS handshake and connection settings, were byte-identical headless and headful across 3 runs each, so headless mode changes neither hash. Lightpanda’s differed from Chrome’s, and overriding its User-Agent doesn’t change them.
When Chrome runs as a headless browser, it still identifies itself to the server and to page script. Under --headless=new it puts HeadlessChrome in the User-Agent and reports screen and availScreen as equal, while on a headful desktop they differ by the height of the dock.
JA3, the older hash of the same handshake, gave 6 values in 6 runs because it doesn’t normalize GREASE, the dummy entries Chrome randomizes per connection to keep TLS extensible, so comparing JA3 values gives you noise.
Round trips show where clients differ
A locator walk and a single evaluation fetch the same data, and clients differ on the walk.
Playwright uploads its injected bundle once per execution context, in a single Runtime.evaluate. Selector matching then runs in the page, so each read costs 1 message.
Puppeteer keeps handles client-side, so each read costs 8 messages. Playwright sent 493 KB against Puppeteer’s 98 KB, because it uploads more data once, while Puppeteer makes more round trips.
Finding the cards on a 600-card page cost Puppeteer 1,832 messages before a field was read. A Playwright locator cost 3. Puppeteer’s page.$$() creates a handle per match at about 3 messages each, used or not, so the cost scales with matches. The locator also cost 3 on the 20-card page.
Selenium Classic needed 81 HTTP requests. The first listed the cards, and each of the 40 fields took 2 more, one to find the element and one to read it. In Selenium 4, getAttribute isn’t a WebDriver endpoint, so each call sends an injected script.
One evaluation has a limit you can measure. Past that limit, it fails silently.
A separate test page holds 35 records, 5 for each of 7 rendering methods. The records are server-rendered, deferred by 800 ms, shown after a click, in an open shadow root, in a closed one, and in same-origin and cross-origin iframes.
One evaluation at domcontentloaded found 5. Waiting raised that to 10, and the click to 15. Searching open shadow roots found 20, and each iframe holds 5 more that need their own evaluation.
Page script doing everything it can, searching open shadow roots in every frame, finds 30 of 35. The last 5 are inside a closed shadow root, invisible to page script and to a Playwright locator. Over CDP, DOM.getDocument with pierce: true returned all 5:
{ "pageScript": { "shadowRoot": "null", "itemsVisible": 0 },
"playwrightLocator": { "count": 0 },
"cdpDomPierce": { "closedShadowItemsFound": 5 } }
No error appears. Your code returns a number and the pipeline accepts it. You only notice the missing 5 when you check the totals.
What latency does to the same code
Running the same benchmark with latency between client and browser produces this:

At 60 ms, each message costs 50 to 71 ms of elapsed time, so the walk times give the same ranking as the message counts.
Now read the bottom half. At the same 60 ms, one evaluation put every client between 65 and 136 ms, and Puppeteer’s own time fell by a factor of 251 without changing the library or the network. At 1 or 2 messages per client, the remaining difference is 1 round trip, not a ranking.
3 detection findings the cost table can’t show
The cost table ranks the clients. These 3 findings tell you which one to use for your target.
Selenium has a detection risk the CDP clients don’t. Chromedriver injects 7 cdc_adoQpoasnfa76pfcZLmcfl_* properties onto window, while Playwright and Puppeteer added none. That string is byte-identical across chromedriver 131 through 153.
Stealth clients patch how the browser launches, not how you connect to it. Launched by patchright, a stealth fork of Playwright, Chrome reported navigator.webdriver as false. Connected over CDP to a Chrome I started myself, the same patchright reported true, because its patch only applies at launch.
The CDP messages themselves create a detection signal. Patchright’s source says a page can detect when the Runtime domain is enabled, and its biggest patch avoids that by running its scripts in isolated execution contexts. Both popular clients enable the domain when they connect, Playwright 3 times in 34 commands and Puppeteer once in 38. Batching a walk into one evaluation doesn’t help here, because those calls happen when the client connects, whatever you do afterwards.
Which browser automation tool to choose
The answer depends on which resource limits you first and on whether the browser runs on your machine at all. It doesn’t depend on which library is fastest.
If nothing limits you yet, start with Playwright. Its walk used 43 to 46 messages, and its locator cost 3 messages at 20 or 600 cards. It also added no cdc_ properties. Move to Puppeteer when client memory per worker limits you.
| What limits you first | What to use | What the benchmark measured |
| Client memory per worker | Selenium or Puppeteer | 81 MB and 87 MB with a browser connected, against Playwright’s 152 MB |
| Round trips over a remote connection | Playwright, or BiDi used directly | 41 to 46 messages against Puppeteer’s 318 to 320, with import costs of 76.4 MB and 26 MB |
| Crawling across domains | Playwright or Puppeteer, not Cypress | cy.origin() callbacks can’t nest, so Cypress can’t follow links it discovers across origins |
| Memory or CPU, on sites with no bot detection | Lightpanda | 10 MB and 0.06 s against Chromium’s 416 MB to 532 MB and 1.3 to 1.65 s |
| Sites with bot detection | Chromium, any client | Lightpanda sends Lightpanda/1.0 as its User-Agent and has no WebGL |
| Identity must vary per worker | Gologin Cloud Browser | profiles with canvas noise on, 3 profiles gave 3 canvas hashes, against 1 across 3 local browsers |
What concurrency and duration cost
Increasing concurrency on the same 20-card page gives this:
| Concurrency | Contexts in one browser | Separate browsers |
| 1 | 431 MB, 9 procs, 1.53 s CPU | 431 MB, 9 procs, 1.58 s CPU |
| 4 | 1,184 MB, 18 procs, 3.22 s CPU | 1,872 MB, 38 procs, 5.95 s CPU |
| 16 | 3,650 MB, 54 procs, 11.13 s CPU | 6,556 MB, 160 procs, 28.31 s CPU |
Both scale linearly. A context costs about 213 MB after the first, so plan for 431 MB plus 213 MB for each additional context. That formula predicts the number at 16 within 1%. Separate browsers add about 410 MB each but finish faster at 16, 1,208 ms against 2,828 ms, because contexts compete inside one process.
Neither setup gives you a different identity. In the test, 3 browsers, each with its own user data directory, produced 1 canvas hash, with the same hardwareConcurrency and deviceMemory values and the same platform. Contexts isolate storage, not the machine. A remote browser with its own profile reports a different machine.
Pick contexts to save memory, and separate browsers to isolate crashes.
Few benchmarks test long runs. Looping the extraction 200 times, closing a context each iteration, kept the browser at 510 to 563 MB. Leaving the contexts open leaks memory, reaching 6.5 GB and 99 processes by iteration 30.
The measurement itself misleads. After 200 loads of the same page in one tab, a browser measured 799 to 824 MB across 2 runs, and 1.5 idle seconds later it had dropped by 201 to 295 MB. A forced garbage collection in the client freed 96 to 110 MB and HeapProfiler.collectGarbage 152 to 176, so waiting freed more than either on both runs. Close what you open, and let a browser go idle before measuring it.
What the agent layer changes
The agent layer changes the token cost, not the protocol cost. Agent tools are built on the protocol, whether the browser runs locally or at a cloud endpoint. The payload costs more than the connection. Playwright MCP, a Model Context Protocol server, needed 3 CDP messages a page for browser_snapshot.
Counted with gpt-tokenizer, the snapshot’s accessibility tree measured 7,719 tokens against 641 for a targeted evaluation returning the same 20 records, and 13,587 against 1,349 on the live Hacker News front page. The snapshot costs 10 to 12 times the tokens for the same data, because it includes every element.
Where the browser should run
You can patch some of what a site detects on your own machine. A script installed through CDP before page load still works after connecting. On a Chrome that this process didn’t start, navigator.webdriver returned true without the hook and false with it. The hook changes what JavaScript checks see, but it can’t change JA4 or HTTP/2.
Those remaining checks, plus a memory limit you can calculate but not increase, are reasons to run the browser elsewhere. Identity is another. A logged-in account should look like the same returning user on each run, but local browsers share one machine’s fingerprint across all your accounts, and a remote browser that keeps nothing between sessions signs in as a new visitor every time.
Gologin Cloud Browser stores that identity in a profile. The profile keeps its cookies and local storage between sessions, and its settings too, including the device fingerprint and any proxy you attach. The browser is Orbita, a modified Chromium that manages the fingerprint inside the engine rather than through a script loaded on top. That matters because a value overridden by a script can contradict the engine running under it.
| Measured | Local Chrome | Cloud profile |
| CreepJS direct headless checks | failed 1 check headful, both headless | passed both, in all 4 runs |
| navigator.webdriver | true | false |
| Canvas hash across 3 instances | 1 | 3 |
Detection signals, not outcomes. Nothing here says whether a given site will block you.
Each profile has its own identity, which you set explicitly before you automate many profiles. In the test, 3 profiles created through the API varied their exit IP and WebGL renderer, and their hardwareConcurrency too. They reported navigator.webdriver as false while every local client reported true. Canvas noise is a separate per-profile setting, which you turn on by changing mode:
"canvas": { "mode": "off", "noise": 0.4832847 }
Setting it to noise gave 3 different canvas hashes. Adding more profiles takes 2 more calls, a fingerprint refresh and a User-Agent update. The refresh gave new values for renderer, cores, memory, and screen resolution across 3 profiles, changing resolution from 1 shared value to 3 different ones. The update moves the User-Agent to a newer Chrome version.
Identity has 2 parts, and the fingerprint settings control only one. With no proxy attached, 5 sessions returned 5 different datacenter exit IPs. Attaching a proxy changes the exit IP, and the API adds 1 per profile by country code, choosing between mobile, residential, datacenter, and device types, each with its own traffic limit. At high concurrency, the proxy matters more, because many profiles that vary every fingerprint but share one exit IP can still be linked by that IP.
Running the headless checks of CreepJS, an open-source fingerprinting test, compares local Chrome and the cloud profile directly. Its 2 direct checks ask whether the webdriver flag is set and whether the User-Agent contains HeadlessChrome. Local Chrome failed 1 check headful and both headless. The cloud profile passed both checks in all 4 runs.
Its indirect checks are heuristics that real users also fail, such as a preference for light color schemes.
Changing a Playwright script to use the cloud browser takes 1 line, and the endpoint serves both clients with 1 difference in the URL scheme. GL_TOKEN is your API token and GL_PROFILE the id of a profile on your account. If either is missing, the endpoint returns a 400 whose body names the missing parameter:
import { chromium } from 'playwright-core';
// Playwright connects over wss://. Puppeteer connects to the same endpoint
// over https://, because it treats browserWSEndpoint as an opaque URL.
const CDP_URL = `wss://cloudbrowser.gologin.com/connect`
+ `?token=${process.env.GL_TOKEN}&profile=${process.env.GL_PROFILE}`;
const browser = await chromium.connectOverCDP(CDP_URL, { timeout: 120_000 });
const page = (browser.contexts()[0] ?? await browser.newContext()).pages()[0]
?? await browser.contexts()[0].newPage();
await page.goto('https://books.toscrape.com/', { waitUntil: 'domcontentloaded' });
// One round trip for the whole page, instead of one per field.
const rows = await page.evaluate(() =>
[...document.querySelectorAll('article.product_pod')].map((el) => ({
title: el.querySelector('h3 a')?.getAttribute('title') ?? null,
price: el.querySelector('.price_color')?.textContent?.trim() ?? null,
})));
console.log(rows.length, rows[0]);
await browser.close();
The script returned all 20 books on each of 3 runs, with no local Chromium:
20 { title: 'A Light in the Attic', price: '£51.77' }
The median CDP round trip to the endpoint was 236 ms over 9 runs, which depends on your distance from it. A locator walk costs that once per read, so the same 40 reads took 64 ms locally and a median 9,941 ms remotely, against 484 ms for one evaluation. A first connection took about 5.5 seconds, and each reconnection after it about 1.5.
Concurrency then depends on your subscription, not your memory. Holding 4 sessions open added at most 3 MB of client RSS between them and started no local browser process. The fifth was refused, because your subscription sets the parallel session limit. Plans charge $0.04 to $0.09 per browser hour, with residential proxy traffic included up to each plan’s allowance and $1/GB after that, as of September 2026.
For your retry loop, a refused WebSocket upgrade returns a 503 with the reason in its body, while a plain GET to the same URL returns a 403 with the reason in X-Error-Reason. Closing 1 client at the limit freed the slot at once, and it stayed free, so sessions need no cleanup process. Keep the profile id in every request, because a plain GET without one creates a new profile.
Ethics, compliance, and rate limits
When you automate sites you don’t own, you have obligations that no benchmark measures. Read robots.txt and obey its Crawl-delay. Let the target’s capacity set your concurrency. Terms of service are legal agreements, and they differ per site.
GDPR doesn’t exempt personal data because a page was public, and CCPA’s exception has conditions of its own. Filter when you extract, not afterwards. In any web data collection workload, reading what a site serves isn’t the same as bypassing what blocks you.
Final thoughts
Browser automation in 2026 mostly runs one browser, so your tool choice matters less than where you run it. Start with Playwright, and move to Puppeteer when client memory limits you. Don’t confuse Chromium’s 416 MB to 532 MB on a 20-card page with your framework’s own memory use, because only a different engine, such as Lightpanda on sites with no bot detection, lowers it.
The 2 changes that help most are batching reads into single evaluations where the data allows and planning concurrency at about 213 MB a context for a page that size. Separate Chrome browsers isolate crashes but don’t give you a new identity, so run the benchmark on your own target, then connect the same script to a Gologin Cloud Browser profile for accounts that need the same identity on every run.
FAQ
Which browser automation tool is fastest in 2026?
It depends on how you read the DOM. Reading one locator at a time, a direct WebDriver BiDi client used the fewest messages, 41. Playwright used 43 to 46 and Puppeteer 318 to 320. With those reads batched into one evaluation, every client took between 65 ms and 136 ms at 60 ms latency.
Do browser contexts give each session a different fingerprint?
No. Contexts isolate storage, and one context couldn’t read another’s localStorage. They don’t isolate the machine. In the test, 3 separate Chrome browsers with their own user data directories still shared 1 canvas hash, with the same hardwareConcurrency and deviceMemory values and the same platform.
Can I run Playwright inside AWS Lambda without memory leaks?
Leaks weren’t the problem, because the browser stayed between 510 and 563 MB over 200 iterations that closed each context. Chromium limits you, not Playwright. It used 416 to 532 MB on a 20-card page, where Lightpanda’s browser cost 10 MB against its client’s 87. With 4 remote sessions open, the client grew by 3 MB at most.
Why is Cypress not recommended for web scraping?
Cypress runs tests inside the page, so reads cost no protocol round trips. But it can’t crawl across origins. Cypress documents that cy.origin() callbacks can’t nest, so a single test can only visit origins it knows in advance and can’t follow a chain of links it discovers.
How does connecting over CDP reduce local RAM usage?
The browser runs elsewhere, so your process keeps only the WebSocket client and the parsed results. On Gologin Cloud Browser, 4 concurrent sessions added at most 3 MB of client memory between them with no local Chromium, against 1,184 MB for 4 local contexts. You trade memory for latency, a median 236 ms per round trip from the test machine.
Do I still need Playwright if I use an AI browser agent?
Yes, indirectly. Agent tools such as Playwright MCP use the same CDP and the same Chromium, so memory use and process count stay the same, and so do round trips. The payload a model reads does change. Playwright MCP’s page snapshot measured 10 to 12 times the tokens of a targeted evaluation returning the same records.
What is the difference between CDP and WebDriver BiDi?
CDP is Chromium’s own bidirectional protocol, and it only works with Chromium. Firefox removed its CDP support in version 141. WebDriver BiDi is the cross-browser option, still a W3C Working Draft rather than a finished standard. Selenium 4.49.0 supports both, with CDP the default on Chrome.



