Back to blog

Playwright Stealth: Configure Anti-Detection for Web Scraping in Python and Node.js

Share article:

Headless browsers in Playwright can leak fingerprint signals that anti-bot systems notice. While Playwright is great for automation, its default settings make these signals easy to spot. Stealth plugins help cover these leaks so your scripts look like real user traffic. This guide explains detection methods, how to set up stealth in Python and Node.js, what gets patched, how to test, and the limits and scaling options.

Document icon inside a rounded square, with an eye symbol overlapping the lower-right corner of the document

TL;DR

  • Playwright stealth isn't a built-in feature. It's third-party packages, playwright-stealth for Python and playwright-extra paired with the Puppeteer stealth plugin for Node.js, that patch fingerprint leaks before page scripts execute.
  • Stealth only fixes browser-level signals. It won't touch IP reputation, TLS fingerprinting, or behavioral analysis, so pair it with proxies and randomized timing for serious targets.
  • Both ecosystems only support Chromium. Firefox and WebKit have no stealth implementation in either language.
  • Test before you scrape. Check navigator.webdriver, plugin count, and User-Agent against a fingerprint test page before pointing the scraper at production.

How websites detect Playwright and headless browsers

Before using stealth patches, it helps to understand what they fix. Anti-bot systems use several detection layers, so a script that passes one check might still get caught by another.

Browser fingerprinting

Websites check things like navigator properties, installed plugins, WebGL renderer strings, supported media codecs, screen size, and many other details to profile visitors. Each value alone isn't suspicious, but headless Chromium often reports settings that real users rarely have, like an empty plugins array or a generic WebGL renderer string. Together, these details create a fingerprint that can flag your session as automated before it even reaches a protected page. The guide on bypassing CreepJS and spoofing browser fingerprinting explains how this scoring works.

Automation markers

Some signals exist only because the browser is being driven by code. navigator.webdriver defaults to true in any Chromium instance launched through CDP. Older headless builds inject the substring HeadlessChrome into the User-Agent string. And Chrome-only APIs like chrome.app and chrome.runtime are missing entirely, because they were never exposed to the automated context in the first place. Any one of these is enough for a basic bot check to reject the session outright.

Behavioral analysis

Detection doesn't stop at page load. Anti-bot systems track mouse movement curves, scroll velocity, time between clicks, and the order in which pages get visited. A script that jumps straight from page load to button click, with no mouse movement in between, doesn't look human, even when every fingerprint value checks out.

Consistency checks

Detection systems also cross-reference signals against each other. A User-Agent that claims to be Windows paired with a navigator.platform value that reports Linux is an immediate red flag, and so is a screen resolution that doesn't match the claimed device type. Spoofing one value without touching the others creates exactly the mismatch these checks are built to catch.

Network-level detection

None of this matters if the connection is already flagged. IP reputation scoring blocks known datacenter ranges and proxy subnets before the browser sends a single request. TLS handshakes leak their own signature too, a technique called TLS fingerprinting. The specific standard has shifted: JA3 was the workhorse for years until Chrome 110 randomized TLS extension order in 2023 and broke it, so most enterprise vendors now run on JA4 instead. Either way, this happens at the network layer, completely separate from anything a stealth patch can touch inside the page.

Stealth plugins target the first two layers: fingerprinting and automation markers. The rest needs proxies, behavioral mimicry, or both, which later sections in this guide cover.

What is Playwright stealth and how does it work?

Nothing inside Playwright itself has a stealth setting. The name refers to third-party packages bolted onto the framework that patch browser fingerprints before any page script gets a chance to run, closing the gaps from the previous section.

Python and Node.js solve this differently. Python gets a dedicated package, playwright-stealth, currently at v2.0.3. It started as a port of the Puppeteer stealth plugin and has since grown its own bundled evasion scripts plus a few Python-only additions. Node.js doesn't have a Playwright-native equivalent at all. Instead, you pair playwright-extra with puppeteer-extra-plugin-stealthplaywright-extra is a thin wrapper that lets the original Puppeteer stealth plugin run against Playwright's API, so what you're actually running is Puppeteer's evasion code, not something built for Playwright.

