Puppeteer Stealth: How to Avoid Bot Detection
If you search for puppeteer stealth, you are usually dealing with one of these problems:
- headless Chrome gets challenged
- pages work locally but fail in production
- one IP gets blocked and the whole job falls over
The mistake is thinking stealth is a single package install.
It is not.
Real stealth is a stack:
- browser fingerprint hygiene
- realistic pacing
- stable session behavior
- proxy rotation and cooldowns
- good failure detection
If your Puppeteer jobs are burning IPs or failing unpredictably, the missing piece is usually the network layer. ProxiesAPI helps you rotate and recover without rebuilding the automation code every time.
What stealth plugins actually do
The most common setup is:
npm i puppeteer puppeteer-extra puppeteer-extra-plugin-stealth
import puppeteer from "puppeteer-extra";
import StealthPlugin from "puppeteer-extra-plugin-stealth";
puppeteer.use(StealthPlugin());
That plugin helps by patching some obvious automation signals, such as:
navigator.webdriver- some headless-specific browser quirks
- pieces of the default fingerprint that scream "bot"
That is useful.
It is also incomplete.
It does not solve:
- bad IP reputation
- aggressive request rates
- weird timezone and locale mismatches
- unrealistic user behavior
So yes, stealth plugins matter. No, they are not the whole answer.
Start with a sane baseline browser profile
Before you add clever tricks, fix the obvious issues:
import puppeteer from "puppeteer-extra";
import StealthPlugin from "puppeteer-extra-plugin-stealth";
puppeteer.use(StealthPlugin());
const browser = await puppeteer.launch({
headless: true,
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--lang=en-US,en",
],
});
const page = await browser.newPage();
await page.setViewport({ width: 1366, height: 768 });
await page.setUserAgent(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
"AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/125.0.0.0 Safari/537.36"
);
await page.setExtraHTTPHeaders({ "Accept-Language": "en-US,en;q=0.9" });
This does not make you invisible. It just stops you from looking obviously synthetic.
The biggest stealth mistake: rotating everything
Many teams rotate:
- user agent
- viewport
- timezone
- language
...on every request.
That often looks less human, not more human.
A better model is:
- keep one coherent profile per session
- rotate IPs when needed
- keep timezone, viewport, and locale internally consistent
Example session profile:
function buildSessionProfile(seed = 1) {
const viewports = [
{ width: 1366, height: 768 },
{ width: 1440, height: 900 },
{ width: 1536, height: 864 },
];
return {
viewport: viewports[seed % viewports.length],
locale: "en-US",
timezoneId: "America/New_York",
};
}
The goal is not randomness. The goal is plausible consistency.
Behavior matters more than people admit
If your script:
- lands on a page
- clicks immediately
- extracts the DOM
- exits
...you may pass simple checks but still fail harder targets.
Add small human-like pacing:
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function jitter(base, spread = 400) {
return base + Math.floor(Math.random() * spread);
}
async function humanize(page) {
await sleep(jitter(700));
await page.mouse.move(200, 220);
await sleep(jitter(300));
await page.evaluate(() => window.scrollBy(0, 500));
await sleep(jitter(900));
}
You do not need to simulate a full human. You just need to avoid obviously robotic behavior.
Where stealth plugins still fail
This is the part most "puppeteer stealth" posts skip.
You can still get blocked because of:
- datacenter IPs that are already dirty
- too many requests from one ASN
- login flows that expect longer-lived trust signals
- TLS and transport fingerprints outside the plugin's control
That is why a proxy plan is not optional for many production targets.
Practical proxy rules
- do not hammer one IP for the whole crawl
- keep a cooldown list for blocked IPs
- rotate on real block signals, not every request
- keep sessions sticky when the target expects continuity
With ProxiesAPI or any equivalent proxy layer, the job of Puppeteer becomes simpler: render, interact, and extract. The network layer handles the blast radius.
Launch Puppeteer through a proxy
const browser = await puppeteer.launch({
headless: true,
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--proxy-server=http://USERNAME:PASSWORD@HOST:PORT",
],
});
Then combine that with:
- low concurrency per domain
- backoff on
403,429, and challenge pages - session reuse where it makes sense
That is much closer to real Puppeteer stealth than just installing a plugin and hoping.
Detect failure early
Do not wait until your parser breaks downstream. Detect block pages in-browser:
async function looksBlocked(page) {
const html = await page.content();
const text = html.toLowerCase();
return (
text.includes("captcha") ||
text.includes("access denied") ||
text.includes("unusual traffic") ||
text.includes("verify you are human")
);
}
When you hit a block:
- save HTML and screenshot
- mark the session as burned
- rotate proxy or pause the crawl
- retry with backoff
That kind of instrumentation saves far more time than endlessly tweaking one more stealth flag.
Should you use headful mode?
Sometimes.
Headful mode can help on specific targets that behave differently under headless execution. But it costs more CPU and memory, and it is not a silver bullet.
Use headful mode when:
- a target clearly treats headless sessions differently
- you need to debug flows visually
Do not default to headful mode for everything. Fix pacing, session quality, and proxies first.
A realistic stealth stack for 2026
If I were building a production Puppeteer stack today, I would use:
puppeteer-extra-plugin-stealth- a stable session profile per worker
- moderate delays and scroll behavior
- proxy rotation with cooldowns
- block-page detection and logging
- screenshots and HTML dumps on failure
That stack works because it treats stealth as an operating model, not a gimmick.
Final verdict
The real answer to puppeteer stealth is:
Use the stealth plugin, but stop expecting it to do the whole job.
It is a useful first layer. The rest of the win comes from how you run the browser:
- consistent profiles
- realistic pacing
- proxy hygiene
- good failure handling
That is how you avoid bot detection without burning through your scraper every time a target tightens its defenses.
If your Puppeteer jobs are burning IPs or failing unpredictably, the missing piece is usually the network layer. ProxiesAPI helps you rotate and recover without rebuilding the automation code every time.