Skip to content

Playwright vs Puppeteer vs Selenium: the detection surface of each driver

· 20 min read
Copyright: MIT
Three driver names over a split transport diagram, WebDriver HTTP on one side and CDP WebSocket on the other

Ask three engineers which browser automation driver is hardest to detect and you will get three confident answers, usually shaped by whichever one they happen to ship. The honest answer is that the question is malformed. None of the three drivers is “detected” as a unit. What an anti-bot system actually sees is a stack of separate signals, and those signals sort cleanly into two buckets: the ones that come from the transport the driver speaks to the browser, and the ones that come from the specific framework’s habit of leaving named objects lying around in the page.

So the comparison worth making is not Playwright versus Puppeteer versus Selenium. It is WebDriver-the-protocol versus CDP-the-protocol, layered under three different libraries that each smear their own initials across the JavaScript runtime. Selenium drives the browser over an HTTP wire protocol and hides a string of cdc_-prefixed globals in the page. Puppeteer and Playwright both ride the Chrome DevTools Protocol over a WebSocket, share a transport-level tell that has nothing to do with either library, and then each adds its own injected bindings on top. Understanding which leak lives at which layer is the whole game, because a leak at the transport layer cannot be patched by switching libraries, and a leak at the library layer cannot be caught by a detector that only watches the transport.

This post walks the stack from the bottom up. First the two transports and why they fingerprint differently. Then the W3C navigator.webdriver flag, the one signal all three share by spec. Then the framework-specific globals: Selenium’s cdc_ family, Playwright’s __playwright__ bindings, Puppeteer’s exposed functions. Then the CDP Runtime.enable leak that Puppeteer and Playwright shared until 2025, the V8 patches that closed it, and the patched-driver projects that grew up around it. The aim is a reference you can map your own driver against, not a ranking.

Two transports, two fingerprints

The first fork in the road is how the driver talks to the browser at all, because that choice decides what an observer on the network or in the page can infer.

Selenium speaks WebDriver, which is a W3C Recommendation. The spec describes it plainly as “a remote control interface that enables introspection and control of user agents” through “a platform- and language-neutral wire protocol.” That wire protocol is HTTP with JSON payloads. Your test code POSTs to /session to start a session, gets back a sessionId and a negotiated capabilities object, then issues further commands as HTTP requests whose method-and-URI pairs map to individual WebDriver commands. A separate driver binary sits in the middle. For Chrome it is chromedriver, for Firefox geckodriver. The binary translates the language-neutral HTTP commands into whatever the browser’s own automation interface wants. This is a request-response protocol by design. You ask, the browser answers, and the browser cannot push an unsolicited event back up the same channel.

Puppeteer and Playwright skip the standardized middle layer. Both speak the Chrome DevTools Protocol directly, over a single WebSocket connection to the browser’s debugging endpoint. CDP is the same protocol Chrome’s own DevTools front-end uses, organized into domains (Runtime, Page, Network, DOM, and so on) with commands you call and events the browser emits back to you. The connection is bidirectional and event-driven from the start, which is exactly why these libraries can do things WebDriver historically could not, like subscribing to every network request as it fires. Playwright’s direct CDP control is generally harder to detect than Selenium’s WebDriver path, and the reason is structural rather than cosmetic: there is no separate driver binary injecting its own scaffolding into the page to make the HTTP protocol work.

Selenium Puppeteer / Playwright test code test code HTTP / JSON CDP / WebSocket chromedriver DevTools / pipe browser browser request / response, driver binary in the path bidirectional events, no driver binary *The two transport stacks. Selenium routes through a separate driver binary over HTTP; Puppeteer and Playwright hold one bidirectional CDP WebSocket straight to the browser.*

The distinction matters for detection because the cost of each transport gets paid in a different currency. Selenium’s HTTP path historically forced the driver binary to inject helper objects into the page so the language-neutral protocol could find and manipulate DOM nodes. Those helpers are observable from JavaScript. The CDP path injects nothing by default to do its basic work, so Puppeteer and Playwright start with a cleaner page. What CDP pays instead is a transport-level behavior, the Runtime.enable tell covered later, that an attacker can trip without ever seeing a framework global. Neither transport is invisible. They are visible in different places.