That distinction matters more than it sounds. The underlying patches, navigator overrides, Chrome API shims, and WebGL spoofing are nearly identical between the two because they trace back to the same source. What's different is ownership: Python forked and maintains its own copy, while Node.js depends on a wrapper around a project that hasn't shipped anything new in years.

Both are Chromium-only. Neither package touches Firefox or WebKit, so cross-browser stealth coverage isn't on the table no matter which language you pick.

That maintenance gap is the real story here. playwright-stealth has shipped four releases in the past year, the latest in April 2026. playwright-extra and puppeteer-extra-plugin-stealth haven't been updated since March 2023. The Node.js stack still passes basic checks, but it isn't absorbing new evasions as detection techniques move forward. If you're choosing a language for a new project and don't already have a Node.js codebase to maintain, that's a real point in Python's favor.

For more on how the two frameworks compare outside of stealth, see Puppeteer vs. Playwright. If you haven't set up a basic Playwright scraper yet, Playwright web scraping covers what this guide assumes you already know.

Feature

Python (playwright-stealth)

Node.js (playwright-extra + plugin)

Active maintenance

Active, v2.0.3 released April 2026

Stale, no core updates since March 2023

Engine architecture

Native Python fork/port

Thin wrapper around Puppeteer's plugin

Configuration style

Constructor keyword arguments

JavaScript Set of string identifiers

Browser support

Chromium-only

Chromium-only

What stealth plugins actually patch in the browser

Each evasion module patches a specific sign. These aren't unusual; they're targeted fixes for the automation markers and fingerprint mismatches that a headless browser shows by default.

Chrome API emulation

Headless Chromium is missing several APIs that only exist in a real Chrome window: chrome.appchrome.csi, and chrome.loadTimes. Stealth fakes their presence, so a fingerprinting script that checks for them gets the expected object instead of undefined. chrome.runtime gets the same treatment, mocking the extension API surface that real Chrome exposes, though it ships disabled by default in the Python package's v2.x line because it can break sites that probe it too aggressively. Enable it explicitly if your target needs it.

navigator.webdriver is the most frequently checked property. Plain Chromium sets it to true, but stealth changes it to undefined to match a real session. navigator.plugins is filled with realistic entries instead of an empty array. navigator.languages is set to values that match a real locale, and navigator.permissions is patched so permission queries don't return the inconsistent results that headless Chrome is known for. navigator.vendor and navigator.hardwareConcurrency are also adjusted to stay consistent with a real desktop Chrome installation.

Rendering and media patches

WebGL reveals its own fingerprint through the vendor and renderer strings it reports, and headless Chromium's defaults don't match real hardware. Stealth overrides both. The media codec list is also faked, since headless builds report a smaller set of supported codecs than regular Chrome, which can be a giveaway.

Automation marker removal

Some patches are designed only to remove signs that the browser was launched by code instead of a person. iframe.contentWindow is fixed so cross-origin iframe access doesn't throw the specific error caused by automation. defaultArgs removes the --enable-automation flag and related launch flags from Chromium's command line before it starts. sourceurl removes a marker that injected evasion scripts can leave in stack traces, and error_prototype goes further by overriding Error.name, since a raw stack trace pointing to an injected script is a clear sign of automation. user-agent-override keeps the User-Agent string consistent across all places it appears, such as the HTTP header, navigator.userAgent, and Sec-CH-UA Client Hints, because mismatches between these are exactly what anti-bot systems check for.

Module count

Both packages include about fifteen to twenty individual evasion modules, depending on whether you count smaller ones like accept-language and console.debug. The Python package ports almost all of them from the original Puppeteer stealth plugin and adds a few of its own, such as error_prototype, which is unique to the Python fork. The main difference beyond that is configuration: Node.js uses a Set of evasion names, while Python uses keyword arguments in the Stealth() constructor.

Complete your Playwright stealth

The stealth plugins mask your browser footprint beautifully, but combining them with Decodo's premium residential proxies is the smart way to stay entirely off the radar.

