---
title: "Playwright Stealth: Setup, Tests and Limits | Scrape.ly"
description: "Playwright stealth in Node.js and Python: package versions, what the evasions patch, tested setup code with real results, and when stealth is not enough."
url: https://scrape.ly/blog/playwright-stealth
markdown: https://scrape.ly/blog/playwright-stealth.md
---

[Guides](https://scrape.ly/blog/category/guides.md)

# Playwright Stealth: How It Works, Tested Setup, and When It Is Not Enough

Playwright stealth means patching a headless browser so it hides the signs of automation. The Node and Python packages, who maintains them, what they patch, tested setup code with real bot.sannysoft.com results, and where stealth stops working.

[Scrape.ly Team The team behind Scrape.ly](https://scrape.ly/blog/author/scrapely-team.md)

- Published

  Oct 23, 2026

- Reading time

  8 min read

![Pixel-art robot with an orange >\_ on its terminal head, disguised in a dark fedora and a raised collar, pulls a data card out of a browser window showing the Playwright logo and drops it into a crate](https://scrape.ly/media/playwright-stealth.png?w=1080)

Playwright stealth is the practice of patching a Playwright browser so it hides the obvious signs that a script is driving it, such as `navigator.webdriver` being `true` or "HeadlessChrome" in the User-Agent. In Node.js you get it from `playwright-extra` with the `puppeteer-extra-plugin-stealth` plugin; in Python, from the `playwright-stealth` package on PyPI.

Below: who maintains each package and when they last shipped (checked on npm, PyPI and GitHub on 23 October 2026), what the evasions actually patch, setup code we tested in both languages with the results it produced, and the detection it can't touch. At the end, the hosted alternative that makes the plugin unnecessary.

## The stealth packages at a glance

|                     | Node.js                                                                                  | Python                                                                                                 |
| ------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| Packages            | `playwright-extra` 4.3.6 + `puppeteer-extra-plugin-stealth` 2.11.2                       | `playwright-stealth` 2.0.3                                                                             |
| Last release        | 1 March 2023 (both)                                                                      | 4 April 2026                                                                                           |
| Maintainer          | berstend, in the [puppeteer-extra](https://github.com/berstend/puppeteer-extra) monorepo | Mattwmaster58, a [fork](https://github.com/Mattwmaster58/playwright_stealth) of AtuboDad's Python port |
| Repository activity | Last push 18 July 2024, 277 open issues                                                  | Last push 4 April 2026                                                                                 |
| License             | MIT                                                                                      | MIT                                                                                                    |
| Evasions            | 17 modules                                                                               | 19 switches                                                                                            |
| Works with          | Playwright (Chromium) and Puppeteer                                                      | Playwright for Python, Python 3.9+                                                                     |

Two things stand out. The Node plugin, the one most tutorials use, hasn't had a release in more than three and a half years. And the Python package changed its API in version 2.0, so most Python tutorials online no longer run as written (more on that below).

## What the stealth plugin patches

A default headless Chromium differs from a normal Chrome in dozens of small ways that a page's JavaScript can read. The stealth evasions overwrite them before any page script runs. Grouped by what they hide:

| What gives headless away                                                  | Evasions (Node module / Python switch)                                                                                                                                      |
| ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The automation flag                                                       | `navigator.webdriver` / `navigator_webdriver`                                                                                                                               |
| Launch flags a normal browser doesn't use, such as `--disable-extensions` | `defaultArgs` (Node); the Python package patches launch options through `use_async` / `use_sync`                                                                            |
| "HeadlessChrome" in the User-Agent and client hints                       | `user-agent-override` / `navigator_user_agent`, `navigator_user_agent_data`, `sec_ch_ua`                                                                                    |
| Missing `window.chrome` objects                                           | `chrome.app`, `chrome.csi`, `chrome.loadTimes`, `chrome.runtime` (same in Python)                                                                                           |
| Empty or odd browser APIs                                                 | `navigator.plugins`, `navigator.permissions`, `navigator.languages`, `navigator.vendor`, `navigator.hardwareConcurrency`, `media.codecs` (Python adds `navigator_platform`) |
| The software GPU                                                          | `webgl.vendor` / `webgl_vendor`                                                                                                                                             |
| Window and frame quirks                                                   | `window.outerdimensions`, `iframe.contentWindow` / `iframe_content_window`, `hairline`                                                                                      |
| Script traces                                                             | `sourceurl` / `error_prototype`                                                                                                                                             |

All of this happens in JavaScript and request headers. Nothing in either package touches your IP address, your TLS handshake or how your script behaves on the page.

## Playwright stealth in Node.js

Install Playwright, the wrapper and the plugin:

```shell
npm install playwright playwright-extra puppeteer-extra-plugin-stealth
npx playwright install chromium
```

`playwright-extra` is a drop-in replacement for Playwright's `chromium` export that accepts plugins:

```javascript
import { chromium } from 'playwright-extra';
import stealth from 'puppeteer-extra-plugin-stealth';

chromium.use(stealth());

const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto('https://bot.sannysoft.com', { waitUntil: 'networkidle' });
await page.waitForTimeout(2000);
const result = await page.evaluate(() => ({
  webdriver: navigator.webdriver,
  userAgent: navigator.userAgent,
  failedOrWarn: document.querySelectorAll('td.failed, td.warn').length,
}));
console.log(result);
await browser.close();
```

[bot.sannysoft.com](https://bot.sannysoft.com/) is a public fingerprint test page that marks each check as passed, warned or failed. The script counts the failures.

## playwright-stealth in Python

Install with `pip install playwright playwright-stealth`, then `playwright install chromium`. Version 2 wraps the whole Playwright context manager, so every page from every context gets the patches:

```python
import asyncio

from playwright.async_api import async_playwright
from playwright_stealth import Stealth

CHECK = """() => ({
    webdriver: navigator.webdriver,
    userAgent: navigator.userAgent,
    failedOrWarn: document.querySelectorAll('td.failed, td.warn').length,
})"""

async def main():
    async with Stealth().use_async(async_playwright()) as p:
        browser = await p.chromium.launch(headless=True)
        page = await browser.new_page()
        await page.goto("https://bot.sannysoft.com", wait_until="networkidle")
        await page.wait_for_timeout(2000)
        print(await page.evaluate(CHECK))
        await browser.close()

asyncio.run(main())
```

For the sync API, use `Stealth().use_sync(sync_playwright())`. You can switch evasions on and off with keyword arguments, for example `Stealth(navigator_languages_override=("fr-FR", "fr"))`.

**If you're following an older tutorial:** `from playwright_stealth import stealth_async` (and `stealth_sync`) was the 1.x API. In 2.0.3 that import fails with `ImportError: cannot import name 'stealth_async'`. The changelog lists 2.0.0 as a breaking change.

## What our test showed

We ran both scripts on 23 October 2026 on macOS: Node with Playwright 1.64.1, Python with Playwright 1.64.0, both driving headless Chromium 156. Each was run once without stealth and once with it. Node and Python gave identical results:

| Check                                   | Plain headless Playwright | With stealth |
| --------------------------------------- | ------------------------- | ------------ |
| `navigator.webdriver`                   | `true`                    | `false`      |
| "HeadlessChrome" in User-Agent          | Yes                       | No           |
| `navigator.plugins.length`              | 0                         | 3            |
| `navigator.languages`                   | `en-US`                   | `en-US,en`   |
| `window.chrome`                         | missing                   | present      |
| bot.sannysoft.com rows passed           | 18                        | 31           |
| bot.sannysoft.com rows failed or warned | 13                        | 0            |

The 13 flagged rows without stealth included the WebDriver check, the missing Chrome object, the permissions check, the plugin checks and the "HEADCHR" headless tests. With stealth loaded, every row passed.

That's a real improvement, and it's also the ceiling of what a test page like this measures. Passing bot.sannysoft.com means your browser's JavaScript surface looks normal. It doesn't mean a commercial anti-bot system will let you through.

## Match the fingerprint to your proxy's location

Stealth makes a browser look normal. It doesn't make it look like it lives where your IP address does. If you scrape through a German proxy, the browser's language and time zone should be German too, and Playwright sets both per context. Here the stealth defaults get in the way. In our test, a context created with `locale="de-DE"` sent `Accept-Language: de-DE`, but with the Python package's default settings, page scripts saw `navigator.languages` as `['en-US', 'en']`. Without stealth it was `['de-DE']`. Two signals that disagree are worse than one that's honest.

The fix is to tell the package which languages to report:

```python
import asyncio

from playwright.async_api import async_playwright
from playwright_stealth import Stealth

async def main():
    stealth = Stealth(navigator_languages_override=("de-DE", "de"))
    async with stealth.use_async(async_playwright()) as p:
        browser = await p.chromium.launch(headless=True)
        context = await browser.new_context(
            locale="de-DE",
            timezone_id="Europe/Berlin",
            # proxy={"server": "http://proxy.example:8000", "username": "user", "password": "pass"},
        )
        page = await context.new_page()
        await page.goto("https://quotes.toscrape.com/")
        print(await page.evaluate(
            "() => [navigator.languages, Intl.DateTimeFormat().resolvedOptions().timeZone]"
        ))
        await browser.close()

asyncio.run(main())
```

With the override it printed `[['de-DE', 'de'], 'Europe/Berlin']`, consistent with the `Accept-Language` header. Each context can also take its own `proxy`, so one browser can run several identities, each with matching IP, language and time zone. Re-run a check like this after every Playwright or plugin upgrade: the patches target properties that change between Chrome versions.

## Puppeteer stealth

The plugin started life in Puppeteer, and setup there is the same idea with `puppeteer-extra`:

```javascript
import puppeteer from 'puppeteer-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';

puppeteer.use(StealthPlugin());

const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
await page.goto('https://bot.sannysoft.com', { waitUntil: 'networkidle2' });
await new Promise((r) => setTimeout(r, 3000));
const result = await page.evaluate(() => ({
  webdriver: navigator.webdriver,
  passed: document.querySelectorAll('td.passed').length,
  failedOrWarn: document.querySelectorAll('td.failed, td.warn').length,
}));
console.log(result);
await browser.close();
```

With Puppeteer 25.15.0 and puppeteer-extra 3.3.6 (pointed at Chrome for Testing 156 in our test), it printed `{ webdriver: false, passed: 31, failedOrWarn: 0 }`, the same result as Playwright. If you're choosing between the two libraries for scraping, see [Playwright vs Puppeteer](https://scrape.ly/blog/playwright-vs-puppeteer.md); for the Selenium comparison, including a `navigator.webdriver` test of both, see [Playwright vs Selenium](https://scrape.ly/blog/playwright-vs-selenium.md).

## When Playwright stealth is not enough

The plugin's own docs are honest about this. The Node README calls it "a rather interesting cat and mouse game" and says "it's probably impossible to prevent all ways to detect headless chromium". The Python README goes further: "Don't expect this to bypass anything but the simplest of bot detection methods."

Here is what stealth can't fix:

- **Your IP address.** If you scrape from a cloud server, every request comes from a datacenter range. Anti-bot systems weigh where traffic comes from, and no JavaScript patch changes that. You need good proxies; see [residential vs datacenter proxies](https://scrape.ly/blog/residential-vs-datacenter-proxies.md).
- **Your TLS handshake.** Systems like Cloudflare fingerprint the TLS connection itself ([JA3/JA4](https://developers.cloudflare.com/bots/additional-configurations/ja3-ja4-fingerprint/)), before any page script runs.
- **Consistency.** Every patch is a lie that has to match every other signal. One example from our test: both packages rewrite the `sec-ch-ua` client-hint header, but to different brand strings than the same unpatched Chromium 156 sends (`" Not A;Brand";v="99"` from the Node plugin and `"Not.A-Brand";v="99"` from the Python package, versus `"Not:A-Brand";v="8"` without stealth). A detector that knows what each Chrome version really sends can spot mismatches like that.
- **Behavior.** Request rates, navigation patterns and how a session moves through a site are judged separately from the fingerprint. Cloudflare, for example, scores "session characteristics" alongside browser signals ([bot scores](https://developers.cloudflare.com/bots/concepts/bot-score/)).
- **Maintenance.** Detection changes all the time. A plugin whose last release was in March 2023 is patching the Chrome of 2023.

Our guide to [web scraping without getting blocked](https://scrape.ly/blog/web-scraping-without-getting-blocked.md) covers the other layers: pacing, sessions, proxies and challenge handling.

## The hosted answer: the Scrape.ly browser

Instead of patching a headless browser yourself, run your Playwright code on a browser that is built to look like a visitor. The [Scrape.ly browser](https://scrape.ly/docs/browser.md) is a hosted Chromium with anti-bot fingerprinting, its own residential proxy and Cloudflare Turnstile solving, on by default. You don't need a stealth plugin.

You turn it on per Actor (**Settings** → **Scrapely Browser**), rebuild, and every browser launch in your code connects to it over CDP, unchanged. To set the location and the operating system the fingerprint presents, add a browser configuration. In Python:

```python
import asyncio

from crawlee.crawlers import PlaywrightCrawler
from scrapely import Actor

async def main():
    browser_configuration = await Actor.create_browser_configuration(
        country_code="US", state="California", os="windows"
    )
    crawler = PlaywrightCrawler(browser_configuration=browser_configuration)
    await crawler.run(["https://example.com"])

asyncio.run(Actor.main(main))
```

Time zone and language follow the exit country unless you set them, which keeps the fingerprint consistent with the IP, the exact problem stealth plugins can't solve on their own. You can choose Windows, macOS or Android. It costs $0.45 per browser hour at list price, billed in 30-second blocks plus the browser's own traffic, and it counts toward the run's maximum charge. The [anti-blocking page](https://scrape.ly/anti-blocking.md) shows how it fits with the managed proxy.

To start from working code, the [Crawlee + Playwright (Python) template](https://scrape.ly/templates/crawlee-playwright-py.md) gives you a `PlaywrightCrawler` project you can push as an Actor. And if your target is a popular site, check the [Scrape.ly Store](https://scrape.ly/store.md) first: 568 ready-made Actors, maintained by their publishers. For crawling public pages into text, the [Website Content Crawler](https://scrape.ly/store/querystone/website-content-crawler.md) opens Chrome only when a page needs JavaScript.

Scrape.ly Store Skip the browser plumbing Crawl public pages to clean text: HTTP with a Chrome TLS fingerprint, Chrome only when a page needs JavaScript. $0.0005 per page.

[Try it free](https://scrape.ly/store/querystone/website-content-crawler.md) [Browse the Store](https://scrape.ly/store.md)

## FAQ

### Does playwright-stealth still work?

It still does what it says: in our 23 October 2026 test, both the Node plugin and the Python package made headless Chromium 156 pass every check on bot.sannysoft.com. Against commercial anti-bot systems it is a partial fix, because it doesn't change your IP, TLS handshake or behavior.

### Is playwright-stealth for Python maintained?

Yes. The PyPI package is maintained by Mattwmaster58 as a fork of AtuboDad's original port; version 2.0.3 shipped on 4 April 2026 and added a `navigator.userAgentData` evasion. The original AtuboDad repository was last pushed in July 2024.

### Why does `from playwright_stealth import stealth_async` fail?

Because version 2.0 replaced that API. Use `Stealth().use_async(async_playwright())` or `Stealth().use_sync(sync_playwright())`, or `await Stealth().apply_stealth_async(context)` to patch a single context.

### Is there a stealth plugin for Playwright in Node.js?

Not a dedicated one. You use `playwright-extra` with the Puppeteer stealth plugin, `puppeteer-extra-plugin-stealth`, which works with both. Both packages last shipped on 1 March 2023.

### Does stealth work with Firefox or WebKit?

Our tests used Chromium only, and many evasions target Chrome-specific properties such as `window.chrome` and Chrome's client hints. If you need a different engine, test it yourself before relying on it.

### How do I test whether my Playwright scraper looks automated?

Load bot.sannysoft.com with the scripts above and count the failed rows. Then log the request headers your browser sends (`request.all_headers()` in Playwright) and compare `navigator.languages` and the time zone with your proxy's country. Only after that, try your target at a low request rate and watch for challenge pages.

### Is using a stealth plugin legal?

Changing how your own browser presents itself is not illegal in itself. What you scrape and how still matters: stay on public pages, respect robots.txt, the site's terms and rate limits, and avoid personal data you don't need.

About the author

[Scrape.ly Team](https://scrape.ly/blog/author/scrapely-team.md) The team behind Scrape.ly

Scrape.ly is the web data platform from TeraShift GmbH in Switzerland, powered by Evomi: ready-made scrapers in the Scrape.ly Store, a cloud runtime, an API with JavaScript and Python SDKs, proxies, a hosted anti-bot browser and an MCP server for AI agents.

- [Website](https://scrape.ly/index.md)

Keep reading

## More in Guides

- [![Pixel-art robot with an orange >\_ on its terminal head stands thinking between a house with lit windows on the left and a server rack of the same height on the right, a cable with data packets running to it from each](https://scrape.ly/media/residential-vs-datacenter-proxies.png?w=750)](https://scrape.ly/blog/residential-vs-datacenter-proxies.md)
  ### [Residential vs Datacenter Proxies for Web Scraping: Cost, Speed and When to Use Each](https://scrape.ly/blog/residential-vs-datacenter-proxies.md)
  [Residential vs datacenter proxies: residential IPs come from home internet connections and look like ordinary visitors; datacenter IPs come from servers and are faster and cheaper. How they compare, when to use which, real per-GB prices and two real runs.](https://scrape.ly/blog/residential-vs-datacenter-proxies.md)
- [![Pixel-art robot with an orange >\_ on its terminal head pulls a data card out of a browser window showing the green Selenium logo, which is cabled to a monitor showing the blue and yellow Python logo](https://scrape.ly/media/selenium-web-scraping.png?w=750)](https://scrape.ly/blog/selenium-web-scraping.md)
  ### [Selenium Web Scraping in Python: Step-by-Step Guide](https://scrape.ly/blog/selenium-web-scraping.md)
  [Selenium web scraping in Python, step by step: install, headless Chrome, locators, explicit waits, clicking through pages, infinite scroll, screenshots and CSV/JSON export, tested with Selenium 4.51 on 23 October 2026. Plus when Playwright fits better and how to run it at scale.](https://scrape.ly/blog/selenium-web-scraping.md)
- [![Pixel-art robot with an orange >\_ on its terminal head lifts a lit strip of text off the screen of an old grey terminal monitor, where one highlighted line is being read, and drops it into a crate](https://scrape.ly/media/screen-scraping.png?w=750)](https://scrape.ly/blog/screen-scraping.md)
  ### [What Is Screen Scraping (and How It Differs From Web Scraping)](https://scrape.ly/blog/screen-scraping.md)
  [Screen scraping reads data from what a program displays: terminal screens, desktop windows, rendered pages or images. See its three kinds, how RPA and banks use it, where regulators drew lines, and why web scraping from structured data beats reading pixels.](https://scrape.ly/blog/screen-scraping.md)

## The internet is full of data. Start owning yours.

Scrape.ly gives you the infrastructure, the tooling, and the team to extract, process, and operationalize web data at any scale. Setup takes minutes. The advantage lasts forever.

[Get Started Free](https://my.scrape.ly/register) [Chat with Support](mailto:support@scrape.ly?subject=Question%20about%20Scrape.ly\&body=Hi%2C%20I%20have%20a%20question%20about%20Scrape.ly.)

No credit card. No setup calls.