Worth noting that the clean split is blurring. Selenium 4 added a CDP bridge so Selenium code could call CDP domains for Chromium browsers, and Firefox is walking the protocols in the other direction. The longer arc is WebDriver BiDi, a W3C effort to give the standardized WebDriver world a bidirectional, event-driven channel like CDP’s without the Chrome-only baggage. Selenium has been migrating onto it and pulling its old CDP bridge for Firefox out from under it: CDP support for Firefox was deprecated across Selenium 4.27 and 4.28 and removed entirely in 4.29.0 in February 2025, with the stated reason that “a major portion of WebDriver BiDi functionality is now available across all Selenium language bindings.” That migration has its own detection surface, which we cover in Selenium’s bidirectional protocol and the WebDriver BiDi migration.

The one flag they all share: navigator.webdriver

Before the framework-specific tells, there is one signal that all three drivers raise by specification, and it is the cheapest detection in the entire field.

The W3C WebDriver spec defines a mixin interface, NavigatorAutomationInformation, with a single read-only boolean attribute named webdriver. The spec says it “returns true if webdriver-active flag is set, false otherwise.” The webdriver-active flag “is set to true when the user agent is under remote control” and “is initially false.” The intent in the spec is benign cooperation: a page can read the flag and take an alternate code path during automated testing. In practice it is the first thing every anti-bot script checks, because a one-line if (navigator.webdriver) separates a meaningful slice of naive automation from real users at zero cost.

In Chrome the flag flips true when the browser is launched with --enable-automation or with a remote debugging port, which covers all three drivers’ default launch path. Firefox sets it under Marionette or --marionette. The Mozilla side has gone to some lengths to bind the flag’s lifetime to the session correctly, so that the property reads false when there is no active automation session rather than staying stuck on.

Because the check is so cheap and so well known, suppressing it is the very first thing every stealth project does. The common route is the launch flag --disable-blink-features=AutomationControlled, which keeps navigator.webdriver reading false. That single flag is in the standard toolkit of Selenium stealth wrappers, Puppeteer’s stealth plugin, and the patched Playwright builds alike. Which is exactly why a modern detector treats a false navigator.webdriver as worth almost nothing on its own. The flag being true is a strong positive; the flag being false tells you only that the operator knew to flip it, which describes both careful humans and every competent bot. The signal has inverted into a weak one, and the interesting detection has moved elsewhere. The puppeteer-extra-plugin-stealth writeup traces how many of these property patches stack up and where they start contradicting each other.

Selenium’s calling card: the cdc_ globals

Selenium’s most famous tell comes straight out of the chromedriver binary, and it is a direct consequence of the HTTP transport choice described above.

When chromedriver automates a page, it injects a set of properties into the document and window with a fixed, hard-coded prefix. The canonical names, confirmed in Castle’s July 2025 writeup on Selenium detection, are cdc_adoQpoasnfa76pfcZLmcfl_Array, cdc_adoQpoasnfa76pfcZLmcfl_Promise, cdc_adoQpoasnfa76pfcZLmcfl_Symbol, along with siblings for Object, Proxy, JSON, and Window, plus a separate window.ret_nodes. The cdc_ prefix is the giveaway, and it has been the same string for years. Anti-bot scripts that want to catch unmodified Selenium do not need anything clever. They enumerate the window and document for any key beginning with cdc_ or for ret_nodes, and they are done.

window { cdc_adoQpoasnfa76pfcZLmcfl_Array: [...], cdc_adoQpoasnfa76pfcZLmcfl_Promise: {...}, cdc_adoQpoasnfa76pfcZLmcfl_Symbol: {...}, cdc_adoQpoasnfa76pfcZLmcfl_Object: {...}, cdc_adoQpoasnfa76pfcZLmcfl_Proxy: {...}, ret_nodes: [...] } detector: any key matching /^cdc_/ flags the session *The hard-coded cdc_ keys ChromeDriver leaves in the page. The suffix varies by JS built-in; the prefix never changes, which is what makes the enumeration check trivial.*

The reason these objects exist is not carelessness. They are there so the driver’s injected automation code can keep working even if the page reassigns window.Array, window.Promise, or window.Symbol out from under it. ChromeDriver stashes its own private references to the original built-ins under the cdc_ names so a hostile page cannot break automation by clobbering the globals. The side effect is a stable, machine-readable signature of ChromeDriver’s presence.

