๐ŸŽ‰ Premium Proxies ยท 24-Hour Free TrialClaim Now
Comparisons

puppeteer-real-browser vs puppeteer-extra-stealth

puppeteer-real-browser vs puppeteer-extra-stealth compared at the machine level: Xvfb, Docker, CI runners, Chrome version coupling and real release dates.

S SparkProxy 4 30 min read
Share

puppeteer-real-browser vs puppeteer-extra-stealth is usually argued as a stealth-quality question, and that is the wrong axis. The two packages make incompatible assumptions about the computer they run on. One expects a bundled Chromium it can rewrite from JavaScript; the other expects a real Chrome installation and a display server, and it will quietly fall over on a box that has neither. Pick wrong and the post-mortem is a Docker build, not a fingerprint report. Everything below was read from the two projects' own source, npm metadata and GitHub API on 9 October 2026, with the file paths and dates quoted so you can re-check them.

Two bets about the machine, not about the fingerprint

puppeteer-extra-plugin-stealth is a plugin bus. You install puppeteer, which downloads its own pinned Chrome build, launch it through puppeteer-extra, and the plugin injects scripts into every new page and document to overwrite properties that headless Chrome gets wrong. The browser stays where Puppeteer put it. The lies live in the page.

puppeteer-real-browser does almost none of that. Its connect() function launches Chrome through Google's chrome-launcher package, which searches the host for an existing Chrome or Chromium binary, then attaches a CDP client to the resulting DevTools port. No Chromium is downloaded. No evasion bundle is injected. On Linux it first starts a virtual X display and runs that Chrome inside it.

Here is the sharpest way to see the difference. The stealth plugin's user-agent-override evasion ships maskLinux: true in its defaults, and on a Linux host it rewrites the first parenthesised block of the user agent to (Windows NT 10.0; Win64; x64), then reports Win32 for navigator.platform and Windows for the UA-CH platform hint. Source, read today, at packages/puppeteer-extra-plugin-stealth/evasions/user-agent-override/index.js:

get defaults() {
  return {
    userAgent: null,
    locale: 'en-US,en',
    maskLinux: true
  }
}
// ...
if (this.opts.maskLinux && ua.includes('Linux') && !ua.includes('Android')) {
  ua = ua.replace(/\(([^)]+)\)/, '(Windows NT 10.0; Win64; x64)')
}

By default, on your Linux scraping box, the stealth plugin tells the target site you are on Windows. puppeteer-real-browser never makes that claim. It runs the real Chrome on the real operating system and lies about one thing only: whether a monitor is attached.

So one package fakes the OS and keeps the display honest where there is one. The other keeps the OS honest and fakes the display. That is the whole comparison, and it is why the right answer depends on where the job runs rather than on which project has more stars. If you want the wider map of what the plugin ecosystem contains, the evasion-by-evasion survey lives in our guide to stealth plugins for Puppeteer and Playwright; this post stays on the two-way machine question.

What each stack actually puts on the command line

Both projects are trying to arrive at the same place: a Chrome process that was not started like an automation target. They take opposite routes, and the routes are visible in the argv.

Puppeteer's own launcher still passes --enable-automation. In packages/puppeteer-core/src/node/ChromeLauncher.ts on main, read today, the flag sits in the default argument array alongside --disable-background-networking, --disable-component-extensions-with-background-pages, --disable-dev-shm-usage, --metrics-recording-only and the rest. The stealth plugin's defaultArgs evasion removes exactly three of those, and --enable-automation is not one of them:

// puppeteer-extra-plugin-stealth/evasions/defaultArgs/index.js, read 9 Oct 2026
const argsToIgnore = [
  '--disable-extensions',
  '--disable-default-apps',
  '--disable-component-extensions-with-background-pages'
]

That is why the plugin needs a navigator.webdriver evasion at all. The browser is still launched as an automation target, so the property has to be deleted from the prototype after the fact, per page, from injected script.

puppeteer-real-browser takes the other route. It never calls puppeteer.launch(). It calls chrome-launcher, whose DEFAULT_FLAGS list contains 23 switches and does not contain --enable-automation at all. I grepped src/flags.ts on main today to confirm the absence. Then it edits that list before launching, in lib/cjs/index.js:

// puppeteer-real-browser 1.4.4, lib/cjs/index.js, read 9 Oct 2026
const flags = Launcher.defaultFlags();
// Add AutomationControlled to "disable-features" flag
const indexDisableFeatures = flags.findIndex((f) => f.startsWith('--disable-features'));
flags[indexDisableFeatures] = `${flags[indexDisableFeatures]},AutomationControlled`;
// Remove "disable-component-update" flag
const indexComponentUpdateFlag = flags.findIndex((f) => f.startsWith('--disable-component-update'));
flags.splice(indexComponentUpdateFlag, 1);
chromeFlags = [
  ...flags,
  ...args,
  ...(headless !== false ? [`--headless=${headless}`] : []),
  ...(proxy && proxy.host && proxy.port ? [`--proxy-server=${proxy.host}:${proxy.port}`] : []),
  "--no-sandbox",
  "--disable-dev-shm-usage",
];

One detail in that block deserves scepticism. AutomationControlled is appended to --disable-features, not to --disable-blink-features. In Chromium's third_party/blink/renderer/platform/runtime_enabled_features.json5 on main, read today, the entry is:

{
  name: "AutomationControlled",
  base_feature: "none",
  public: true,
  settable_from_internals: true,
},

base_feature: "none" means no base::Feature is generated for it, and --disable-features only accepts base::Feature names. My reading of that source is that the line is a no-op and the commonly cited form is --disable-blink-features=AutomationControlled. Neither project documents the distinction, so treat the equivalence as unverified. What matters for the outcome is the thing that is not in dispute: because chrome-launcher never passes --enable-automation, navigator.webdriver on a puppeteer-real-browser session is false by construction rather than by patch.

Launch concernpuppeteer-extra-stealthpuppeteer-real-browser
Who launches Chrome`puppeteer.launch()` via `puppeteer-extra``chrome-launcher` `launch()`, then `puppeteer.connect()` to the DevTools port
Which binarythe Chrome for Testing build pinned by your `puppeteer` versionfirst match from the host search path
`--enable-automation`passed, then the symptom patched in JSnever passed
`--headless=new`added whenever `headless` is truthy, which is the Puppeteer defaultonly if you explicitly ask, and the README advises against it
`--no-sandbox`only if you pass it, or set `PUPPETEER_DANGEROUS_NO_SANDBOX=true`appended unconditionally unless `ignoreAllFlags: true`
`--disable-component-update`present in Puppeteer's defaultsdeliberately removed
Flag-set provenancePuppeteer's automation defaults`chrome-launcher`'s automation defaults, edited

Notice the last row. Neither stack launches a default Chrome. Both launch an automation-flavoured Chrome, just a differently flavoured one. chrome-launcher's defaults still include --disable-extensions, --mute-audio, --password-store=basic, --no-first-run and a ten-entry --disable-features list covering Translate, MediaRouter, AutofillServerCommunication and PrivacySandboxSettings4. A browser started that way is not what your own Chrome is running. "Real browser" describes the binary and the display, not the configuration.

Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

The Xvfb dependency, line by line

This is the part that decides your deployment, and it is 14 lines of code. From the same file:

let Xvfb
try {
  Xvfb = require("xvfb");
} catch {
  // ignore
}

// ... inside connect():
if (process.platform === "linux" && disableXvfb === false) {
  try {
    xvfbsession = new Xvfb({
      silent: true,
      xvfb_args: ["-screen", "0", "1920x1080x24", "-ac"],
    });
    xvfbsession.startSync();
  } catch (err) {
    console.log(
      "You are running on a Linux platform but do not have xvfb installed. The browser can be captured. Please install it with the following command\n\nsudo apt-get install xvfb\n\n" +
        err.message
    );
  }
}

Read what that does and does not do.

It only runs on Linux. process.platform === "linux" is the gate, so on macOS and Windows no virtual display is created and Chrome opens a window on your actual desktop. There is no third path.

The screen is hard-coded. -screen 0 1920x1080x24 means every session on every Linux host reports a 1920x1080 24-bit display, and -ac disables X host access control for the server it starts. You cannot change either through connect(); you would have to start your own Xvfb and set DISPLAY yourself before the process boots, with disableXvfb: true.

The failure is swallowed. If xvfb the binary is missing, or if require("xvfb") failed earlier and Xvfb is undefined, the catch prints a friendly sentence to stdout and execution continues. Chrome is then launched headful with no DISPLAY, so it exits immediately, and what you actually see in your logs is a chrome-launcher connection failure several frames away from the real cause. The helpful message and the fatal error are not adjacent, which is why "puppeteer-real-browser works locally but not on my server" is the most common shape of the problem.

The Node wrapper is old. xvfb on npm is at 0.4.0, published 2020-07-05, eight versions total, from Rob--W/node-xvfb. It is a thin child-process wrapper, which is fine, but it means the virtual-display layer of your stack has had no release in over six years.

puppeteer-extra-plugin-stealth has no equivalent requirement. It never asks about a display, because the browser it drives is headless by default and headless Chrome has no X dependency.

Headful is the default, and headless is barely supported