Setting up Playwright stealth in Python

Create a dedicated project directory before writing any code:

mkdir playwright-stealth-scraper
cd playwright-stealth-scraper

Set up a virtual environment so this project's dependencies stay isolated from everything else on your machine:

python -m venv venv
source venv/bin/activate # on Windows: venv\Scripts\activate

Check your Python version before going further. playwright-stealth requires 3.9 or higher:

python --version
# Expected: Python 3.9.x or higher

Create the main script file:

touch scraper.py

Your project should now look like this:

playwright-stealth-scraper/
├── venv/
└── scraper.py

All code in this section goes inside scraper.py unless stated otherwise.

Installing playwright-stealth

pip install playwright-stealth
playwright install chromium

playwright-stealth installs Playwright as a dependency, but the browser binary must be downloaded separately. Since stealth only works with Chromium, that's the only browser you need to install. You can skip Firefox and WebKit.

For scraping workloads, wrap async_playwright() with the Stealth().use_async() context manager. Every page created inside it, across every context, gets every evasion applied automatically:

import asyncio
from playwright.async_api import async_playwright
from playwright_stealth import Stealth
async def fetch_rendered_html(url: str) -> str:
async with Stealth().use_async(async_playwright()) as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
await page.goto(url, wait_until="networkidle")
html = await page.content()
await browser.close()
return html
asyncio.run(fetch_rendered_html("https://example.com"))

The use of page.goto or browser.close doesn't change. The context manager handles browser and context creation behind the scenes and injects the evasion scripts before anything else runs. It also patches some Chromium launch flags directly, which JavaScript-only patches cannot do.

The sync pattern (quick scripts and prototyping)

For a one-off script or quick debugging, Stealth().use_sync() wraps sync_playwright() the same way:

from playwright.sync_api import sync_playwright
from playwright_stealth import Stealth
with Stealth().use_sync(sync_playwright()) as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto("https://example.com", wait_until="networkidle")
print(page.title())
browser.close()

Use this approach when testing something interactively. If you need to run more than a few pages, the async pattern manages concurrency more effectively.

Manual application with apply_stealth_async

The context manager wraps the whole Playwright instance, which doesn't always fit. If part of your workload needs stealth and part doesn't, or your code already manages the browser lifecycle elsewhere, apply it to a specific context instead:

import asyncio
from playwright.async_api import async_playwright
from playwright_stealth import Stealth
async def fetch_with_manual_stealth(url: str) -> str:
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context()
await Stealth().apply_stealth_async(context)
page = await context.new_page()
await page.goto(url, wait_until="networkidle")
html = await page.content()
await browser.close()
return html
asyncio.run(fetch_with_manual_stealth("https://example.com"))

Only pages created from that context after apply_stealth_async runs will have the evasions. Make sure to call it before new_page(), not after.

Customizing which evasions run

Stealth() takes its configuration as keyword arguments directly, there's no separate config object. Override a specific value, or start from nothing and enable evasions one at a time:

from playwright_stealth import Stealth, ALL_EVASIONS_DISABLED_KWARGS
# override one evasion's value, leave everything else at defaults
stealth = Stealth(navigator_languages_override=("en-GB", "en"))
# start from a blank slate and turn evasions on individually
webdriver_only = Stealth(**{**ALL_EVASIONS_DISABLED_KWARGS, "navigator_webdriver": True})

ALL_EVASIONS_DISABLED_KWARGS helps you isolate exactly which evasion a target checks for, so you don't have to guess.

Don't follow tutorials using stealth_async(page)

Many older tutorials still show stealth_async(page) or stealth_sync(page) called directly on a page object. That was the original v1.x API, which no longer exists in the current package. The 2.0.0 rewrite in mid-2025 replaced it with the Stealth class, switched to keyword-only configuration, and added the ability to patch Chromium's launch arguments along with the JavaScript evasions. If a guide imports stealth_async directly from playwright_stealth, it's describing a version that has been gone for about a year.

For targets that render content after the initial load, how to scrape websites with dynamic content covers waiting strategies. And for locating elements once the page renders, Playwright XPath covers selector syntax.