The obvious counter is to hex-edit the chromedriver binary and replace the cdc_ string with a different four-character prefix of the same length, which is exactly the trick that the undetected-chromedriver and nodriver lineage automated. It defeats a literal cdc_ substring match. It does not defeat a detector that has noticed the structural pattern instead of the literal string. DataDome has written about tracking modified ChromeDriver builds despite renamed variables, the point being that the existence of a matched set of private references to JS built-ins, injected at document start, is itself the fingerprint. Rename the prefix and you still have seven oddly-named globals that shadow seven specific intrinsics, which no real page produces. This is the recurring shape of the whole field: patching the literal value of a tell is easy, and matching the statistical profile of a real browser is hard, a gap covered at length in why stealth plugins lose.

Playwright’s bindings: playwright and the init scripts

Playwright leaves a different set of fingerprints, and they come from how it exposes Node-side functions into the page rather than from a driver binary.

The default Playwright build exposes two named globals on the window that anti-bot research has flagged repeatedly. Castle’s March 2025 Playwright detection writeup names __playwright__binding__ and __pwInitScripts as present in the window object by default. The first comes from Playwright’s binding machinery, the bridge it uses to call back from the page into the controlling process when you use page.exposeBinding() or page.exposeFunction(). The second relates to Playwright’s init-script system, the mechanism behind addInitScript that runs your setup code before any page script. A detector that checks '__pwInitScripts' in window or looks for the binding global catches a default Playwright session the same way the cdc_ check catches default Selenium.

Playwright’s exposed functions carry finer tells too. The same research notes that functions injected through page.exposeBinding() end up with a detectable __installed boolean property on them, and that calling toString() on an exposed function reveals Playwright source code, including the literal error string exposeBindingHandle supports a single argument. That is a precise, quotable signature: a page can grab the function, stringify it, and look for Playwright’s own internal text. There is no version of “spoof the user agent” that hides a function that literally serializes its own library’s source when stringified.

Patchright, the patched Playwright fork maintained under the Kaliiiiiiiiii-Vinyzu project, exists specifically to file these tells off. Its README lists the leaks it targets: the Runtime.enable leak (handled by running JavaScript in isolated execution contexts, covered in the next section), the Console.enable leak (handled by disabling the Console API entirely), and a set of “Command Flags” leaks that involve dropping the launch flags Playwright sets by default, such as --enable-automation, --disable-popup-blocking, --disable-component-update, --disable-default-apps, and --disable-extensions, while adding --disable-blink-features=AutomationControlled. Those default flags are themselves a fingerprint. A real Chrome started by a human does not pass --enable-automation, and the set of flags a process was launched with is partly observable through behavior the flags change.

The flag list is worth dwelling on because it is the cleanest example of a library-layer leak that has nothing to do with the transport. Whether you drive Chrome over CDP or WebDriver, if the launch command carries --enable-automation the resulting browser behaves slightly differently from a default one, and a careful detector can tell. Switching libraries does not help. Switching to a build that launches with a real-Chrome flag set does.

The CDP leak both drivers shared: Runtime.enable

The most interesting signal in this whole comparison is one that, until 2025, belonged to neither Puppeteer nor Playwright specifically. It belonged to the CDP transport, which means it hit both at once and could not be patched by choosing between them.

Every CDP-based library needs to run JavaScript in the page, which means it needs the ExecutionContextId of each frame. The standard way to learn those IDs is to send the Runtime.enable command, which switches on the Runtime domain and makes the browser emit Runtime.executionContextCreated events carrying the context IDs the library wants. Puppeteer, Playwright, and CDP-mode Selenium all sent Runtime.enable on startup by default. The problem is that enabling the Runtime domain also changes how the browser handles certain object serializations across the CDP WebSocket, and that change is observable from inside the page with a handful of lines of JavaScript.

The mechanism, documented by DataDome’s VP of Research Antoine Vastel in a June 13 2024 post and dissected further by Castle, exploited error-object serialization. When the Runtime domain is active and an error object is logged or otherwise previewed for the inspector, V8 would serialize that object for transmission over the CDP channel, and that serialization step read the object’s properties, including any custom getter the page had defined on something like the .stack property. A page could define such a getter, log an error, and watch for its getter to fire. If the getter ran with no DevTools window open, something was serializing objects for an inspector that was not there, and that something was an automation library’s Runtime.enable. The check was cheap, generic, and indifferent to which library had enabled the domain.

automation library Runtime.enable browser: Runtime domain now active page: Object.defineProperty(err, 'stack', { get() { /* tripwire */ } }) page defines a getter, then logs the error V8 serializes the error for the inspector preview the getter fires with no DevTools window open CDP-controlled session flagged *The Runtime.enable error-serialization tell. The page never sees the WebSocket; it infers the active Runtime domain from a getter firing during an inspector preview that no human triggered.*