connect() declares headless = false in its parameter defaults, and the first thing it does with the value is collapse the string "auto" to false. The README is blunt about the alternatives: "Values such as 'new', true, 'shell' can also be sent, but it works most stable when false is used."

Then look at how the flag is built: ` --headless=${headless} . Pass headless: true and the literal switch becomes --headless=true, which is not one of the values Chrome documents for that switch. I did not test what current Chrome does with an unrecognised value, so I will not claim a behaviour; I will say that the construction is a string interpolation of whatever you passed, and that the maintainer's own recommendation is to leave it at false`.

The practical reading: puppeteer-real-browser is a headful tool. Its stealth story depends on being headful. Strip the display and you have stripped the product, which is the opposite of how the stealth plugin is designed. The plugin's entire premise, stated in its README, is "various techniques to make detection of headless puppeteer harder".

That cuts both ways. Running headful on Xvfb costs real memory per browser, and a 1920x1080x24 framebuffer plus a full Chrome with GPU rasterisation disabled is heavier than a --headless=new Chrome on the same box. I did not benchmark the two side by side for this post, so treat the direction as architectural rather than quantified. If you need concurrency numbers, measure them on your own instance type; published figures for stealth browsers are almost never reproducible across hardware.

The one thing being headful genuinely buys you is a coherent screen. window.screen.width, availHeight, window.outerWidth and the device pixel ratio all come from a real X server rather than from a patch, and those values are among the easiest headless tells to check. Even so, puppeteer-real-browser still needs one JavaScript lie, because CDP-synthesised input does not carry screen coordinates. From lib/cjs/module/pageController.js:

Object.defineProperty(MouseEvent.prototype, 'screenX', {
  get: function () {
    return this.clientX + window.screenX
  }
})

A "real browser" that needs a prototype patch for MouseEvent.screenX is a useful reminder that the categories leak. For what the underlying tells look like from the detector's side, see our breakdown of headless browser detection.

Containers: who installs Chrome, and the env var that does nothing

puppeteer-real-browser ships a Dockerfile in its repository root. Here it is, verbatim, read today:

FROM node:latest

