rebrowser-patches vs Patchright: Which Survives Upgrades
rebrowser-patches vs Patchright compared on one axis that decides everything: what happens to your patched Playwright on the next upstream release.
rebrowser-patches vs Patchright is not a contest between two stealth scores. One is a bag of unified diffs you run against a library you already installed; the other is a prebuilt distribution with its own version number. That single architectural choice decides what happens to your scraper on the Monday after Playwright ships a release, and that is the only question a maintainer actually has to answer. Everything below is dated, sourced from each project's own repository, and read on 9 October 2026.
If you want an effectiveness ranking across stealth stacks instead, we already published one: undetected-chromedriver vs Patchright vs Camoufox. This post is about the upgrade, the lockfile, and the CI step.
What you actually install
Install rebrowser-patches and you get 113 KB of text and two scripts. The npm package declares exactly two shipped paths in its files array, /patches and /scripts, and the repository root holds nothing else of substance: a README, a package.json, and those two directories. Four .patch files live under patches/, split by target library and by code target:
patches/playwright-core/lib.patch 11,640 bytes
patches/playwright-core/src.patch 13,242 bytes
patches/puppeteer-core/lib.patch 69,693 bytes
patches/puppeteer-core/src.patch 18,567 bytes
Nothing in that package is a browser automation library. It is a patcher, scripts/patcher.js is its entire runtime, and you point it at an installed copy of puppeteer-core or playwright-core so it can rewrite that copy in place.
Install patchright and you get a library. pip install patchright puts a Playwright API on your import path, patchright install chromium fetches the browser, and the thing you imported is a Playwright driver that was already patched before it was published. The version you pin, 1.63.0 at the time of writing, is both Patchright's version and the Playwright version it was built from.
| rebrowser-patches | Patchright | |
|---|---|---|
| What ships | 4 unified diffs + a patcher script | a prebuilt patched Playwright driver |
| Applied | on your machine, after install | at the project's release time |
| Targets | `puppeteer-core` and `playwright-core` | Playwright only |
| Version number means | the patcher's own version (1.0.19) | the upstream Playwright it was built from (1.63.0) |
| Reversible | yes, `unpatch` runs the diff backwards | no, you uninstall and swap the import |
| Survives `npm install` | no, the README says so | yes, it is a normal dependency |
| Toggle at runtime | yes, via `REBROWSER_PATCHES_*` env vars | no, the patch is compiled in |
| Also covers Puppeteer | yes | no |
The reversibility row is the honest argument for the diff approach, and it is not a small one. npx rebrowser-patches@latest unpatch --packageName playwright-core gives you back a stock library on the same machine, in the same node_modules, with the same lockfile. The env-var row is the other one: you can set REBROWSER_PATCHES_RUNTIME_FIX_MODE=0 and run the same binary unpatched for a test, which is genuinely useful when you are bisecting a behaviour change. Patchright has no equivalent, because by the time you import it the patch is already part of the build.
rebrowser-patches: four diffs and GNU patch
The mechanics matter here, because they are the reason the upgrade story ends the way it does. scripts/utils/index.js builds one command and reuses it for everything:
patch --batch -p1 --input=<patchfile> --verbose --no-backup-if-mismatch --reject-file=- --forward --silent
That is GNU patch, not a JavaScript diff library. Four flags on that line are load-bearing:
--reject-file=-sends rejected hunks to stdout instead of writing a.rejfile next to the source. You do not get an artefact on disk showing which hunk failed.--forwardtellspatchto ignore hunks that look already applied or reversed rather than prompting.--batchand--silentsuppress interaction and most output, which is correct for a CLI and unhelpful for a diagnosis.- No
--fuzzis passed, so GNUpatchuses its default maximum fuzz factor of 2. Hunks can apply at an offset with up to two lines of context mismatch.
patcher.js runs that command with --dry-run first to classify the current state, and the classification is a string match on the output:
// scripts/patcher.js, abridged
try {
await exec(`${getPatchBaseCmd(patchFilePath)} --dry-run`, { cwd: packagePath })
patchStatus = 'unpatched'
} catch (e) {
if (e.stdout.includes('No file to patch')) {
fatalError('Internal error, patch command cannot find file to patch')
} else if (e.stdout.includes('Ignoring previously applied (or reversed) patch')) {
patchStatus = 'patched'
} else if (e.stderr.includes('is not recognized')) {
fatalError('patch command not found!')
} else {
log('[debug] patch error:', e)
throw e
}
}
Read the fourth branch. If the dry run fails for any reason other than a missing file, an already-applied patch, or a missing patch binary, the error is logged only under REBROWSER_PATCHES_DEBUG=1 and then rethrown raw. The case that lands there is the interesting one: the target file exists, the patch is not applied, and the hunks no longer match because the library moved. The useful detail sits behind a debug flag you have not set, so set REBROWSER_PATCHES_DEBUG=1 first whenever a patch run misbehaves.
There is also a guard on which packages are accepted. validPackagesNames is a two-element array, ['puppeteer-core', 'playwright-core'], and patcher.js compares packageJson.name against it. What it does not compare is packageJson.version. Nothing in the patcher asks whether the installed version is one the diffs were built for. The README states the tested targets in prose, Puppeteer 24.8.1 and Playwright 1.52.0, and the code trusts you to have read it.
Three commands exist: patch, unpatch, and check. Run check in CI if you adopt this tool; it is the cheapest assertion available.
# apply, against the installed library in ./node_modules
npx rebrowser-patches@latest patch --packageName playwright-core
# confirm state without changing anything
npx rebrowser-patches@latest check --packageName playwright-core
# roll back
npx rebrowser-patches@latest unpatch --packageName playwright-core
For Python, Java, or .NET Playwright the README's instruction is to find the bundled Node driver and patch that instead, with --packagePath=$yourDriverFolder/$yourPlatform/package. That works, and it also means your Python dependency tree now contains a mutated vendored Node package that pip has no record of changing.
On Windows the patch binary does not exist by default. The README's fix is to install Git for Windows and add C:\Program Files\Git\usr\bin to PATH, because that is where patch.exe lives. On a Windows CI runner this is a real step, not a footnote.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
Patchright: 34 modules and an hourly release bot
Patchright does not ship diffs to you at all. The Kaliiiiiiiiii-Vinyzu/patchright repository holds driver_patches/, a directory of 34 TypeScript modules plus an index.ts and a utils.ts helper. They are source transforms, not unified diffs, and they are applied to the Playwright driver during the release build. The largest ones show where the work is:
driver_patches/framesPatch.ts 57,587 bytes
driver_patches/crNetworkManagerPatch.ts 24,978 bytes
driver_patches/crPagePatch.ts 22,963 bytes
driver_patches/frameSelectorsPatch.ts 18,631 bytes
driver_patches/utilityScriptSerializersPatch.ts 14,319 bytes
Because the transforms are written against named symbols rather than line context, a cosmetic reformat upstream does not break them the way it breaks a context diff. That is the structural advantage, and the project spent its tooling budget on making the breakages it does have visible.
Six GitHub Actions workflows sit in .github/workflows/: patchright_release.yml, patchright_tests.yml, check_patch_impact.yml, patch_file_updater.yml, build_test_package_on_pr.yml, and post_build_artifacts_comment.yml. Two of them define the upgrade behaviour.
patchright_release.yml runs on a schedule, cron: '0 ', so every hour. Its first job queries api.github.com/repos/microsoft/playwright/releases/latest, compares that tag to Patchright's own latest release, and proceeds only if upstream has moved. Nobody has to notice that Playwright shipped.
check_patch_impact.yml takes an old_version and a new_version, requests issues: write, and has a create_issue input. Paired with utils/extract_patched_symbols.ts, it answers a precise question: of the symbols Patchright patches, which ones changed between two Playwright releases, and how. Not "did the diff apply", but "did the function we rewrote get a new body or a new signature".
The repository also carries its own tests alongside Playwright's suite: utils/custom_tests/execution-context.spec.ts, closed-shadow-root.spec.ts, init-script-binding.spec.ts, and utils/detection_smoke_test.mjs. The README states that "Patchright is now tested against the Playwright Tests after every release", then warns it passes most but not all of them, with a standing issue (#30, opened 8 December 2024) listing known bugs and which the maintainers consider unfixable.
Installation is a normal package install plus a browser download:
pip install patchright
patchright install chromium
# or, as the README recommends, real Chrome:
patchright install chrome
The README's own "Best Practice" section is worth copying verbatim rather than paraphrasing, because the configuration is the point: launch a persistent context, use channel="chrome", run headful, skip the viewport override, and add no custom user agent or headers.
# patchright, with SparkProxy residential egress and a persistent profile
from patchright.sync_api import sync_playwright
PROXY = {
"server": "http://gateway.sparkproxy.io:11000",
"username": "YOUR_SPARKPROXY_USER",
"password": "YOUR_SPARKPROXY_PASS",
}
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
user_data_dir="./profiles/worker-01",
channel="chrome",
headless=False,
no_viewport=True,
proxy=PROXY,
locale="en-US",
timezone_id="America/Chicago",
)
page = ctx.new_page()
page.goto("https://www.sparkproxy.io/", wait_until="domcontentloaded")
print(page.title())
ctx.close()
The import name is the only change from a Playwright script. That is the drop-in claim, and in our reading of the API surface it holds for the common path.
Node and .NET are packaged separately: Kaliiiiiiiiii-Vinyzu/patchright-nodejs is tracked as a git submodule of the driver repo, and the .NET package is community-maintained at DevEnterpriseSoftware/patchright-dotnet. Java is still an open TODO in the README, tracked as issue #89 since 4 June 2025.
The leaks each project names in its own docs
Both projects start from the same primary defect, and both describe it in their own words. Neither description is ours.
rebrowser-patches: "Popular automation libraries rely on the CDP command Runtime.Enable [...] However, there's a technique that detects the usage of this command". Its fix "disables the automatic Runtime.Enable command on every frame" and then recovers a context ID through one of three routes, selectable at runtime:
addBinding(the default): create a binding in the main world, call it, keep the context ID it reveals. The README lists its cons as "None are discovered so far" and notes it works with web workers and iframes.alwaysIsolated: create an isolated world viaPage.createIsolatedWorld. You lose access to main-world variables, and workers do not allow new worlds.enableDisable: callRuntime.Enableand immediatelyRuntime.Disableto catch theRuntime.executionContextCreatedevents. The README concedes "a slight chance that during this short timeframe, the page will call code that leads to the leak".
That third mode is a documented, bounded risk the user chooses. The Playwright side of the patch set is narrower than the Puppeteer side: the README lists only addBinding and alwaysIsolated modes for Playwright, plus the utility-world rename.
Patchright: "To avoid detection by this leak, patchright avoids using Runtime.enable by executing Javascript in (isolated) ExecutionContexts." Same leak, one strategy, no switch. It then names four more:
- Console.enable. Patchright "patches this leak by disabling the Console API all together", and says plainly that "console functionality will not work in Patchright". That is a feature removal, and if your scraper reads
consolemessages to detect page state, it is a migration blocker. - Command flag leaks. The default argument list is edited:
--disable-blink-features=AutomationControlledadded, and--enable-automation,--disable-popup-blocking,--disable-component-update,--disable-default-appsand--disable-extensionsall removed. The reason given for dropping--disable-component-updateis "to avoid detection as a Stealth Driver", which is the more interesting kind of tell: not that you are automated, but that you are hiding. - Closed shadow roots. Normal locators reach into closed shadow roots, and XPath works there too. This is a capability, not an evasion, and it has no counterpart in rebrowser-patches.
- Init script delivery. To inject scripts without
Runtime.enable, Patchright routes them through Playwright Routes into HTML responses. The README admits those init scripts "can be detected by Timing Attacks" and gives its own assessment that no anti-bot currently checks for it. Treat that as the maintainers' judgement, not a measurement.
rebrowser-patches adds two cosmetic patches Patchright does not advertise. It rewrites Puppeteer's //# sourceURL=pptr:... marker to app.js, configurable via REBROWSER_PATCHES_SOURCE_URL, and it renames the default utility world from '__puppeteer_utility_world__' + packageVersion to util, configurable via REBROWSER_PATCHES_UTILITY_WORLD_NAME. Its own note on the latter is candid: not detectable by page scripts, "but Google might use this information in their proprietary Chrome; we never know."
| Leak or behaviour | rebrowser-patches | Patchright |
|---|---|---|
| `Runtime.enable` | fixed, 3 selectable modes | fixed, isolated contexts only |
| Main-world access retained | yes with `addBinding` or `enableDisable` | isolated by design |
| `Console.enable` | not addressed | fixed by disabling the Console API |
| Default command flags | not addressed | 1 added, 5 removed |
| `sourceURL` marker | rewritten to `app.js` | not advertised |
| Utility world name | renamed to `util`, configurable | not advertised |
| Closed shadow roots | not addressed | supported, XPath included |
| Firefox and WebKit | Chrome only | "Firefox and Webkit are not supported" |
For what the detection side of this actually tests, see headless browser detection; for how both compare against the older puppeteer-extra evasion stack, see stealth plugins for Puppeteer and Playwright.
Patchright's README lists fourteen detection suites and commercial anti-bot vendors it says it passes, Cloudflare, Kasada, Akamai, Shape/F5, DataDome, Fingerprint.com, CreepJS and others among them, with Brotector conditional on also using CDP-Patches. We have not reproduced that list, and neither project publishes a reproducible benchmark with dates and sample sizes. Read both claim sets as vendor claims.
Release and commit dates, read on 9 October 2026
Everything in this table was pulled from the GitHub and registry APIs on 9 October 2026. The dates, not the stars, are the finding.
| Fact | rebrowser-patches | Patchright |
|---|---|---|
| Repository created | 2024-08-06 | 2023-11-07 |
| Stars | 1,439 | 4,804 |
| Forks | 83 | 235 |
| Open issues | 37 | 5 |
| Latest release tag | 1.0.19, 2025-05-09 | v1.63.0, 2026-09-08 |
| Last push to `main` | 2025-05-09 | 2026-10-07 |
| Newest commit subject | "v1.0.19 release: playwright patch fixes to work with playwright v1.52.0" | "Add ProxyLane Sponsor" |
| Newest functional commit | 2025-05-09 | 2026-10-01, "Fix request listener leak and crash retry loop (#247)" |
| Archived | no | no |
Neither repository is archived, and neither maintainer has declared anything abandoned. What is measurable is the gap. The last commit to rebrowser-patches main lands on 9 May 2025. That is seventeen months before this post. In that window, Playwright shipped through to 1.64.0 and Puppeteer through to 25.13.0.
| Package | Version | Published | Source |
|---|---|---|---|
| npm `rebrowser-patches` | 1.0.19 | 2025-05-09 | registry.npmjs.org |
| npm `rebrowser-playwright` | 1.52.0 | 2025-05-09 | registry.npmjs.org |
| npm `rebrowser-puppeteer-core` | 24.8.1 | 2025-05-09 | registry.npmjs.org |
| PyPI `rebrowser-playwright` | 1.52.0 | 2025-05-09 | pypi.org (6 releases total) |
| npm `patchright` | 1.63.0 | 2026-09-08 | registry.npmjs.org |
| PyPI `patchright` | 1.63.0 | 2026-09-20 | pypi.org (31 releases total) |
| npm `playwright-core` | 1.64.0 | 2026-10-07 | registry.npmjs.org |
| PyPI `playwright` | 1.63.0 | 2026-09-15 | pypi.org |
| npm `puppeteer-core` | 25.13.0 | 2026-10-08 | registry.npmjs.org |
The README's declared targets are Playwright 1.52.0 and Puppeteer 24.8.1. Against the npm registry's current playwright-core, 1.52.0 is twelve minor versions back. Against puppeteer-core, 24.8.1 is a major version plus thirteen minors back. Someone asked about exactly this in issue #128 on 8 September 2026, titled "Puppeteer version 25.x", with the body "Will you release the patch for this new version ?". It has zero comments and is still open.
Patchright's own lag is measurable too, and much shorter. Playwright v1.63.0 was tagged on 4 September 2026; patchright 1.63.0 hit npm on 8 September, four days later. On PyPI, upstream playwright 1.63.0 landed 15 September and patchright 1.63.0 landed 20 September, five days later. One more pattern is visible in the registry: patchright has published 1.60.1, 1.61.2, 1.62.2 and 1.62.3, and npm's playwright-core has no such versions. The third component is Patchright's own counter for fixes against the same upstream minor, which means the version string tells you the Playwright base but not the patch generation. Pin all three components.
Six Playwright releases for context, from the microsoft/playwright release feed: v1.64.0 on 2026-10-07, v1.63.0 on 2026-09-04, v1.62.1 on 2026-07-30, v1.62.0 on 2026-07-24, v1.61.1 on 2026-06-23, v1.61.0 on 2026-06-15. That is roughly a release every three to five weeks, and it is the clock both projects are running against.
The upgrade event, step by step on both sides
Playwright 1.64.0 was published to npm at 20:44 UTC on 7 October 2026. Here is what happened next on each side, which is as close to a controlled experiment as this comparison gets.
Patchright. The hourly patchright_release.yml cron fired, saw a new upstream tag, and started a build. The build ran Playwright's test suite against the patched driver and the tests failed. At 21:10 UTC the same day, 26 minutes after the upstream publish, the pipeline opened issue #249, titled "Release Failed: v1.64.0 - Tests Failed", labelled upstream-break and release-failed. The body links the failing workflow run, the exact commit, and the two failing steps, then prints a generated table of patched APIs that changed between v1.63.0 and v1.64.0. An excerpt:
| Symbol | Kind | Change type | Playwright file | Patch file |
|---|---|---|---|---|
| `UtilityScript` | class | body_changed | `packages/injected/src/utilityScript.ts` | `utilityScriptPatch.ts` |
| `BrowserContext` | class | body_changed | `packages/playwright-core/src/server/browserContext.ts` | `browserContextPatch.ts` |
| `CRPage` | class | body_changed | `packages/playwright-core/src/server/chromium/crPage.ts` | `crPagePatch.ts` |
| `_onAttachedToTarget` | method | signature_changed | `packages/playwright-core/src/server/chromium/crPage.ts` | `crPagePatch.ts` |
| `_onFrameNavigated` | method | body_changed | `packages/playwright-core/src/server/chromium/crPage.ts` | `crPagePatch.ts` |
| `Frame` | class | body_changed | `packages/playwright-core/src/server/frames.ts` | `framesPatch.ts` |
That is the thing worth paying for, and it is free. Patchright is currently one minor version behind upstream, the reason is a signature_changed on _onAttachedToTarget and a dozen changed method bodies, and you can read it in a public issue without installing anything. The README is honest about the consequence: "Deployment of new Patchright versions are automatic, but bugs due to Playwright codebase changes may occur. Fixes for these bugs might take a few days to be released."
So the Patchright failure mode is a delay with a receipt. You stay on 1.63.0 for a few days, and you know why.
rebrowser-patches. Nothing fired, because there is no automation and no release since May 2025. If you bump playwright-core to 1.64.0 and run the patcher, one of three things happens, and the difference between them is not obvious from the console output:
- Every hunk fails. The dry run errors, the message does not match any of the three recognised strings, and
patcher.jsrethrows the raw exec error with the detail suppressed unlessREBROWSER_PATCHES_DEBUG=1is set. Loud enough, confusing enough. - Some hunks fail. GNU
patchexits non-zero, the patcher reports an error, and because--reject-file=-discards the rejects you have no.rejartefact to inspect. Your library is now partially patched. Partially patched is worse than unpatched, because theRuntime.enablesuppression and the context-ID recovery it depends on are different hunks in the same file. - Every hunk applies within the default fuzz of 2, at an offset, into code that has changed around it. The patcher prints success. Whether the patched code still does what it did in 1.52.0 is not asserted by anything, because the project ships no test suite.
Case three is the one to be afraid of. It is the only one of the three that gives you a green exit code and a silently wrong build.
There is independent evidence that behavioural drift is already real on a supported version. Issue #129, opened 17 September 2026, reports that page.waitForFunction promises get "silently lost" across a navigation under rebrowser where stock Puppeteer resolves them, with a reproducible script and the actual timeout output. Whether that is a patch bug or an interaction, it is the class of regression a test suite catches and prose does not.
#!/usr/bin/env python3
"""Fail the build when the installed Playwright has moved past the patch target.
Run before the patcher, not after."""
import json
import pathlib
import subprocess
import sys
PATCH_TARGET = "1.52.0" # rebrowser-patches README, read 2026-10-09
PKG = pathlib.Path("node_modules/playwright-core/package.json")
def installed_version() -> str:
return json.loads(PKG.read_text(encoding="utf-8"))["version"]
def main() -> int:
got = installed_version()
if got != PATCH_TARGET:
print(f"REFUSING TO PATCH: playwright-core {got} != tested {PATCH_TARGET}")
print("Re-read the rebrowser-patches README and re-verify the diffs first.")
return 1
check = subprocess.run(
["npx", "rebrowser-patches@latest", "check", "--packageName", "playwright-core"],
capture_output=True,
text=True,
)
print(check.stdout.strip() or check.stderr.strip())
return check.returncode
if __name__ == "__main__":
sys.exit(main())
The patcher will not do that check for you. It validates packageJson.name against a two-element allowlist and never looks at packageJson.version.
Pinning, lockfiles, and the install that undoes your patch
Patchright pins like any other dependency, with one wrinkle. Because the patch component is Patchright's own, a caret range quietly accepts a different patch generation against the same Playwright base:
{
"dependencies": {
"patchright": "1.63.0"
}
}
Write the exact version. ^1.63.0 would have accepted 1.62.2 to 1.62.3 style bumps in the previous line of releases, which carry patch changes you have not reviewed. For Python, patchright==1.63.0 in requirements.txt and a hash-pinned lockfile. Then pin the browser as well: patchright install chrome tracks whatever Chrome the channel resolves to, and a Chrome major bump changes your fingerprint surface independently of any patch.
rebrowser-patches is where lockfile discipline stops helping, because the artefact it modifies is not the one the lockfile describes. The README says it outright: "Be aware that after running npm install or yarn install in your project folder, it might override all the changes from the patches. You'll need to run the patcher again to keep the patches in place."
Read that as an invariant you now own. Your lockfile hash is still correct. npm ci will reproduce it faithfully. And the reproduced tree is unpatched, because the patch was never in the lockfile. Every one of these undoes it:
npm install, which may rewrite the treenpm ci, which deletesnode_modulesoutright- a Dependabot bump merged by someone who never heard of the patcher
- a fresh container build from a cached base layer
yarn install --check-files
The project's documented remedy for updating is itself a full rebuild: delete node_modules/puppeteer-core, reinstall, then patch again.
If you take the diff route, the only safe place for the patcher is a postinstall hook, so that the thing which undoes the patch is also the thing which reapplies it:
{
"scripts": {
"postinstall": "rebrowser-patches patch --packageName playwright-core && rebrowser-patches check --packageName playwright-core"
},
"devDependencies": {
"rebrowser-patches": "1.0.19"
},
"dependencies": {
"playwright-core": "1.52.0"
}
}
Note the exact playwright-core pin. A range here is a liability: the patch was built for one version and nothing in the toolchain will stop a minor bump from reaching the patcher. Note also the local binary rather than npx rebrowser-patches@latest; @latest in a postinstall hook means your build output changes when a third party publishes, which is the opposite of what a lockfile is for.
The drop-in packages sidestep most of this, and they are the right choice if you go this way at all. rebrowser-playwright and rebrowser-puppeteer-core are the patched sources published as normal packages, so the lockfile does describe the patched artefact. The alias trick in the README keeps your imports unchanged:
{
"dependencies": {
"playwright-core": "npm:rebrowser-playwright-core@1.52.0"
}
}
The cost is that you are now pinned to whatever upstream version the drop-in was cut from, and there have been six rebrowser-playwright releases ever, the last on 9 May 2025.
Patching inside CI and a Docker image
In a container, the ordering problem gets sharper, because Docker layer caching will happily serve you an unpatched node_modules forever.
Patchright needs no special handling. It is a dependency and a browser download:
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates fonts-liberation && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN patchright install --with-deps chrome
COPY . .
CMD ["python", "-I", "worker.py"]
The diff route needs three extra things: the patch binary present in the image, the patcher run in the same layer as the install, and a verification step that fails the build rather than warning:
FROM node:22-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends patch \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json ./
# install and patch in ONE layer, then assert. A separate RUN for the patcher
# can be cache-hit while the install layer below it was rebuilt.
RUN npm ci \
&& npx rebrowser-patches@1.0.19 patch --packageName playwright-core \
&& npx rebrowser-patches@1.0.19 check --packageName playwright-core
COPY . .
CMD ["node", "worker.js"]
Three details there earn their place: apt-get install patch, because slim Debian images do not ship it, which is the Linux version of the Windows patch.exe problem; the pinned @1.0.19 instead of @latest, which keeps the image reproducible; and the trailing check, which is the only assertion available, so it belongs in the same && chain where a failure stops the build.
For a Windows runner, add the PATH edit the README specifies:
$env:PATH += ';C:\Program Files\Git\usr\bin'
npx rebrowser-patches@1.0.19 patch --packageName playwright-core
The CI check that actually matters is different from either of these, and neither project provides it. Ask once per build whether upstream has moved past the version your stealth layer was built for, and fail loudly if it has:
#!/usr/bin/env python3
"""Upstream drift monitor. Exits 1 when the pinned stealth base is behind npm."""
import json
import sys
import urllib.request
PINNED = {
"playwright-core": "1.52.0", # rebrowser-patches tested target
"patchright": "1.63.0", # our pinned Patchright
}
REGISTRY = "https://registry.npmjs.org/{}"
def latest(pkg: str) -> str:
req = urllib.request.Request(
REGISTRY.format(pkg), headers={"Accept": "application/json"}
)
with urllib.request.urlopen(req, timeout=20) as r:
return json.load(r)["dist-tags"]["latest"]
def tup(v: str):
return tuple(int(x) for x in v.split(".")[:3])
def main() -> int:
drift = 0
for pkg, pinned in PINNED.items():
up = latest(pkg)
behind = tup(up) > tup(pinned)
print(f"{pkg:18} pinned={pinned:10} latest={up:10} {'BEHIND' if behind else 'current'}")
drift += behind
if drift:
print(f"\n{drift} pinned package(s) behind upstream. Re-verify before shipping.")
return 1 if drift else 0
if __name__ == "__main__":
sys.exit(main())
Run it weekly on a schedule, not on every push. The point is not to upgrade automatically; it is to stop discovering a seventeen-month gap by reading a blog post.
Reading the diff: auditability and licence
You are about to run third-party code that rewrites a browser automation library to hide itself from websites. Reviewing what it changes is reasonable, and the two projects make it very different kinds of work.
rebrowser-patches gives you four unified diffs, 113 KB of text, and that is the whole surface. You can read patches/playwright-core/src.patch end to end in an afternoon and know exactly what lands in your tree. The lib.patch and src.patch split corresponds to the --codeTarget option, patching the compiled output or the TypeScript sources. For an audit, src.patch is the readable one and puppeteer-core/lib.patch at 69,693 bytes is mostly mechanical.
Patchright gives you 34 TypeScript modules describing transforms, a generated patchright.patch in the repository root kept current by patch_file_updater.yml, and utils/extract_patched_symbols.ts which enumerates the symbols being touched. That is more total material, and it is organised per concern, so "what does this do to the network manager" has a file-shaped answer in crNetworkManagerPatch.ts. The README credits the idea of documenting the patches as a .patch file to rebrowser's Nick Webson, which is a nice detail: the two projects read each other.
Licensing is the one place where the comparison is simply lopsided. Patchright carries a LICENSE file at the repository root and GitHub reports Apache-2.0, an explicit grant with a patent clause. The rebrowser-patches repository root contains .gitignore, README.md, package.json, patches/ and scripts/, and no LICENSE file; GitHub's licence detection returns null. The npm package.json declares "license": "MIT". A declared SPDX string in package metadata without a licence text in the repository is a thinner artefact than a LICENSE file, and if your organisation runs a licence scanner it will flag the difference. Ask your own counsel, not a blog.
Both projects publish a disclaimer saying the code is for educational purposes and assumes no liability, and both have a commercial interest adjacent to the tool: rebrowser-patches states in its README that it "is sponsored and maintained by Rebrowser", a cloud browser product, and Patchright's README carries paid proxy-vendor sponsor blocks. Neither fact makes the code worse. Both are worth knowing when you read a stealth claim in the same document.
What breaks quietly on each side
Documented in the projects' own words, and worth checking against your codebase before you commit either way.
rebrowser-patches, quiet failures:
npm installreverts the patch and nothing tells you. The README names it.page.pause()does not work with the fix enabled. The README says it "needs more investigation" and the workaround isREBROWSER_PATCHES_RUNTIME_FIX_MODE=0while debugging.alwaysIsolatedmode cannot see main-world variables, and web workers do not permit new isolated worlds, so worker-scoped evaluation fails in that mode.enableDisablemode has a documented window in which the leak can fire.- Playwright coverage is Chrome only. The README says WebKit and Firefox would need an issue opened. Someone did open one, #126 on 4 December 2025, asking how to skip the patches for those browsers.
- Promise-across-navigation drift, as reported in open issue #129 (17 September 2026).
Patchright, quiet failures:
- The Console API is disabled.
page.on("console")and anything downstream of it stops producing data, and the README's suggested replacement is JavaScript loggers. - Init scripts arrive via Playwright Routes, so route-based interception in your own code shares a mechanism with the stealth layer. The README says Patchright's init scripts "won't cause any bugs that wouldn't be caused by normal Playwright Routes", which is a real but narrow guarantee.
- Init-script delivery is detectable by timing attacks per the project's own assessment, with the caveat that no anti-bot is believed to check.
- Firefox and WebKit are explicitly unsupported.
- It fails some Playwright tests, enumerated in issue #30, and the maintainers consider a subset unfixable.
- The upstream break delay is real: as of today the current release targets Playwright 1.63.0 while upstream is at 1.64.0, for the reasons in issue #249.
- Chrome on arm64 is an open feature request, issue #211 since 31 May 2026, which matters if you build on Apple silicon or Graviton.
Those two lists are not symmetric in kind. Patchright's quiet failures are capability removals you can grep for in ten minutes: console, page.route, firefox, webkit. rebrowser's are state-dependent, because the same source tree behaves differently depending on whether anyone ran npm install since the last patcher run. One is a compatibility matrix, the other is a race with your own tooling.
Where the IP layer does more than either patch
Both READMEs say this, and both say it against their own commercial interest. rebrowser-patches: "this fix alone won't make your browser bulletproof and undetectable. You need to address many other aspects such as proxies, proper user-agent and fingerprints". Patchright's recommended configuration is a persistent profile on real Chrome with a geo-matched sticky IP, which is a statement about network identity, not about CDP.
A patched browser on a flagged datacentre IP is a patched browser on a flagged datacentre IP. If the same /24 has been scraping a target all week, no execution-context trick changes the verdict. SparkProxy's residential pool exists for that layer: 1M+ premium IPs across 80+ countries, random rotation or 5-minute auto-rotation, HTTP/HTTPS/SOCKS5, IP whitelist or user:pass auth, 1 Gbps port speed, plans starting from $75/month as packs with unlimited bandwidth rather than per-GB metering, so the billing unit is concurrency and not traffic consumed. Term discounts run 5% at three months, 10% at six, 15% at twelve, and there is a 24-hour free trial with no card.
There is also the case for not maintaining a patched browser at all. If your job is "fetch rendered HTML from a protected page", the patch-maintenance work described in this entire post is overhead you are choosing. The SparkProxy Scraping API takes the rendering and the stealth upkeep off your plate at https://scrape.sparkproxy.io/api/v1 with an X-API-Key header, and the first 1,000 credits are free.
#!/usr/bin/env python3
"""SparkProxy Scraping API: rendered page + the metadata envelope.
json_response=true is the ONLY source of the envelope, and body is base64."""
import base64
import json
import urllib.parse
import urllib.request
API = "https://scrape.sparkproxy.io/api/v1"
KEY = "YOUR_SPARKPROXY_API_KEY"
def fetch(url: str, country: str = "us") -> dict:
params = {
"url": url,
"render_js": "true", # default true, 5 credits
"proxy_type": "premium", # file-based premium pool
"country_code": country, # +5 credits
"json_response": "true", # envelope: job_id/status_code/duration_ms/credits_used/body
}
req = urllib.request.Request(
f"{API}?{urllib.parse.urlencode(params)}",
headers={"X-API-Key": KEY},
)
with urllib.request.urlopen(req, timeout=120) as r:
return json.load(r)
def main() -> None:
env = fetch("https://quotes.toscrape.com/js/")
html = base64.b64decode(env["body"]).decode("utf-8", "replace")
print(f"status={env['status_code']} ms={env['duration_ms']} credits={env['credits_used']}")
print(f"marker present: {'Albert Einstein' in html}")
if __name__ == "__main__":
main()
Two parameter rules are worth stating because they are easy to get backwards. format is content conversion only, html|md|mdx|json|screenshot|pdf, so format=json converts the page to JSON and is not the envelope; json_response=true is the envelope, and its body is base64 that you must decode before any marker matching. And extract_rules replaces the response with an extracted object, so you never stack it with json_response.
#!/usr/bin/env python3
"""extract_rules replaces the response body with an "extracted" object.
Do not combine it with json_response."""
import json
import urllib.parse
import urllib.request
API = "https://scrape.sparkproxy.io/api/v1"
KEY = "YOUR_SPARKPROXY_API_KEY"
RULES = {
"quotes": {"selector": ".quote .text", "type": "list"},
"authors": {"selector": ".quote .author", "type": "list"},
}
params = {
"url": "https://quotes.toscrape.com/js/",
"render_js": "true",
"proxy_type": "premium",
"extract_rules": json.dumps(RULES),
}
req = urllib.request.Request(
f"{API}?{urllib.parse.urlencode(params)}", headers={"X-API-Key": KEY}
)
with urllib.request.urlopen(req, timeout=120) as r:
print(json.dumps(json.load(r), indent=2)[:600])
For high-volume static targets, render_js=false drops a request to 1 credit and batch mode costs 1 credit for the whole batch, which is the cheapest path when a page does not need a browser at all. For a fuller treatment of where each library fits, Puppeteer vs Playwright for web scraping covers the automation side, and nodriver vs undetected-chromedriver covers the Python CDP stacks.
The maintenance checklist
Run this before you adopt either tool, and again every time upstream ships.
Before you choose:
- Grep your codebase for
page.on("console"),console.,page.route,firefoxandwebkit. Each hit is a Patchright migration cost you can price today. - Grep for
page.pause(). Each hit is a rebrowser debugging cost. - Decide whether you need main-world access. If you do, rebrowser's
addBindingmode is the only one of the two designs that advertises it; Patchright is isolated by design. - Decide whether Puppeteer is in scope. Patchright does not cover it.
- Check the licence file, not the metadata string, against your own policy.
At adoption:
- Pin all three version components exactly, in both the manifest and the lockfile. For Patchright the third component is the project's own fix counter, not Playwright's.
- Pin the browser too.
patchright install chrometracks a moving channel. - If you use the diff route, prefer the published drop-in packages, so the lockfile describes the patched artefact and not the unpatched one.
- If you run the patcher yourself, put it in
postinstall, pin the patcher version, and chain acheckcall with&&so a failure stops the build. - In Docker, install the
patchbinary explicitly and keepnpm ciand the patcher in oneRUNlayer. - On Windows runners, add
C:\Program Files\Git\usr\bintoPATH. - Record the upstream version your stealth layer was built for in a file your CI reads, not in a commit message.
Every upstream release:
- Run a drift check against the npm
dist-tagsforplaywright-coreand your stealth package. Weekly on a cron is enough. - For Patchright, read the open issues filtered to
upstream-breakbefore upgrading. The pipeline posts the changed-symbol table for you. - For rebrowser, never trust a green patcher exit on an untested version. GNU
patchwith default fuzz can apply hunks at an offset into changed code and report success. - Set
REBROWSER_PATCHES_DEBUG=1the first time anything looks wrong; the useful error is suppressed without it. - Re-run your own detection smoke test after the upgrade, against a page you control. Neither project's claim list is a substitute for one measurement on your target.
- Keep a rollback pin ready. Patchright's delay on a broken upstream has been days, not weeks, and staying one minor behind is a legitimate position.
Pick by failure mode, not by star count. Patchright's worst case is a few days on an older Playwright with a public issue explaining exactly which patched symbols changed. rebrowser-patches' worst case is a patch that applies within fuzz against a library twelve minor versions past its tested target, exits zero, and ships. The second one is harder to notice, and noticing is the whole job.
Frequently asked questions
FAQ
The repository is not archived and the maintainers have not said so. What is verifiable is that the newest commit on main is dated 9 May 2025, release 1.0.19 was published the same day, and the version-bump request in issue #128 from 8 September 2026 has no replies. Treat it as frozen at Playwright 1.52.0 and Puppeteer 24.8.1 unless that changes.
No. Patchright patches the Playwright driver and ships Python, Node and community .NET packages built on it. Its README states that only Chromium-based browsers are patched, and Puppeteer is not a target at all. rebrowser-patches is the only one of the two that covers puppeteer-core.
No, and the README says so directly: running npm install or yarn install "might override all the changes from the patches". The patch is not in your lockfile, so npm ci reproduces a correct and unpatched tree. Put the patcher in a postinstall hook and chain its check command so a reverted patch fails the build.
Puppeteer and Playwright call the CDP command Runtime.enable to receive execution-context events, and a page can detect that a few lines of JavaScript ran as a result. rebrowser-patches suppresses the automatic call and recovers a context ID via addBinding, an isolated world, or an immediate enable-then-disable. Patchright suppresses it and executes in isolated contexts only.
Neither project publishes a reproducible, dated benchmark, so an honest answer is that nobody has measured it in public. Patchright's README lists fourteen suites and vendors it claims to pass; rebrowser points you at its own bot-detector.rebrowser.net test page. Run both against your actual target and read those claim lists as vendor claims.
If your job is fetching rendered HTML from protected pages, probably. The SparkProxy Scraping API at https://scrape.sparkproxy.io/api/v1 handles rendering and stealth upkeep behind an X-API-Key header with 1,000 free credits, which removes the entire upgrade-tracking checklist above. Keep a patched browser when you need long-lived authenticated sessions or complex in-page interaction.
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
Related articles
Roxy Browser Alternatives for Cross-Border Sellers
Roxy Browser alternatives picked for cross-border selling: five vendors priced from their own pages, plus the four account layers no browser can touch.
Indigo Browser Alternatives: Check the Paper Trail
Indigo browser alternatives ranked on one test: does the vendor publish a dated record of what shipped? Plus which product indigobrowser.com actually is.
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.