That single technique is why a wave of patched drivers appeared. The rebrowser-patches project, which patches both Puppeteer and Playwright, exists to stop the driver from sending Runtime.enable automatically and instead acquire context IDs another way. It offers three modes, exposed through the REBROWSER_PATCHES_RUNTIME_FIX_MODE environment variable: an addBinding default that creates a binding in the main world and calls it to recover the context ID, an alwaysIsolated mode that runs everything in an isolated world created with Page.createIsolatedWorld, and an enableDisable mode that calls Runtime.enable then Runtime.disable immediately to grab the events without leaving the domain on. Patchright takes the isolated-world route for Playwright, and nodriver was built from the start to avoid the leak natively. The detail and the trade-offs of each mode are the subject of detecting CDP in the wild: the Runtime.enable leak and the V8 patch war, and the broader question of CDP as a detection vector is in the Chrome DevTools Protocol as a detection vector.

Then Google closed the window from its side. In May 2025, V8 landed two commits that stopped the error-serialization side effect: one on May 7 (“Avoid error side effects in DevTools”) and one on May 9 (“Apply getter guard throughout error preview”), tracked under Chromium issue 40073683. The fix introduced a getter guard in the inspector’s value-mirror code that checks whether a getter belongs to user-defined script and skips running it during the preview, so a page’s .stack getter no longer fires when V8 serializes an error for an active Runtime domain. The classic signal stopped working, and as Castle’s August 2025 post on the change put it, mostly nobody noticed. It is a clean illustration of the four-to-six-year cadence these signals run on: a detection technique gets documented, the ecosystem races to patch the drivers, and then the browser vendor quietly removes the underlying behavior, resetting the board. The CDP transport got a little less leaky, not because of any library, but because V8 changed.

This is the heart of why “which driver” is the wrong question. The biggest CDP detection of the 2024 era was a property of the transport and the V8 build, not of Puppeteer or Playwright. A team that had bet everything on choosing the “stealthier” of the two libraries gained nothing against it, because both spoke the same protocol to the same engine. A team that understood the leak lived at the transport layer patched it once, in the place where it lived.

Puppeteer’s tells and the headless tax

Puppeteer’s framework-specific surface is thinner than Selenium’s or Playwright’s, which is partly why it has been a popular base for stealth work, but it is not zero.

Like Playwright, Puppeteer needs to bridge page-side calls back into Node, and it does so through CDP’s Runtime.addBinding, applying an internal prefix to the binding name. Exposed functions added with page.exposeFunction() land on the page’s window object and stay there, which the Puppeteer maintainers have discussed as a known constraint of the binding design. Those bindings and the script Puppeteer injects to install its utility helpers are observable in the same general way Playwright’s are, though Puppeteer’s defaults expose fewer distinctively named globals than Playwright’s __playwright__binding__ and __pwInitScripts. The exact internal binding names are an implementation detail that shifts across versions, so a detector that hard-codes one will rot; the durable check is structural, the same lesson as the cdc_ rename.

The larger Puppeteer tell historically had nothing to do with Puppeteer at all. It was headless Chrome. The default automation path ran the old headless mode, which differed from real Chrome in dozens of measurable ways, the loudest being the user-agent string. Unmodified headless Chrome advertised HeadlessChrome directly in the UA and in the sec-ch-ua client-hint header, with version strings like HeadlessChrome/131.0.0.0 showing up in the research. A server-side check for the HeadlessChrome token caught it before a line of JavaScript ran. That, and the long tail of rendering and API differences, is its own subject covered in headless Chrome detection: every tell and the HeadlessChrome user-agent token.

The reason this matters for the three-way comparison is that the headless tax is shared too. Selenium running headless Chrome paid the same HeadlessChrome UA tax. The new headless mode introduced as --headless=new, which renders with the same engine path as headed Chrome, narrowed that gap considerably and changed which signals fire, and browser vendors have generally been making automation harder to distinguish from real browsing over the last two years. So a chunk of what people attributed to “Puppeteer being detectable” was really “old headless Chrome being detectable,” and it moved when the browser moved, independent of the driver.

What sorts into which bucket

Pulling the threads together, the detection surface of each driver is best read as a layered stack rather than a single score, and the layers do not move together.

