1.One TLS stack under every Node HTTP client
axios, got, node-fetch, and the built-in fetch are all conveniences over Node's networking layer, which negotiates TLS through Node's bundled OpenSSL with Node's own defaults. The cipher ordering, extension set, and supported groups that results from that configuration is consistent across those libraries and distinct from any browser's.
Cloudflare hashes those handshake properties into a JA3 or JA4 value at the edge. On a zone that scores fingerprints, the decision is available before the request line is parsed. This is why the header-tuning phase of debugging produces no signal: you are editing something the edge has not looked at yet.
Node also exposes fewer knobs here than people expect. You can adjust some TLS options, but you cannot reproduce Chrome's GREASE behaviour and full extension ordering from userland. Getting a browser fingerprint in Node realistically means running a browser.
2.What puppeteer-extra-stealth covers, and what it does not
The stealth plugin is a collection of evasions for known automation tells: navigator.webdriver, inconsistencies in the plugins and mimeTypes arrays, permissions query behaviour, WebGL vendor and renderer strings, Chrome-specific runtime objects missing under automation, and a number of similar surface details. Against naive checks it is effective, and it is a reasonable default if you are running Puppeteer anyway.
It patches the JavaScript-visible surface. It does not change how the browser behaves under behavioural analysis — mouse movement, scroll cadence, timing between interactions — and it does not change the fact that a headless environment differs from a desktop one in ways that are measurable without touching any of the patched properties.
The deeper issue is that the evasion list is a snapshot. Each entry exists because someone found a detection vector; new vectors keep being found, and the plugin trails them. A stack that passes today is not a stack that passes indefinitely, and the degradation is gradual rather than obvious.
3.CDP is a detection surface of its own
Puppeteer and Playwright both drive Chrome through the Chrome DevTools Protocol. Attaching a CDP client changes observable characteristics of the runtime, and detection scripts have historically probed for those characteristics directly rather than for the properties the stealth plugin patches.
This is why "I added stealth and it still fails" is such a common report. The evasions addressed the properties they were written for; the check that caught you was looking at the automation channel itself. The two are different layers, and patching one does not cover the other.
Running Chrome in headful mode inside a virtual display removes some headless-specific signals at the cost of substantially more resource usage per instance. It is a real option for hard targets, and it is also the point at which the infrastructure question — how many browsers, supervised how, recycled how often — becomes the actual engineering problem rather than a detail.
4.Calling OmniScrape from Node
The API is a single POST with no SDK requirement, so native fetch is sufficient. Send the URL with mode auto and enable_solver true; the fast HTTP attempt and the browser escalation are decided server-side based on whether challenge signals appear in the first response.
Read your key from the environment. Check success on the envelope, then data.status_code for what the origin actually returned, then assert something about the body — a length floor, or the presence of a selector you expect. Envelope success plus an origin 404 is a perfectly ordinary outcome and should not enter your dataset as content.
For long or stateful flows — authenticated sessions, multi-step wizards, anything where you already own a working Puppeteer or Playwright script — Browser-as-a-Service is the closer fit. You connect over CDP to a remote browser and keep your script unchanged, rather than reimplementing the flow as a sequence of scrape calls.
123456789101112131415161718192021222324252627282930const API = "https://api.omniscrape.io/v1/scrape";
async function scrape(url, overrides = {}) {
const res = await fetch(API, {
method: "POST",
headers: {
"X-API-Key": process.env.OMNISCRAPE_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
url,
mode: "auto",
enable_solver: true,
output_format: "html",
...overrides,
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
if (!body.success) throw new Error(body.error ?? "unlock failed");
return body;
}
const result = await scrape("https://cf-protected-shop.com/product/8821", {
proxy: "residential:us",
});
console.log(result.metadata.challenge_solved, result.data.status_code);
1234567891011121314151617181920212223242526272829303132333435363738394041const CONCURRENCY = 5; // match your plan's limit
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function scrapeWithRetry(url, attempts = 4) {
for (let i = 0; i < attempts; i += 1) {
const res = await fetch(API, {
method: "POST",
headers: {
"X-API-Key": process.env.OMNISCRAPE_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ url, mode: "auto", enable_solver: true }),
});
if (RETRYABLE.has(res.status)) {
// 429 responses carry Retry-After; otherwise back off exponentially.
const wait = Number(res.headers.get("retry-after")) || 2 ** i;
await sleep(wait * 1000);
continue;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
return null;
}
async function scrapeAll(urls) {
const queue = [...urls];
const out = [];
const workers = Array.from({ length: CONCURRENCY }, async () => {
while (queue.length) {
const url = queue.shift();
const r = await scrapeWithRetry(url);
if (r) out.push(r);
}
});
await Promise.all(workers);
return out;
}
5.Concurrency is a plan limit, not a tuning dial
A common Node mistake is mapping an array of URLs straight into Promise.all. That opens every request at once, which exceeds your plan's concurrency limit and returns 429 with a CONCURRENCY_LIMIT code. Retrying those immediately produces a retry storm that keeps the limit saturated.
A fixed worker pool sized to the plan — 5 on Pay-As-You-Go, 10 on Startup, 25 on Growth — keeps throughput at the ceiling without ever tripping it. The pattern above drains a shared queue from a fixed number of workers, which is both simpler and better behaved than unbounded parallelism with retries bolted on.
Honour Retry-After when it is present. The 429 response includes it precisely so clients do not have to guess, and guessing shorter is how a transient limit turns into a sustained one.
Frequently asked questions
Does switching from axios to got or native fetch help?
No. All of them use Node's TLS layer and present the same handshake fingerprint. The library choice affects your API ergonomics and nothing Cloudflare scores at the edge.
Is puppeteer-extra-stealth enough on its own?
Sometimes, and less often over time. It patches known JavaScript-visible automation tells, but it does not address behavioural analysis or detection that probes the CDP channel itself. Treat it as a maintained dependency, not a solved problem.
Why does my Puppeteer script fail even with stealth enabled?
Most often because the check looked at something stealth does not cover — the automation channel, headless-specific environment characteristics, or interaction timing. Those are different layers from the properties the plugin patches.
Should I use the Scrape API or Browser-as-a-Service?
Use the Scrape API for fetch-and-parse work, including single-page challenges. Use BaaS when you already have a Puppeteer or Playwright script with real state — logins, multi-step flows, long sessions — and want to keep it unchanged while running it on a remote browser over CDP.
How do I avoid 429 responses?
Cap in-flight requests to your plan's concurrency with a fixed worker pool rather than mapping into Promise.all, and honour the Retry-After header when a 429 does occur. Unbounded parallelism plus immediate retry is what turns a brief limit into a sustained one.
Related guides
- How to Bypass Cloudflare When Web Scraping
- How to Bypass Cloudflare in Python
- Puppeteer Web Scraping: Patterns, Anti-Bot Limits, and BaaS Integration
- Web Scraping with Node.js: fetch, Cheerio, and the OmniScrape API
- cloudscraper and cfscrape Alternatives That Still Work
- How to Bypass DataDome When Web Scraping