RUN apt-get update && apt-get install -y \
    wget \
    gnupg \
    ca-certificates \
    apt-transport-https \
    chromium \
    chromium-driver \
    xvfb \
    && rm -rf /var/lib/apt/lists/*

ENV CHROME_BIN=/usr/bin/chromium

WORKDIR /app
COPY package*.json ./
RUN npm update
RUN npm install
COPY . .
CMD ["npm", "run","cjs_test"]

Three things are worth knowing before you copy it.

CHROME_BIN is not read by anything in this stack. chrome-launcher resolves the binary through CHROME_PATH, with LIGHTHOUSE_CHROMIUM_PATH kept as a deprecated fallback. I read src/chrome-finder.ts on main today to check: resolveChromePath() tests process.env.CHROME_PATH and process.env.LIGHTHOUSE_CHROMIUM_PATH, and CHROME_BIN appears nowhere in the package. The image works anyway, by accident of the search order. linux() shells out to which for google-chrome-stable, google-chrome, chromium-browser and chromium in that order, so /usr/bin/chromium is found by name. Install Chrome somewhere off PATH, or rename the binary, and the env var will not rescue you.

The search order has priorities, and they bite on shared hosts. chrome-launcher weights its candidates: chrome-wrapper 51, google-chrome-stable 50, google-chrome 49, chromium-browser 48, chromium 47. On a VPS where both Chrome stable and Chromium are installed, puppeteer-real-browser silently takes Chrome stable, which is the one that auto-updates behind your back. Set CHROME_PATH, or pass customConfig: { chromePath: ... }, if you care which browser your scraper is actually using.

FROM node:latest plus RUN npm update is a non-reproducible build. Two builds a month apart can get different Node majors, different Chromium builds from Debian and different transitive dependency trees. For anything you intend to run again, pin all three.

A corrected version of the same image:

FROM node:22.20-bookworm

RUN apt-get update && apt-get install -y --no-install-recommends \
      chromium xvfb ca-certificates fonts-liberation \
    && rm -rf /var/lib/apt/lists/*

# chrome-launcher reads CHROME_PATH, never CHROME_BIN
ENV CHROME_PATH=/usr/bin/chromium

WORKDIR /app
COPY package*.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "scrape.js"]

The stealth-plugin image is a different shape. You do not apt-install a browser, because npm i puppeteer downloads one. Puppeteer 25.13.0, published 2026-10-08, pins Chrome 155.0.8059.39, and the chrome-linux64.zip for that revision is 198,195,769 bytes on Google's Chrome for Testing storage, which I checked with a HEAD request today. So you trade about 189 MiB of image for a browser version that is written into your lockfile. You still need the shared libraries Chrome links against, which is the part people forget:

FROM node:22.20-bookworm-slim

# Chrome's runtime libs; Puppeteer brings the browser itself
RUN apt-get update && apt-get install -y --no-install-recommends \
      ca-certificates fonts-liberation libasound2 libatk-bridge2.0-0 \
      libatk1.0-0 libcups2 libdbus-1-3 libdrm2 libgbm1 libnspr4 libnss3 \
      libpango-1.0-0 libx11-6 libxcomposite1 libxdamage1 libxfixes3 \
      libxkbcommon0 libxrandr2 \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY package*.json package-lock.json ./
# downloads Chrome 155.0.8059.39 into the image at install time
RUN npm ci --omit=dev
COPY . .
CMD ["node", "scrape.js"]

No xvfb, no DISPLAY, no apt-installed browser. That image runs on any container host. The puppeteer-real-browser image runs only where you have both a Chrome build and a working X server, and where you have accepted that the browser version is whatever the base image's apt repository offered on build day.

CI runners and the display that is not there

CI is the one environment where puppeteer-real-browser's requirements are often already met, and almost nobody realises it.

GitHub's hosted Ubuntu 24.04 runner image ships Google Chrome 154.0.8037.57, ChromeDriver 154.0.8037.57, and xvfb at 2:21.1.12-1ubuntu1.8. Those three lines come from images/ubuntu/Ubuntu2404-Readme.md in actions/runner-images, read today. So a puppeteer-real-browser job on ubuntu-24.04 finds google-chrome-stable first in the search order, starts its own Xvfb through the Node wrapper, and runs with no extra setup step at all.

The catch is the version. Your scraper is now pinned to whatever Chrome the runner image shipped this month, and that number moves when GitHub rolls the image. On 9 October 2026 it is Chrome 154 on the runner and Chrome 155 in Puppeteer's pin, which means the two approaches are not even testing the same browser. Assert the version rather than discovering it later:

# .github/workflows/scrape.yml
jobs:
  scrape:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22.20'
      - run: npm ci
      # Fail loudly if the runner image changed the browser under us
      - run: |
          which google-chrome-stable
          google-chrome-stable --version | tee chrome.txt
          grep -q "Google Chrome 154" chrome.txt
      - run: node scrape.js
        env:
          CHROME_PATH: /usr/bin/google-chrome-stable

Self-hosted runners are where this goes wrong. A minimal container-based runner has neither Chrome nor xvfb, and because the Xvfb failure is caught and logged rather than thrown, the job does not fail at the display step. It fails later, inside chrome-launcher, with a message about not being able to connect to the browser. If you run puppeteer-real-browser anywhere you do not fully control, make the precondition explicit before you call connect():

// preflight.js: assert the machine before the scraper assumes it
const { execSync } = require('node:child_process');

function must(cmd, label) {
  try {
    return execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
  } catch {
    throw new Error(`missing precondition: ${label} (${cmd})`);
  }
}

if (process.platform === 'linux') {
  console.log('Xvfb:', must('which Xvfb', 'xvfb package'));
}
const chrome =
  process.env.CHROME_PATH ||
  must('which google-chrome-stable || which chromium', 'a Chrome/Chromium binary');
console.log('chrome:', chrome);
console.log('version:', must(`"${chrome}" --version`, 'chrome --version'));

Ten lines, and it converts a confusing CDP timeout into a one-line "missing precondition" at job start. The stealth-plugin path needs no such preflight, because its two preconditions, a Node runtime and Chrome's shared libraries, are both satisfied at npm ci time or not at all.

Who owns the Chrome version

Both projects have a version-coupling problem. They are mirror images of each other.

puppeteer-real-browser 1.4.4 declares "rebrowser-puppeteer-core": "^23.3.1". The highest 23.x on npm is 23.10.3, published 2024-12-10, and the caret range stops below 24, so a fresh install today pulls a Puppeteer-23-era CDP client from December 2024. Meanwhile rebrowser-puppeteer-core itself is at 24.8.1, published 2025-05-09. That December-2024 client is then pointed at whatever Chrome the host has, which on an auto-updating desktop or a rolled CI image is a 2026 build. Nothing in the dependency tree couples the two. When a protocol domain changes shape, you find out at runtime.

The project also removes --disable-component-update from the flag list, which means the Chrome it launches is permitted to fetch component updates while running. That is arguably more realistic and definitely less reproducible: the browser mutates between runs.

puppeteer-extra-plugin-stealth has the opposite arrangement. Your puppeteer version and your Chrome version are one lockfile entry, and today that is 25.13.0 with Chrome 155.0.8059.39. What is unpinned is the patch set. The plugin's last release is 2.11.2, published 2023-03-01, and its evasions were written against a Chrome from that era.

You can see the drift in the plugin's own documentation. Its README usage example calls await page.waitForTimeout(5000). That method is absent from packages/puppeteer-core/src/api/Page.ts and from the published puppeteer.page.md API reference on main, both grepped today. Copy the plugin's headline example into a current project and it throws. The patch is three and a half years behind the browser it patches, and the first thing you hit is the quickstart.

Couplingpuppeteer-extra-stealthpuppeteer-real-browser
Browser version sourcepinned by `puppeteer` in your lockfile (25.13.0 to Chrome 155.0.8059.39)whatever the host's search order finds
CDP client versioncurrent, moves with `puppeteer``^23.3.1`, resolves to 23.10.3 from 2024-12-10
Patch set versionfrozen at 2.11.2, 2023-03-01no injected patch set to freeze
Component updatesdisabled by Puppeteer's defaultsre-enabled by removing the flag
What driftsthe evasions, against a newer browserthe protocol client, against a newer browser

Neither arrangement is safe unattended. The useful question is which drift your target notices first, and that depends on whether the site checks JavaScript surfaces or protocol behaviour. For how the maintained alternatives handle the same tension, our comparison of undetected-chromedriver, Patchright and Camoufox covers three projects that still ship releases.

The vectors that move when you change machines

Group the tells by whether they are a property of the machine or a property of the page. That reframing predicts which package wins on a given box.

VectorWhat it comes frompuppeteer-extra-stealthpuppeteer-real-browser
`navigator.webdriver`the `--enable-automation` switchswitch still passed, property deleted per page from injected scriptswitch never passed, property is false natively
Screen metrics and `outerWidth`the display server`window.outerdimensions` evasion computes plausible valuesreal values from a 1920x1080x24 Xvfb screen
Operating system identitythe host`maskLinux: true` rewrites the UA to `Windows NT 10.0` and reports `Win32`truthful, no override
Mouse event coordinatesCDP input synthesisnot addressed by the stealth bundle`MouseEvent.prototype.screenX` patched, `ghost-cursor` for paths
UA string "HeadlessChrome"the headless buildstring replaced with `Chrome/` over `Network.setUserAgentOverride`absent, because the build is not headless
CDP `Runtime.enable` tracesthe protocol clientunpatched Puppeteer`rebrowser-puppeteer-core`, per the project's own credits
WebGL vendor and rendererthe GPU stack on the box`webgl.vendor` evasion overrides the stringsinherits the host's real stack
TLS fingerprint and IP reputationthe networkneither touches theseneither touches these

Two rows deserve emphasis.

The OS row is the one that decides most real outcomes. A detector that cross-checks the claimed platform against anything else, the WebGL renderer string, the font list, the Client Hints platform, sees a contradiction on a default stealth setup running on Linux. puppeteer-real-browser has no contradiction to find, because it did not make a claim. If you run the stealth plugin on Linux servers and your targets are getting stricter, maskLinux: false is the single highest-value configuration change available to you, and almost nobody sets it.

The bottom row is where both projects stop. Neither rewrites your TLS handshake and neither changes your egress IP. Those are the two signals a server-side filter can act on before any JavaScript runs, which is why a well-configured plugin on a flagged datacentre range still gets a challenge page. Browser-level work and network-level work are not substitutes; the proxy setup in our Puppeteer proxy guide applies unchanged to both packages.

Liveness, read on 9 October 2026

Before the architecture argument matters, check whether either project is still being written. Both answers are uncomfortable, and one is explicit.

puppeteer-real-browser's README opens with a notice from the maintainer:

This repo will no longer receive updates. Thank you to everyone who supported it.

That line is the first thing in the file on both main and master. Supporting facts from the GitHub API and npm registry, all read today:

  • Repository: ZFC-Digital/puppeteer-real-browser, redirected from the old zfcsoftware path. 1,649 stars, 208 forks, 25 open issues, not archived, created 2023-12-22.
  • Last push: 2025-09-03T17:09:37Z. The last five commits are remove sponsor, chore: update capsolver image, refactor(docs): update sponsor logo in README, update sponsor image, docs: add sponsor section to README.md.
  • The last commits that touched dependencies or behaviour are dated 2025-02-01: chore(deps): upgrade puppeteer-real-browser from 1.3.22 to 1.4.0, fix: resolve Docker build failure due to missing dependencies, and refactor(deps): add xvfb dependency.
  • npm: 1.4.4 published 2025-09-03T17:10:14Z, 78 versions, license: ISC in package.json while GitHub's detected licence reads MIT. Not flagged deprecated.
  • Downloads: 56,909 in the week 2026-10-01 to 2026-10-07, per the npm downloads API.

So: no functional change in about 20 months, a farewell notice in the README, and still nearly 57,000 installs a week.

puppeteer-extra is not archived and not deprecated either, and it has not been committed to in longer:

  • Repository: berstend/puppeteer-extra. 7,406 stars, 781 forks, 275 open issues, created 2018-05-15.
  • Default branch master, last commit 2023-03-01T12:18:55Z, message Publish. The commit before it is fix(puppeteer-extra): Prefer puppeteer over puppeteer-core (#755).
  • npm: puppeteer-extra-plugin-stealth 2.11.2 published 2023-03-01T12:07:26Z; puppeteer-extra 3.3.6 published 2023-03-01T12:19:02Z. Neither deprecated.
  • Downloads for the week 2026-10-01 to 2026-10-07: 1,214,239 for the stealth plugin and 526,923 for puppeteer-extra.

Over a million weekly installs of a package whose last release predates two Puppeteer major-version lines. The 17 evasion directories under packages/puppeteer-extra-plugin-stealth/evasions (19 entries, two of which are the _template and _utils helpers) are the same 17 they were in March 2023.

The author's framing in the README is honest about the ceiling, and it is his claim rather than a measured result: "It's probably impossible to prevent all ways to detect headless chromium, but it should be possible to make it so difficult that it becomes cost-prohibitive." The README's checklist of passed tests, fpscanner, the intoli suite, areyouheadless, is likewise the project's own unverified claim from a changelog written years ago, not a current measurement.

puppeteer-real-browser's maintainer offers a redirection that is easy to miss, in his own Q&A section: "If using rebrowser solves your problem, I don't recommend using real browser." That is the author of the package telling you to consider the patch set it depends on instead of the wrapper. rebrowser-puppeteer-core pulled 87,112 downloads in the same week, more than the wrapper itself. For the other end of the same dilemma, a frozen tool with a live fork, see nodriver vs undetected-chromedriver.

Proxy wiring, and the sandbox you just switched off

Proxy support looks similar and is wired differently in one way that matters.

puppeteer-real-browser accepts a proxy object and splits it in two. The host and port become a launch flag; the credentials are answered at the page level. From lib/cjs/index.js and lib/cjs/module/pageController.js:

// launch side
...(proxy && proxy.host && proxy.port ? [`--proxy-server=${proxy.host}:${proxy.port}`] : []),

// page side
if (proxy.username && proxy.password)
  await page.authenticate({ username: proxy.username, password: proxy.password });

That is the correct split, because Chrome discards credentials embedded in --proxy-server. In practice:

const { connect } = require('puppeteer-real-browser');

const { browser, page } = await connect({
  headless: false,
  turnstile: true,
  proxy: {
    host: 'gateway.sparkproxy.io',
    port: '11000',
    username: process.env.SPARK_USER,
    password: process.env.SPARK_PASS,
  },
});

await page.goto('https://www.sparkproxy.io/', { waitUntil: 'domcontentloaded' });
console.log(await page.title());
await browser.close();

The stealth-plugin equivalent puts the same two halves in the same two places, you just write the launch flag yourself:

const puppeteer = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');

// Linux hosts: stop the plugin claiming Windows in the UA
puppeteer.use(StealthPlugin({ maskLinux: false }));

const browser = await puppeteer.launch({
  headless: true,
  args: ['--proxy-server=gateway.sparkproxy.io:11000'],
});
const page = await browser.newPage();
await page.authenticate({ username: process.env.SPARK_USER, password: process.env.SPARK_PASS });
await page.goto('https://www.sparkproxy.io/');
await browser.close();

Note that StealthPlugin() passes its options through to the individual evasions, so maskLinux: false reaches user-agent-override. On a Linux box that one keyword removes the platform contradiction described earlier. SparkProxy accepts either auth model, user and password as shown or an IP allowlist you set in the dashboard, over HTTP, HTTPS and SOCKS5, with SOCKS5 on gateway.sparkproxy.io:13000.

Now the part nobody mentions. Look again at the tail of puppeteer-real-browser's flag array:

chromeFlags = [
  ...flags,
  ...args,
  // ...
  "--no-sandbox",
  "--disable-dev-shm-usage",
];

Those two are appended unconditionally on every platform, not only in containers, unless you set ignoreAllFlags: true and rebuild the entire flag list yourself. Puppeteer does the opposite: --no-sandbox is added only when you pass it explicitly or set PUPPETEER_DANGEROUS_NO_SANDBOX=true, and the environment variable is named to make you think about it.

So installing puppeteer-real-browser and calling connect() runs a browser that renders untrusted, attacker-chosen web content with Chrome's sandbox disabled, on your laptop, by default. If the scraper runs as a user with anything worth reaching, that is a real exposure and not a theoretical one. The fix is to take the flags back:

const { connect } = require('puppeteer-real-browser');
const { Launcher } = require('chrome-launcher');

// Rebuild the flag list WITHOUT --no-sandbox, then hand it over whole.
const flags = Launcher.defaultFlags().filter((f) => !f.startsWith('--disable-component-update'));

const { browser, page } = await connect({
  headless: false,
  ignoreAllFlags: true,            // we supply every flag ourselves
  args: [...flags, '--disable-dev-shm-usage', '--disable-blink-features=AutomationControlled'],
});

That keeps the display and the real-install behaviour, drops the sandbox override, and uses the --disable-blink-features form of the automation switch. Run it in a container or under an unprivileged user with seccomp if you need --no-sandbox back for a kernel that will not allow user namespaces.

puppeteer-real-browser vs puppeteer-extra-stealth, by deployment target

Laptop, VPS, container, CI runner: the answer changes per row, and it changes because of the machine rather than the evasions.

Environmentpuppeteer-extra-stealthpuppeteer-real-browserWhat to do
macOS or Windows laptopworks headless, nothing to install beyond npmworks, opens a visible Chrome window, no Xvfb path on these platformseither; `puppeteer-real-browser` is good for eyeballing a flow, bad as a background job
Linux desktop with a sessionworksworks, uses Xvfb rather than your real display unless `disableXvfb: true`either; set `disableXvfb: true` when you want to watch it
Linux VPS, no GUIworks unchangedneeds `apt-get install xvfb` plus a Chrome or Chromium package, or it fails downstream of a swallowed warningthe stealth plugin unless you specifically need headful; otherwise install both and add a preflight check
Containerone `npm ci`, about 189 MiB of Chrome in the image, no displaytwo apt packages, an X server inside the container, and a browser version chosen by the base imagethe stealth plugin for portability; `puppeteer-real-browser` only with a pinned base image and `CHROME_PATH` set
GitHub-hosted `ubuntu-24.04`works, browser pinned by your lockfileworks with zero setup; Chrome 154 and `xvfb` are preinstalled`puppeteer-real-browser` is unusually convenient here, but assert the Chrome version in the job
Minimal self-hosted runnerworksthe common failure case; neither Chrome nor `xvfb` is presentthe stealth plugin, or install the preconditions and check them before `connect()`
Windows Server with no interactive sessionworksno Xvfb path exists on Windows, so Chrome needs a desktopthe stealth plugin

The short version: puppeteer-real-browser is a strong choice on a machine that already looks like a workstation, and a liability on a machine that does not. puppeteer-extra-plugin-stealth is indifferent to the machine and correspondingly more honest about being a patch, with one configuration change, maskLinux: false, standing between it and an obvious self-contradiction on Linux.

And one option that is not on the table: neither project is receiving updates. If you are starting something new rather than maintaining something that works, weigh both against a project that still ships releases before you commit.

The deployment that removes the question

If the reason you are comparing two unmaintained stealth wrappers is that your scraper keeps getting challenged, the display argument is downstream of the actual problem. Most blocks are decided before any JavaScript executes, on the TLS handshake and the IP's reputation. A browser running on a flagged datacentre range gets challenged whether or not it has a 1920x1080 framebuffer.

The SparkProxy Scraping API moves both the browser and the exit IP off your machine, which deletes the Xvfb question entirely. Base URL is https://scrape.sparkproxy.io/api/v1, authenticated with an X-API-Key header, and new accounts get 1,000 free credits. Two parameters carry most of the weight: render_js defaults to true and costs 5 credits, render_js=false is a plain fetch at 1 credit, and country_code adds 5 credits.

The envelope is the part people get wrong, so here it is correctly. json_response=true is the only thing that returns the metadata wrapper, and the body inside it is base64:

import base64
import os

import requests

API = "https://scrape.sparkproxy.io/api/v1/scrape"
HEADERS = {"X-API-Key": os.environ["SPARKPROXY_API_KEY"]}


def fetch(url, render=True, country=None):
    """Return (decoded_html, envelope) for one page."""
    params = {
        "url": url,
        "render_js": "true" if render else "false",
        "proxy_type": "premium",
        "json_response": "true",  # the ONLY source of the envelope
    }
    if country:
        params["country_code"] = country

    r = requests.get(API, headers=HEADERS, params=params, timeout=120)
    r.raise_for_status()
    env = r.json()

    # body is base64; decode BEFORE matching any marker
    html = base64.b64decode(env["body"]).decode("utf-8", errors="replace")
    return html, env


html, env = fetch("https://www.sparkproxy.io/", render=True, country="us")
print(env["status_code"], env["credits_used"], env["duration_ms"], len(html))
if "Starting from" in html:
    print("marker found")

Decode first, then match. Searching the base64 string for a marker is the bug that keeps resurfacing, because it fails silently: the marker is genuinely absent from the encoded text.

For jobs that do not need a browser at all, render_js=false turns the same call into a 1-credit fetch, and batch mode requires render_js=false and bills the whole batch as 1 credit. If the target really does need a browser, js_scenario costs 5 credits on top of render_js=true and takes up to 50 steps, which covers the click-and-wait sequences that are the usual reason people reach for a headful Chrome:

import json

scenario = {
    "steps": [
        {"wait_for": "#results"},
        {"click": "button.load-more"},
        {"wait": 1500},
        {"scroll_y": 2000},
    ]
}

params = {
    "url": "https://www.sparkproxy.io/",
    "render_js": "true",
    "js_scenario": json.dumps(scenario),
    "proxy_type": "premium",
    "json_response": "true",
}
# +5 credits for js_scenario, +5 for render_js, max 50 steps
print(json.dumps(params, indent=2)[:120])

If you would rather keep the browser and fix the network layer, SparkProxy's residential pool is 1M+ premium IPs across 80+ countries with random rotation and 5-minute auto-rotation, HTTP, HTTPS and SOCKS5, IP allowlist or user and password auth, and 1 Gbps port speed. Plans start from $75/month (USD, as listed on 9 October 2026) and are sold as packs with unlimited bandwidth included, so the billing unit is the plan and its concurrency rather than traffic consumed. Term discounts are 5% at 3 months, 10% at 6 and 15% at 12, and there is a 24-hour free trial with no card required.

Frequently asked questions

FAQ

Not reliably. connect() starts Xvfb whenever process.platform === "linux" and disableXvfb is false, and if the package is missing it catches the error, prints an install hint and keeps going. Chrome then launches headful with no DISPLAY and the job fails later inside chrome-launcher, which makes the real cause hard to spot.

No. The last npm release is 2.11.2, published 2023-03-01, and the last commit on berstend/puppeteer-extra's master branch is dated 2023-03-01T12:18:55Z, both read on 9 October 2026. It is neither archived on GitHub nor deprecated on npm, and it still pulled 1,214,239 downloads in the week of 2026-10-01.

Yes. connect() takes a plugins array, calls addExtra() from puppeteer-extra and registers each plugin, so the two projects share a plugin bus rather than competing for it. The README warns that some plugins, naming puppeteer-extra-plugin-anonymize-ua, can make you easier to detect.

The stealth plugin for containers, because npm ci brings its own Chrome and nothing needs a display. puppeteer-real-browser is unusually easy on GitHub-hosted ubuntu-24.04, where Chrome 154.0.8037.57 and xvfb are already in the runner image, but it fails on minimal self-hosted runners that have neither.

Because user-agent-override ships maskLinux: true by default, rewrites the first parenthesised part of the user agent to (Windows NT 10.0; Win64; x64) and then reports Win32 for navigator.platform. Anything that cross-checks the platform against the WebGL renderer or the font list sees the contradiction. Pass StealthPlugin({ maskLinux: false }) on Linux hosts.

Yes, by default. lib/cjs/index.js appends --no-sandbox and --disable-dev-shm-usage to every launch unless you set ignoreAllFlags: true and supply the full flag list yourself. Puppeteer adds --no-sandbox only when you ask for it or set PUPPETEER_DANGEROUS_NO_SANDBOX=true.

Special Discount ยท 20% off

Get 20% off your first month

Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.

Save up to 15% more on quarterly, half-yearly and yearly plans

Claim Discount

About the Author

The SparkProxy Technical Team builds and operates SparkProxy's datacenter proxies, residential proxies and the SparkProxy Scraping API. We run browser automation against hostile targets every day, which means we spend more time on Dockerfiles, display servers and Chrome flags than on fingerprint demos. Every version number, commit date, download count and flag list in this article was read from the projects' own repositories, npm metadata and GitHub API responses on 9 October 2026, with file paths quoted so you can verify them yourself. Where a figure is a project's own claim rather than something we measured, we say so.

Keep reading

Related articles

Insomniac Browser Alternatives, Scored by Layer

Insomniac Browser alternatives scored by which identity layer each one really covers, with prices verified in October 2026 and a measured three-profile test.

SparkProxyยทComparisons