Full code

import asyncio
from playwright.async_api import async_playwright
from playwright_stealth import Stealth
async def verify_stealth(page) -> dict:
return await page.evaluate("""() => ({
webdriver: navigator.webdriver,
pluginCount: navigator.plugins.length,
languages: navigator.languages,
vendor: navigator.vendor,
userAgent: navigator.userAgent,
})""")
async def fetch_rendered_html(url: str):
async with Stealth().use_async(async_playwright()) as p:
browser = await p.chromium.launch(
headless=True,
args=["--disable-blink-features=AutomationControlled"]
)
context = await browser.new_context(
user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
)
page = await context.new_page()
await page.add_init_script(
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
)
await page.goto(url, wait_until="networkidle")
fingerprint = await verify_stealth(page)
html = await page.content()
await browser.close()
return html, fingerprint
async def main():
url = "https://example.com"
html, fingerprint = await fetch_rendered_html(url)
assert fingerprint["webdriver"] is None, "navigator.webdriver leaked"
assert fingerprint["pluginCount"] > 0, "plugin array is empty"
assert len(fingerprint["languages"]) > 0, "languages array is empty"
assert "HeadlessChrome" not in fingerprint["userAgent"], "User-Agent reveals headless"
print("Stealth checks passed:", fingerprint)
print(f"Fetched {len(html)} characters of HTML from {url}")
if __name__ == "__main__":
asyncio.run(main())

Setting up Playwright stealth in Node.js

Create a dedicated project directory before writing any code:

mkdir playwright-stealth-scraper
cd playwright-stealth-scraper
npm init -y

Check your Node version before going further. Playwright requires Node 18 or higher:

node --version
# Expected: v18.x.x or higher

Create the main script file:

touch scraper.js

Your project should now look like this:

playwright-stealth-scraper/
├── node_modules/
├── package.json
└── scraper.js

Installing the packages

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

You need three packages: playwright itself, the playwright-extra wrapper, and the stealth plugin. All three are required. If you skip one, the setup will not give a clear error, it just will not work.

The import that actually matters

This is the one mistake that breaks the entire setup with no warning: import chromium from playwright-extra, not from playwright.

// wrong, stealth never gets applied, and nothing tells you why
const { chromium } = require("playwright");
// correct
const { chromium } = require("playwright-extra");

playwright-extra wraps the regular chromium launcher with a plugin system but keeps the rest of the API the same. If you import from the plain playwright package, chromium.use() either doesn't exist where you expect it or your editor autocompletes the wrong import, and the script runs without any stealth applied.

Registering the stealth plugin

Call chromium.use(StealthPlugin()) before launching any browser instance, it has to run before chromium.launch(), not after.

CommonJS:

const { chromium } = require("playwright-extra");
const StealthPlugin = require("puppeteer-extra-plugin-stealth");
chromium.use(StealthPlugin());
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto("https://example.com", { waitUntil: "networkidle" });
console.log(await page.title());
await browser.close();
})();

ESM:

import { chromium } from "playwright-extra";
import StealthPlugin from "puppeteer-extra-plugin-stealth";
chromium.use(StealthPlugin());
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto("https://example.com", { waitUntil: "networkidle" });
console.log(await page.title());
await browser.close();

For the ESM version, add "type": "module" to package.json or save the file with an .mjs extension instead.

Customizing which evasions run

By default, StealthPlugin() enables every evasion module it ships with. Pass an enabledEvasions Set to run a specific subset instead:

const { chromium } = require("playwright-extra");
const StealthPlugin = require("puppeteer-extra-plugin-stealth");
const enabledEvasions = new Set([
"chrome.app",
"chrome.runtime",
"navigator.webdriver",
"navigator.plugins",
"navigator.languages",
"iframe.contentWindow",
"user-agent-override",
]);
chromium.use(StealthPlugin({ enabledEvasions }));

Anything not included in the Set remains unpatched. Check StealthPlugin().availableEvasions for the full list of module names in your version, as this can change between releases.