layer Selenium Puppeteer Playwright navigator.webdriver W3C spec, shared true true true transport WebDriver vs CDP HTTP wire Runtime.enable Runtime.enable framework globals injected names cdc_*, ret_nodes bindings __pwInitScripts launch flags --enable-automation yes yes yes headless tax UA + render path shared shared shared *The same signals sorted by layer. Orange marks a leak that originates at that layer for that driver; grey marks a shared or transport-neutral signal. Notice how few rows are genuinely framework-specific.*

Two rows are shared by all three. The navigator.webdriver flag is shared because the W3C spec mandates it, and the headless tax is shared because it belongs to Chrome, not to any driver. One row is shared by exactly two: the Runtime.enable leak hit Puppeteer and Playwright together because they share CDP, and Selenium only inherited it when running in CDP mode. Only two rows are genuinely framework-specific in the sense the original question imagines: Selenium’s cdc_ family, which exists because of its HTTP transport and driver binary, and Playwright’s named bindings and init-script globals. Puppeteer’s framework layer is the thinnest of the three, which is the real reason it became the favorite base for stealth tooling, more than any inherent stealth property.

So when someone asks which driver is hardest to detect, the useful reframing is: at which layer are you being detected, and does your choice of driver move that layer at all. If the detector keys on navigator.webdriver or the HeadlessChrome UA, all three drivers are equally exposed and equally patchable, and the driver is irrelevant. If it keys on the Runtime.enable serialization tell, the two CDP drivers were equally exposed until V8’s May 2025 patch and Selenium-over-CDP with them, and again the choice between Puppeteer and Playwright bought nothing. Only at the framework-globals and launch-flags layer does the driver name actually change the answer, and that is precisely the layer that the patched forks (undetected-chromedriver for Selenium, Patchright and rebrowser-patches for the CDP pair) target by renaming, isolating, or stripping. Which means the practical question is not “which driver” but “which patched build of which driver, against which layer,” and a serious anti-bot vendor scores all the layers at once anyway, so a clean answer at one layer is worth little if you are loud at another. The driver is the least interesting variable in its own comparison.


Sources & further reading

Frequently asked questions

Why is asking which automation driver is hardest to detect the wrong question?

An anti-bot system never detects a driver as a single unit. It sees a stack of separate signals that sort into transport-level tells and framework-specific globals. A leak at the transport layer cannot be patched by switching libraries, and a leak at the library layer is not caught by a detector watching only the transport. The useful question is which layer you are detected at, and whether your driver choice moves that layer at all.

How does Selenium's transport differ from how Puppeteer and Playwright talk to the browser?

Selenium speaks WebDriver, a W3C wire protocol that runs over HTTP with JSON payloads and routes through a separate driver binary like chromedriver. It is request-response, so the browser cannot push unsolicited events back. Puppeteer and Playwright skip that middle layer and speak the Chrome DevTools Protocol directly over one bidirectional WebSocket, which is event-driven from the start and injects nothing by default to do its basic work.

What are the cdc_ globals that ChromeDriver leaves in a page and why are they there?

ChromeDriver injects a set of properties with a fixed cdc_adoQpoasnfa76pfcZLmcfl_ prefix covering built-ins like Array, Promise, Symbol, Object, Proxy, JSON, and Window, plus a separate window.ret_nodes. They exist so the driver's automation code keeps working even if a hostile page reassigns the real built-ins, since ChromeDriver stashes private references under those names. The side effect is a stable signature, and any key matching cdc_ flags an unmodified Selenium session.

How did the Runtime.enable leak let a page detect CDP automation, and what changed in 2025?

CDP libraries send Runtime.enable to learn each frame's execution context ID, which also changes how the browser serializes error objects for the inspector. A page could define a getter on an error's stack property, log the error, and watch the getter fire even with no DevTools window open, revealing an active Runtime domain. In May 2025 V8 landed two commits adding a getter guard that skips user-defined getters during error preview, so the classic signal stopped working.

Does running headless Chrome make Puppeteer easier to detect than other drivers?

The headless tax belongs to Chrome, not to any driver, so it is shared. Old headless Chrome advertised a HeadlessChrome token directly in the user-agent and sec-ch-ua header, with versions like HeadlessChrome/131.0.0.0, and a server-side check caught it before any JavaScript ran. Selenium running the same headless Chrome paid the identical tax. The newer --headless=new mode renders through the same engine path as headed Chrome and narrowed that gap considerably.

Further reading