TypeScript

playwright-extra and most of its plugins are written in TypeScript and ship their own type declarations, so chromium.use()StealthPlugin(), and everything else get full autocomplete and type checking with no separate @types package to install.

For broader Node.js scraping patterns outside of Playwright, see web scraping with Cheerio and Node.js. For more on Node.js itself, the glossary entry covers the runtime basics this guide assumes.

Full code

const { chromium } = require("playwright-extra");
const StealthPlugin = require("puppeteer-extra-plugin-stealth");
chromium.use(StealthPlugin());
async function verifyStealth(page) {
return page.evaluate(() => ({
webdriver: navigator.webdriver,
pluginCount: navigator.plugins.length,
languages: navigator.languages,
vendor: navigator.vendor,
userAgent: navigator.userAgent,
}));
}
async function fetchRenderedHtml(url) {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(url, { waitUntil: "networkidle" });
const fingerprint = await verifyStealth(page);
const html = await page.content();
await browser.close();
return { html, fingerprint };
}
async function main() {
const url = "https://example.com";
const { html, fingerprint } = await fetchRenderedHtml(url);
if (fingerprint.webdriver !== undefined) {
throw new Error("navigator.webdriver leaked");
}
if (fingerprint.pluginCount === 0) {
throw new Error("plugin array is empty");
}
if (fingerprint.languages.length === 0) {
throw new Error("languages array is empty");
}
if (fingerprint.userAgent.includes("HeadlessChrome")) {
throw new Error("User-Agent reveals headless");
}
console.log("Stealth checks passed:", fingerprint);
console.log(`Fetched ${html.length} characters of HTML from ${url}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

Testing and verifying your stealth setup

One of the biggest mistakes is using stealth and just assuming it works. Usually, you only notice something is wrong when you start getting blocked in production without any clear error messages. By that point, you've already wasted the proxy budget and lost data on a run that seemed fine.

Browser fingerprint test pages

bot.sannysoft.com is a common place to start testing. It checks many automation signals, such as navigator.webdriver, plugin count, WebGL renderer, and User-Agent consistency, all on one page. Each signal is marked green or red. While it's not a full test, the CreepJS-based check in the section about bypassing CreepJS and spoofing browser fingerprints is more thorough; it's the fastest way to spot a broken stealth setup before using it on a real target.

For a quick manual check during development, navigate to the test page and save a screenshot:

await page.goto("https://bot.sannysoft.com", wait_until="networkidle")
await page.screenshot(path="stealth_check.png", full_page=True)

Open the screenshot and check for any red rows. This method is good for a quick check, but it doesn't scale. It also won't catch issues that appear later, such as when a dependency update quietly breaks one of your evasions weeks later.

Programmatic verification

If you plan to run your setup more than once, use page.evaluate() to get the actual fingerprint values and check them directly, instead of just looking at a screenshot:

async def verify_stealth(page) -> dict:
return await page.evaluate("""() => ({
webdriver: navigator.webdriver,
pluginCount: navigator.plugins.length,
languages: navigator.languages,
vendor: navigator.vendor,
userAgent: navigator.userAgent,
})""")
result = await verify_stealth(page)
assert result["webdriver"] is None, "navigator.webdriver leaked"
assert result["pluginCount"] > 0, "plugin array is empty"
assert len(result["languages"]) > 0, "languages array is empty"
assert "HeadlessChrome" not in result["userAgent"], "User-Agent reveals headless"

You can use the same approach in Node.js. The page.evaluate() function works the same way there. Just use the assertions that match your test runner.

Verification checklist

  • navigator.webdriver should return undefined, not true. Some older guides say it should be false, but undefined is what a real, unautomated Chrome session shows, and that's what the patches in this guide aim for
  • navigator.plugins.length is greater than 0
  • navigator.languages is a populated array, not empty
  • navigator.vendor returns “Google Inc.”
  • The User-Agent string contains no HeadlessChrome substring

Ongoing monitoring

A stealth setup that works today might stop working next month, even if you haven't changed your code. Evasion modules can become outdated, target sites add new checks, and sometimes a dependency update breaks a patch. Run the verification function above on bot.sannysoft.com and a few target pages regularly, especially for anything in production. If you see a failure, check for package updates before assuming the target site changed. For more on what to do when stealth isn't enough, see the section on web scraping without getting blocked.

Limitations of Playwright stealth

Stealth only patches one layer: the JavaScript properties and browser environment signals that fingerprinting scripts can read. This is important, and many anti-bot systems stop at this layer, but it's not the only thing that matters. Stealth doesn't help with anything outside this layer.

IP reputation

This is checked before your stealth patches even come into play. Datacenter IP ranges and known proxy subnets are often scored and blocked at the network level, sometimes before the browser even sends a request. Even if your fingerprint is perfect, if your IP is flagged, you'll still get blocked. In these cases, the fingerprint isn't the issue.

TLS fingerprinting

Every TLS handshake produces its own signature. The original JA3 fingerprint standard lost most of its reliability after Chrome 110 started randomizing TLS extension order in 2023, so by 2026 most enterprise anti-bot vendors, Cloudflare and DataDome included, score connections against JA4 instead, which sorts before hashing specifically to survive that randomization. Either fingerprint gets evaluated at the network layer, before a single JavaScript evasion gets a chance to run. No amount of property spoofing touches this.

Advanced JavaScript challenges

Cloudflare, DataDome, and PerimeterX go beyond just reading navigator properties. They use cryptographic proof-of-work challenges, server-side session checks, and detection methods that change faster than open-source stealth plugins can keep up. Even a well-maintained plugin might only handle last year's checks, while these vendors release new ones every week.

Behavioral analysis

None of this covers how a session behaves over time. Things like mouse movement patterns, scroll speed, time between clicks, and the order of page visits aren't affected by stealth. If your script loads a page and clicks a button without any mouse movement, it still looks like a bot, no matter how good your fingerprint is.

When stealth alone is enough, and when it isn't

Stealth is enough for sites running basic protections: a navigator.webdriver check, a missing-plugins check, a simple User-Agent filter. That covers a large share of the web. Most sites aren't running enterprise anti-bot infrastructure.

Stealth stops being enough when a target uses a well-known anti-bot vendor, needs to handle large-scale scraping, or shows signs like unexpected CAPTCHAs, fast IP bans, or files returning as HTML instead of data. At that point, you need to decide whether to add proxy rotation and behavioral mimicry, or use a managed service. For sites protected by Cloudflare, stealth alone usually only works on the lowest security settings.

Advanced stealth strategies for scraping at scale

Stealth helps you get started, but the rest of this section is about staying in long enough to collect the data you need, without wasting proxies and sessions.

Session management

Persist cookies and storage state within a session instead of starting cold on every request. Playwright supports this directly:

# save state after a session does its work
await context.storage_state(path="session.json")
# reuse it on the next run instead of starting cold
context = await browser.new_context(storage_state="session.json")

Don't let a session run forever. Rotate sessions on a set schedule, after a certain number of requests, or as soon as you notice the block rate going up. Also, keep each session limited to a single target. Using the same session and cookies across different sites can create its own fingerprint issues

Mimicking human behavior

Stealth only fixes the fingerprint. It doesn't help with timing, and perfectly consistent timing is a giveaway. Add some randomness to things like scroll depth, how long you stay on a page before acting, and the time between actions:

import random
import asyncio
async def human_pause(min_ms=300, max_ms=1200):
await asyncio.sleep(random.uniform(min_ms, max_ms) / 1000)

You don't need perfect realism, since that's not practical. The main goal is to avoid the obvious sign of a script that always goes from page load to click in just a few milliseconds.

CAPTCHA handling

Try to detect a CAPTCHA as soon as possible, instead of finding out after a timeout. Signs include an empty DOM where content should be, an interstitial page, or a redirect loop back to the same URL. These all mean you've hit a challenge instead of the real page.

Once you've detected a CAPTCHA, follow a recovery ladder instead of blindly retrying the same request. Retry once, then start a new session, then switch IPs. If none of that works, do a full reset of the session, IP, and fingerprint together. Treat a rising CAPTCHA rate as a sign to back off and reassess, not as an obstacle to push through with more retries.

How to bypass CAPTCHAs: the ultimate guide covers the recovery ladder in more depth, and how to bypass Google CAPTCHA is worth a look if reCAPTCHA specifically is what you're running into.

Proxy integration

Stealth and proxies address different issues, and you need both for serious scraping. Stealth fixes the fingerprint, while proxies handle IP reputation. The usual setup for production scraping is to combine stealth with rotating residential proxies. Make sure the proxy's location matches your fingerprint's locale settings. For example, if your session claims en-US languages and a New York timezone but uses a Frankfurt IP, that's a mismatch and can be detected.

Concurrency and rate limiting

Throttle your requests by domain, not across all sites. One site might allow 10 requests per second from one IP, but block the same rate from another IP pool. There's no single safe rate for all cases. Start with a small test batch before increasing volume, and slow down as soon as you see more blocks, instead of waiting for a full outage. Adaptive concurrency, which means reducing parallelism when failures go up, helps catch issues faster than a fixed worker pool. For more on retries, see the section on retrying failed Python requests.

Monitoring and maintenance

Track your success rate, block rate, and CAPTCHA rate for each domain, not just overall. A 95% success rate across all sites can hide a domain that's failing completely. Save screenshots and HTML snapshots when something fails. These help you quickly spot whether it's a selector issue or a detection problem, especially if it happens late at night. Also, set up a regression suite that tests your stealth setup on a few key targets regularly. This way, you'll catch broken evasions or outdated selectors before they cause silent drops in your data.

For background on why proxy rotation matters here in the first place, see what are rotating proxies?

Stealth makes your scraper harder to detect, but it doesn't make it legal or ethical. These are separate issues. Ignoring them just because you solved the technical challenges can lead to being blocked or even facing legal trouble.

robots.txt and terms of service

Always check robots.txt before scraping and follow its rules. Stealth can help you get past technical defenses, but it doesn't change the fact that many sites' terms of service ban automated access. Just because you're undetectable doesn't mean you're not breaking the agreement or what a court might see as reasonable use.

Rate limiting

Throttle your requests by domain and endpoint, no matter how much your infrastructure can handle. Even if your scraper can send 50 requests per second, you shouldn't do that if the target is a small site with limited resources. Scraping politely isn't just ethical; it also helps prevent the site from adding tougher defenses.

Data handling

If you collect personal data from EU citizens, GDPR applies no matter where your scraper is running. Only store personal information you actually need, and make sure you have a valid reason for keeping it. If you want to use the scraped data for something different than what you collected it for, check the site's terms and the law before doing so.

Operational guardrails

Keep a list of approved targets instead of letting your scraper run on any site it finds. Don't pretend to be a privileged crawler like Googlebot to get special access. Sites that catch this usually respond with strict enforcement. Handle credentials securely, just like in any other production system: don't hardcode or log them. Also, remove personal information from logs, screenshots, and debugging files before they end up in bug trackers or shared channels.

When to use an official API instead

If a target has an API, even if it's rate-limited or paid, it's usually a better choice than stealth scraping. APIs are more stable, less likely to break with site changes, and don't have the same legal risks as scraping around defenses. Use stealth only when there's no official way to get the data.

For more on where the legal lines generally sit, what is web scraping? covers the broader context. Decodo isn't neutral here either; the company co-founded the Ethical Web Data Collection Initiative, a set of industry guardrails for exactly the practices covered in this section.

None of this is legal advice. The specifics vary by jurisdiction and by what you're scraping, so loop in counsel before running anything at scale that touches personal data or a site's terms of service.

Final thoughts

Stealth plugins fix the first layer that most basic detection checks look for: browser fingerprints and automation markers. This is useful and lets you access a lot of sites with minimal setup. But it's not the whole solution. IP reputation, TLS fingerprinting, and behavioral analysis are outside the reach of any JavaScript patch, no matter how good the package is.

A strong setup combines stealth with rotating residential proxies, careful session management, and enough behavioral randomness to avoid obvious patterns. Each part is important. If you skip any one, you risk having a single point of failure.

If a target becomes too difficult for your layered setup, such as when anti-bot vendors are involved, CAPTCHA rates stay high, or IP bans happen faster than you can rotate addresses, it's time to consider a managed solution. Decodo's Scraping API takes care of fingerprint management, anti-bot bypass, and proxy rotation in one place, so your team can focus on the data instead of maintaining a stealth setup.

Supercharge your Playwright scripts

Handle the browser rendering with Decodo's Web Scraping API, which also manages IP rotation and CAPTCHA bypass for a completely frictionless workflow.

Share article:

About the author

Kipras Kalzanauskas

Senior Account Manager

Kipras is a strategic account expert with a strong background in sales, IT support, and data-driven solutions. Born and raised in Vilnius, he studied history at Vilnius University before spending time in the Lithuanian Military. For the past 3.5 years, he has been a key player at Decodo, working with Fortune 500 companies in eCommerce and Market Intelligence.

Connect with Kipras on LinkedIn.

All information on Decodo Blog is provided on an as is basis and for informational purposes only. We make no representation and disclaim all liability with respect to your use of any information contained on Decodo Blog or any third-party websites that may belinked therein.

Frequently asked questions

Does Playwright stealth work with Firefox or WebKit?

No. Both stealth implementations, the Python package and the Node.js plugin, only support Chromium. The evasion modules patch Chrome-specific APIs and runtime behavior, chrome.app and chrome.runtime among them, which don't exist in Firefox or WebKit in the first place. Supporting either browser would mean writing a separate set of evasion modules from scratch, and neither package has done that.

Can Playwright stealth bypass Cloudflare or DataDome?

Not reliably on its own. Cloudflare, DataDome, and PerimeterX rely on TLS fingerprinting, server-side behavioral analysis, and cryptographic challenges, all of which operate outside anything a JavaScript-level stealth patch can touch. Pair stealth with rotating residential proxies for these targets, and if the block rate still doesn't move, a managed option like Decodo's Site Unblocker is built specifically for this tier of anti-bot protection.

Is the Python playwright-stealth package still actively maintained?

Yes. As of mid-2026, playwright-stealth is on v2.0.3 with regular releases over the past year, built around a modern context-manager API. The Node.js side, playwright-extra and puppeteer-extra-plugin-stealth, hasn't shipped an update since March 2023. If a tutorial shows stealth_async(page) called directly on a page object, that's the old v1.x pattern, and it doesn't work with the current package.

Playwright logo and title 'Playwright' showing subtitle 'Web Scraping Tutorial' on a dark dotted background

Playwright Web Scraping: A Practical Tutorial

Web scraping can feel like directing a play without a script – unpredictable and chaotic. That’s where Playwright steps in: a powerful, headless browser automation tool that makes scraping modern, dynamic websites smoother than ever. In this practical tutorial, you’ll learn how to use Playwright to reliably extract data from any web page.

Puppeteer vs. Playwright: Which Tool Is Better for Web Scraping?

Puppeteer vs. Playwright is a real architectural decision for any production scraping project. The two libraries share a common origin: Playwright was built at Microsoft by engineers who previously worked on Puppeteer at Google. Yet they're different on browser coverage, language bindings, and scraping ergonomics. Performance, stealth, proxy integration, and parallel execution decide which tool fits your pipeline.

Code panel showing HTML request beside 'Proxies enabled' and 'Your data is ready!' cards on dark gradient background

Web Scraping Without Getting Blocked: A Practical Guide for 2026

Web scraping without getting blocked is one of the hardest challenges you might face. Whether you’re a business conducting market research or a solopreneur working on your next big thing, most scrapers fail not because the code is wrong, but because websites now run layered detection that flags bots before a single byte of HTML is returned. This guide breaks down all the detection layers, including network, TLS, browser, and behavioral, and delivers the best techniques on how to overcome each.

© 2018-2026 decodo.com (formerly smartproxy.com). All Rights Reserved