Camoufox vs Playwright Firefox: What Each Build Patches
Camoufox vs Playwright Firefox, measured: the leaks stock Playwright Firefox ships, what Camoufox patches in C++, and when Gecko is the right client.
Camoufox vs Playwright Firefox is not a comparison between a stealth browser and a normal one. Both are forks of Firefox that Mozilla did not ship, and neither one is the Firefox on your laptop. The only question that matters is what each patch set was written to achieve, because that decides which properties a detection script can read back. On 9 October 2026 I probed the stock build directly and read Camoufox's shipped package and source, and the stock build's leak list is longer than most people expect.
Both of these are patched Firefox, not Firefox
Start with the sentence Microsoft puts in its own documentation. From the Playwright browsers page: "Playwright's Firefox version matches the recent Firefox Stable build. Playwright doesn't work with the branded version of Firefox since it relies on patches."
That is the whole frame. When you run p.firefox.launch(), you are not driving Firefox. You are driving a Mozilla-derived build carrying an XPCOM component called Juggler plus a large preference override file, downloaded from cdn.playwright.dev. Camoufox is also a Mozilla-derived build carrying Juggler plus a large patch set. Two forks, same ancestor, same control protocol.
The difference is the brief each patch set was written against:
| Playwright Firefox | Camoufox | |
|---|---|---|
| Who builds it | Microsoft, as part of Playwright | daijro, single-maintainer project |
| Purpose of the patches | make a browser reproducible and controllable for tests | make a browser statistically ordinary |
| Where fingerprint values come from | the host machine | a drawn identity, checked for coherence |
| `navigator.webdriver` | `true`, measured | patched, per the project's capability list |
| Shipped preference file | `firefox/playwright.cfg`, readable in the download | Firefox source patches, compiled in |
| Extension support | none on Firefox, per the docs | `addons` launch parameter |
| Latest build at time of writing | Firefox 157.0, with Playwright 1.64.0 (7 Oct 2026) | Firefox 156.0.1 line, `v156.0.1-beta.36` (6 Oct 2026) |
Both numbers in that last row are current. Playwright 1.64.0 shipped on 7 October 2026 bundling Firefox 157.0, which packages/playwright-core/browsers.json confirms as revision 1556. Camoufox v156.0.1-beta.36 was published on 6 October 2026. Firefox stable itself is 157.0.1, per Mozilla's product-details feed. Camoufox trails stable by a single major version, which is what a project rebasing a source patch set onto each Firefox release looks like when it is keeping up.
A note on sourcing before the numbers start, because the two sides of this post were established differently. Everything I attribute to stock Playwright Firefox is a value I read off a running browser. Everything I attribute to Camoufox comes from its own documentation, its release and commit history, or from reading the camoufox 0.5.8 package installed on this machine. I did not run a Camoufox browser for this post, so no stealth outcome here is presented as a result I observed, and every Camoufox capability is labelled as the project's claim.
Maintenance is not the discriminator here, so put it to bed with dates rather than adjectives. The Camoufox repository's six most recent commits run from 4 October to 8 October 2026, including "Install the paired browser on first launch, and refuse a browser the library cannot drive" (#837) on 6 October. The PyPI launcher camoufox 0.5.8 was uploaded on 6 October 2026 at 20:56 UTC. Both projects shipped code within the last week. For how Camoufox compares against the Chromium-family stealth tools, which is a different argument entirely, see our three-way on undetected-chromedriver vs Patchright vs Camoufox.
The leak register I measured on stock Playwright Firefox
Everything in this section is a measurement I ran, not a quoted claim. Environment: Windows Server 2022, Python 3.14.2, playwright 1.63.0 from PyPI driving its bundled Firefox 155.0 (revision 1543), headless and headed, page at about:blank. Playwright 1.64.0 had no Python wheel on PyPI on 9 October 2026, so 1.63.0 is the newest a Python user can install.
import json
from playwright.sync_api import sync_playwright
PROBE = """() => {
const el = document.createElement('div');
document.body.appendChild(el);
const col = (name) => { el.style.color = name; return getComputedStyle(el).color; };
const out = {
webdriver: navigator.webdriver,
pdfViewerEnabled: navigator.pdfViewerEnabled,
plugins: navigator.plugins.length,
mimeTypes: navigator.mimeTypes.length,
cores: navigator.hardwareConcurrency,
screen: [screen.width, screen.height, screen.availHeight].join('x'),
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
highlight: col('Highlight'),
buttonFace: col('ButtonFace'),
hasFocus: document.hasFocus(),
buildID: navigator.buildID,
};
el.remove();
return out;
}"""
with sync_playwright() as p:
browser = p.firefox.launch(headless=True)
page = browser.new_page()
page.goto("about:blank")
print(browser.version, json.dumps(page.evaluate(PROBE), indent=1))
browser.close()
Results. Every value in the middle column came off my own run:
| Property | Playwright Firefox 155.0 (measured) | Why it stands out |
|---|---|---|
| `navigator.webdriver` | `true` | A real user's browser reports `false` |
| `navigator.pdfViewerEnabled` | `false` | pdf.js is on in a default Firefox install |
| `navigator.plugins.length` | `0` | Spec requires 5 entries when PDF viewing is supported |
| `navigator.mimeTypes.length` | `0` | Should be 2, `application/pdf` and `text/pdf` |
| `navigator.hardwareConcurrency` | `32` | The host's real core count, stable forever |
| `screen` headless | `1366x768`, `availHeight 720` | A default, not a panel |
| `screen` headed | `1920x1080`, `availHeight 1032` | Matches the host, so fine |
| `Intl` timezone | `Asia/Kolkata`, the host OS zone | Ignores the proxy exit entirely |
| CSS `Highlight` | `rgb(51, 153, 255)` | Firefox's standin palette, not the OS value |
| CSS `ButtonFace` | `rgb(233, 233, 237)` | Same palette |
| `prefers-color-scheme: dark` | `false`, always | Forced by a shipped preference |
| `document.hasFocus()` | `true`, headless and backgrounded | A backgrounded real tab reports `false` |
| `navigator.buildID` | `20181001000000` | Not a tell; Mozilla froze this upstream |
Several of those are not accidents. They are written down in the preference file Playwright ships inside its own download, at firefox-1543/firefox/playwright.cfg, which you can read with cat right now:
# Linux/macOS. On Windows the same file sits under
# %LOCALAPPDATA%\ms-playwright\firefox-1543\firefox\playwright.cfg
grep -E 'pdfjs|standins|systemUsesDarkTheme|focusmanager|processCount' \
~/.cache/ms-playwright/firefox-1543/firefox/playwright.cfg
pref("pdfjs.disabled", true);
pref("dom.ipc.processCount", 60000);
pref("focusmanager.testmode", true);
pref("ui.systemUsesDarkTheme", 0);
pref("ui.use_standins_for_native_colors", true);
The PDF line is the most expensive, and it is a three-property tell rather than one. The HTML specification ties all of them to the same internal flag: the pdfViewerEnabled getter "steps are to return the user agent's PDF viewer supported", and when that flag is set the browser must expose a fixed PDF viewer plugin names list of exactly five entries. Disabling pdf.js clears the flag, so the boolean, the plugin array and the MIME type array all empty out together. A Gecko user agent arriving with plugins.length === 0 is a contradiction that costs a detector one property read.
This one you can fix yourself. I re-ran the probe with the pref flipped back:
browser = p.firefox.launch(
headless=True,
firefox_user_prefs={"pdfjs.disabled": False},
)
Measured result, and the strings are worth seeing because they are what a real Firefox reports verbatim:
pdfViewerEnabled: true
plugins: 5 PDF Viewer|Chrome PDF Viewer|Chromium PDF Viewer|
Microsoft Edge PDF Viewer|WebKit built-in PDF
mimeTypes: 2 application/pdf|text/pdf
Camoufox lists the same repair in its capability list as "Re-enable PDF.js", which confirms the two projects agree on what the leak is. The difference is that Camoufox ships the fix and Playwright ships the leak, because Playwright wants PDFs to land in the downloads directory for test assertions.
The colour palette is half curable, which I only know because I tried it. With ui.use_standins_for_native_colors set to False, Highlight changed from rgb(51, 153, 255) to rgb(0, 120, 215), which is the host machine's actual Windows highlight colour. ButtonFace and AccentColor did not budge on this build. So one pref recovers one OS-derived colour and leaves the rest on Firefox's standin values, and I have not verified which of the remaining ones a default Firefox would report differently.
navigator.webdriver is the one that does not yield to a preference. I launched three times, with no prefs, with dom.webdriver.enabled set to False, and with that plus three others:
| Launch config | `navigator.webdriver` | Getter `toString()` |
|---|---|---|
| default | `true` | `function webdriver() { [native code] }` |
| `dom.webdriver.enabled: False` | `true` | `[native code]` |
| four prefs flipped | `true` | `[native code]` |
The flag survives the preference that is supposed to control it, so the stock build is asserting it somewhere below the preference layer. I did not trace which patch does that, only that no preference I tried reaches it. So on stock Playwright Firefox your options are an add_init_script property override, which is JavaScript injection with all the detectability that carries, or a different build. There is no third option. Our walkthrough of headless browser detection covers how detectors chain that flag with the rest.
One more measurement worth recording because it corrects a widely repeated claim. The main world is clean under plain use. Scanning Object.getOwnPropertyNames(window) and document for anything matching /playwright|puppeteer|juggler|__pw|automation/i returned empty arrays on both headless and headed launches. Call page.expose_function("x", fn) and the same scan returns three entries:
['__playwright__binding__', '__playwright__binding__controller__', 'x']
and the exposed function's toString() does not contain [native code]. So Playwright's binding globals are real, but they appear only when you ask for a binding. page.evaluate alone leaves nothing behind.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
The UA override that builds a contradiction
This is the single measurement that explains why source patching exists, and it uses only documented Playwright options. I asked for a macOS identity the way the API intends:
context = browser.new_context(
user_agent=(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:155.0) "
"Gecko/20100101 Firefox/155.0"
),
locale="de-DE",
timezone_id="Europe/Berlin",
viewport={"width": 1280, "height": 800},
screen={"width": 2560, "height": 1440},
device_scale_factor=2,
color_scheme="dark",
)
What the page saw:
| Signal | Value | Asked for? |
|---|---|---|
| `navigator.userAgent` | `...(Macintosh; Intel Mac OS X 10.15; rv:155.0)...` | yes, applied |
| `navigator.platform` | `Win32` | no, host value |
| `navigator.oscpu` | `Windows NT 10.0; Win64; x64` | no, host value |
| `Intl` timezone | `Europe/Berlin` | yes, applied |
| `Intl` locale and `navigator.languages` | `de-DE` | yes, applied |
| `screen.width x height` | `2560x1440` | yes, applied |
| `devicePixelRatio` | `2` | yes, applied |
| `prefers-color-scheme: dark` | `true` | yes, applied |
| `navigator.hardwareConcurrency` | `32` | not emulable |
| WebGL unmasked vendor | `Google Inc. (NVIDIA)` | not emulable |
| `navigator.maxTouchPoints` | `0` | not emulable on Firefox |
Read the first three rows together. A Mac claiming Win32 as its platform and Windows NT 10.0 as its OS is not an unusual machine. It is an impossible one, and the check is a string comparison a junior engineer could write. Playwright is not at fault here: its userAgent option is an emulation feature for tests, and nothing in the docs promises a coherent device identity. The failure is using a test tool's emulation surface as a disguise.
Then look at the bottom three rows. hardwareConcurrency reported 32 because that is the real core count of the machine I ran on. A consumer Firefox install reporting 32 logical cores is a rounding error in the traffic distribution, and the number is stable across every session you ever run from that box, which makes it a correlator rather than just an oddity. WebGL still reported the host GPU through ANGLE. maxTouchPoints stayed 0 because has_touch and is_mobile are Chromium-only Playwright options.
So stock Playwright Firefox gives you four knobs that work cleanly (timezone, locale, screen geometry, colour scheme), one that actively creates a contradiction if you use it (user_agent), and a hardware layer you cannot reach at all.
Source patch versus runtime property spoof
The alternative to a patched build is injecting JavaScript before page scripts run. Both Playwright's add_init_script and every stealth plugin take that route, and the Camoufox README is blunt about why it has a ceiling:
Additionally, all injected JavaScript is detectable in some way. Anti-bot systems can check if
Object.getOwnPropertyDescriptorreveals an overwritten property, if a function'stoString()no longer returns[native code](revealing it was hijacked), or if data in the window context doesn't match the worker thread context.
Three distinct checks, and each one is cheap to run:
The descriptor check. Replacing navigator.hardwareConcurrency with Object.defineProperty leaves a descriptor whose shape differs from the native accessor. In my measurement the native webdriver descriptor on Navigator.prototype had keys get,set,enumerable,configurable. An own-property override on the instance shows up in the wrong place on the prototype chain, and the page can walk that chain.
The toString check. This is the one I confirmed by accident: the function I exposed through page.expose_function failed the [native code] test, while every genuine Gecko accessor passed it. Proxy-based wrappers that forge toString are themselves forgeable, which is how this turns into an arms race rather than a fix.
The worker check. A DedicatedWorkerGlobalScope has its own global, and an init script that patched window.navigator has not patched the worker's navigator. Spawning a worker from a blob URL and comparing navigator.hardwareConcurrency across both scopes costs about eight lines of JavaScript. Anything that spoofs per-realm by injection has to enumerate every realm, including ones created after the patch ran.
Compiled changes sidestep all three because there is no JavaScript layer to inspect. Camoufox's own summary: "Since Camoufox intercepts calls in the browser's C++ implementation level, all of the hijacked objects and properties appear native. There is no JavaScript hijacking to be detected." That is a structural property of the approach and it is also the reason Camoufox needs a rebuild for every Firefox release while an injection library needs only a new script. If you are weighing the injection route on Chromium instead, our writeup on stealth plugins for Puppeteer and Playwright covers how those patch sets age.
What source patching does not fix is inconsistency between patched values, which is the next section.
What Camoufox's coherence check refuses to ship
The most under-reported part of Camoufox is not the C++ interception. It is the validator that runs before launch. Launch parameters that are each individually plausible can combine into a machine that has never existed, and the README says so: "fingerprints must also be internally consistent. A Windows user agent with an Apple M1 GPU, a MacOS user agent with a Windows DirectX renderer, and a mobile device with a desktop screen resolution are all impossible, and will be flagged for being suspicious."
I read pythonlib/camoufox/coherence.py out of the installed 0.5.8 package today. The rules are specific enough to be worth quoting, because they are a free checklist for anyone hand-rolling fingerprints on any engine:
Apple Silicon core counts are an explicit set, not a range. The file's comment: "The M1 is the floor at 8; nothing Apple has made has fewer... 11 is in the set because the M3 Pro is 6P+5E, an odd count that is real, and the reason this is an explicit list rather than '8 to 32': 9, 13 and 15 are not Apple parts. Apple Silicon has no SMT, so logical cores == physical."
devicePixelRatio is quantised per platform. Windows exposes the display-scaling steps at 100, 125, 150, 175, 200, 250 and 300 percent. macOS reports 1 or 2 and nothing between. Linux reports 1, or 2 under HiDPI, with GNOME fractional scaling adding 1.25, 1.5 and 1.75. The comment names the artefacts it rejects: "Values outside these (1.818, 1.09, 1.36) are scraped artefacts, a browser zoom level folded into the ratio, not a display mode."
colorDepth is 24, or 30 on a deep-colour display, and "32 appears in scraped data and is not a value Firefox emits." macOS defaults to deep colour, so an Apple Silicon identity reports 30.
Window chrome height is a property of the binary, not the identity. Measured at 86 CSS pixels on that build. The comment explains the consequence: "the real window is sized from window.outerHeight, so the content area a page can actually receive input in is outerHeight - 86. An identity claiming outerHeight - innerHeight SMALLER than that is claiming a viewport taller than the window can hold, and the difference is unreachable: mouse events dispatched into those bottom rows are delivered to nothing." A drawn pair of outer 801 and inner 717 left the bottom two rows dead, caught on 17 September 2026 by a boundary sweep.
GPU strings are gated by platform rendering path. "Firefox 152 renders WebGL on a Mac through CGL, not ANGLE: its SanitizeRenderer has no ANGLE-on-Metal branch (Bug 2046027 added one after 152)." So an ANGLE renderer string under a macOS user agent is impossible on that Firefox line, independent of anything the identity claims.
The values themselves come from fpgen, which the README describes as "a Bayesian network trained on live traffic, so each device characteristic appears about as often as it does in the real world, and in the combinations real devices produce." camoufox 0.5.8 pins fpgen>=1.3.0,<2.0.0 and ships version 1.3.0. Font data is bundled rather than generated: fonts.json in the installed package lists 335 Windows families, 567 macOS and 358 Linux, and media-devices.json carries weighted per-OS pools of device labels in "the label format Firefox exposes after a getUserMedia grant".
None of this exists in stock Playwright Firefox, and none of it needs to, because Playwright is not trying to generate an identity. It reports the host. Our primer on browser fingerprinting covers the signal categories these rules are defending.
Juggler is the quiet advantage, and the ceiling
Both builds speak Juggler. Playwright launches Firefox with -juggler-pipe, visible in defaultArgs in packages/playwright-core/src/server/firefox/firefox.ts, and Camoufox ships a patched copy of the same component. So Juggler is common ground, not a differentiator between these two, which is the opposite of how it gets written up.
What Juggler buys you is relative to the Chromium side. Camoufox's README: "While Playwright uses CDP to control Chromium, it uses Juggler for Firefox. Juggler is a custom protocol developed before Firefox supported CDP. It is a distinct module within Firefox, and not part of its core browser." Two consequences follow. There is no Runtime.enable equivalent whose side effects a page can probe, and inputs go "directly through the Firefox's original user input handlers, meaning they are handled the exact same way as if you were using the browser normally." Mozilla, for its part, deprecated its CDP shim from Firefox 129 in favour of WebDriver BiDi, so driving Gecko over CDP is not a live option anyway. We cover the BiDi feature gaps in headless Chrome vs headless Firefox.
Where the two Firefox builds actually diverge is what Juggler is allowed to see. Camoufox "patches Juggler to give it its own isolated 'copy' of the page to work with. Playwright can read and edit its own version of the page freely. Everything appears to work normally to it, but the real page is completely unaffected by these changes. The page also can't detect when things are being read (through tricks like hijacking getters) or listeners being added to watch elements."
That last clause is the part my window scan could not test, and it is worth being precise about. My measurement showed stock Playwright Firefox leaves no automation-named globals in the main world under plain page.evaluate, so the commonly quoted version of this claim overstates the visible surface. The real difference is subtler: a page that installs a getter on a property and waits to see whether anything reads it can observe the automation layer touching the DOM on the stock build, and cannot on Camoufox. Nobody publishes a one-liner for that, which is exactly why it keeps working.
Now the ceiling, and it is the most important paragraph in this post. From the README's own note:
Camoufox does not fully support injecting Chromium fingerprints. Some WAFs (such as Interstitial) test for Spidermonkey engine behavior, which is impossible to spoof.
You cannot leave Gecko. Error message wording, stack trace formatting, property enumeration order, the exact shape of Function.prototype.toString output on built-ins, floating-point edge cases in Math: all of it says SpiderMonkey, and none of it is patchable without writing a different JavaScript engine. Which means every identity Camoufox draws, no matter how it rotates, is a Firefox identity. Fingerprint rotation moves you around inside the Firefox bucket. It never moves you out of it.
Extensions: only one of them loads an addon
This difference is binary and the documentation is explicit on both sides.
Playwright: extensions "only work in Chromium when launched with a persistent context", per the Chrome extensions page. There is no Firefox extension support in Playwright at all.
Camoufox exposes addons as a launch parameter. The capability list is specific: "Load Firefox addons without a debug server by passing a list of paths to the addons property", addons "are not allowed to open tabs", are "automatically enabled in Private Browsing mode" and "automatically pinned to the toolbar". uBlock Origin ships as a default addon with custom privacy filters, and the package also claims a fix for "DNS leaks with uBO prefetching".
Two operational notes from reading camoufox/addons.py in the installed package. First, the path must be an extracted directory containing manifest.json, not an .xpi; the code raises InvalidAddonPath with the message "manifest.json is missing. Addon path must be a path to an extracted addon." Second, the default uBO is fetched from addons.mozilla.org/firefox/downloads/latest/ublock-origin/latest.xpi at install time, which is a network dependency worth knowing about in a locked-down build environment. Opt out with exclude_addons=[DefaultAddons.UBO].
Whether that matters depends on your job. For scraping, a content blocker is primarily a bandwidth and latency lever: blocking third-party ad and analytics requests cuts page weight substantially on ad-funded targets, and under a metered proxy plan that is money. For account automation, extension support is sometimes the whole requirement, and on Firefox only one of these two builds can do it.
Rotation: context options versus a drawn identity
The two builds rotate at different layers, and this changes your process architecture more than any single fingerprint property does.
Playwright rotates per browser context. One Firefox process, N contexts, each with its own cookie jar, its own proxy, its own timezone_id and locale. Cheap. The identity inside each context is still the host machine's hardware with four emulation knobs on top.
context = browser.new_context(
proxy={
"server": "http://gateway.sparkproxy.io:11000",
"username": "USER-country-de-session-a1b2c3",
"password": "PASS",
},
locale="de-DE",
timezone_id="Europe/Berlin",
)
Note that you have to know the exit country to set the timezone, and you have to look it up yourself on every rotation.
Camoufox rotates per launch. fpgen draws a fresh identity, the coherence check validates it, and the values are compiled into the process through the browser's configuration surface. A new identity means a new process.
from camoufox.sync_api import Camoufox
proxy = {
"server": "http://gateway.sparkproxy.io:11000",
"username": "USER-country-de-session-a1b2c3",
"password": "PASS",
}
with Camoufox(proxy=proxy, geoip=True, os=["windows", "macos"], humanize=True) as browser:
page = browser.new_page()
page.goto("https://www.sparkproxy.io/pricing")
print(page.title())
geoip=True resolves the exit IP and sets timezone, locale and geolocation from it. The Python interface also "calculates and spoofs the browser's language based on the distribution of language speakers in the proxy's target region", which is a different thing from setting locale to the country's main language and a detail most hand-rolled setups get wrong.
The architectural trade reads like this:
| Playwright Firefox | Camoufox | |
|---|---|---|
| Rotation unit | browser context | browser launch |
| Cost of a new identity | a few MB and milliseconds | a full browser start |
| Hardware layer | host values, fixed | drawn per launch |
| Timezone and locale from proxy exit | manual lookup, every rotation | `geoip=True` |
| Per-context proxy | yes, with credentials | yes, inherited from Playwright |
| Fonts | host font set | bundled per target OS |
| Identity source | none | `fpgen` Bayesian draw plus coherence check |
Whichever side you pick, the IP underneath is doing more work than the fingerprint. A drawn Windows identity arriving from a flagged datacentre range fails on request one. SparkProxy's residential pool is 1M+ premium IPs across 80+ countries with random rotation and 5-minute auto-rotation, HTTP/HTTPS/SOCKS5, IP whitelist or user:pass auth, and a 1 Gbps port. Plans start from $75/month and are sold as packs with unlimited bandwidth, so the billing unit is the plan and its concurrency rather than traffic consumed, and there is a 24-hour free trial with no credit card. Term discounts are 5% at 3 months, 10% at 6 and 15% at 12. If you are still choosing a pool type, residential vs datacenter proxies covers the trade.
The throughput bill
Stealth costs wall-clock time and memory, and the two builds spend it in different places. These are structural costs, not benchmark numbers, because per-page figures depend entirely on your targets.
Humanised input is real delay you opt into. humanize=True replays recorded human cursor paths. Camoufox ships Cursory via cursory-js, vendored into Juggler, which the README says "holds 2356 mouse movements recorded from real people: it picks a recording whose direction, distance and wander suit the move being made, morphs it onto the requested start and end points, and replays it with that recording's own timing, pauses, overshoots and all." That replay happens at human speed. It is the single largest throughput cost in the stack, and humanize also accepts a maximum duration in seconds if you need a ceiling.
One honest detail from the same page, because it shows the project correcting itself: Camoufox previously "walked a Bézier curve through two random knots and emitted a point every 10ms", and the README calls out both halves as tells, since "an analytic curve sampled at a fixed rate has velocity and jerk profiles that separate cleanly from a hand's" and the easing meant "every movement Camoufox ever made sped up and slowed down the same way". If you are running an older pinned build, that is what you are running.
Animations. Camoufox runs animations on stock timing by default. instantAnimations: True finishes them at once, and the capability list states the cost plainly: it is "detectable". That is a deliberately offered footgun, labelled as one.
Memory. The Camoufox README claims the debloated build "runs faster than the original Mozilla Firefox, and uses less memory (200mb)". That is the project's own unverified figure, not something I measured, and your number will depend on your pages.
The process model is not the Firefox default on either build. Mozilla's process model documentation gives dom.ipc.processCount a default of 8 shared content processes. Playwright's shipped playwright.cfg sets it to 60000 and disables process preallocation with dom.ipc.processPrelaunch.enabled, with the comment "Increase max number of child web processes so that new pages get a new process by default and we have a process isolation between pages from different contexts." So the "Firefox is cheap at concurrency because it pools processes" reasoning does not apply to Playwright Firefox. Every context gets its own process, by design, and your memory curve looks closer to Chrome's than to Firefox's.
Headless mode. Camoufox "patches Firefox's headless mode to appear the same as if it were running in a normal window", with headless="virtual" as a fallback that runs the browser headfully inside a virtual display. Reading camoufox/virtdisplay.py: that path shells out to Xvfb, raises CannotFindXvfb("Please install Xvfb to use headless mode.") when the binary is absent, and raises VirtualDisplayNotSupported off Linux. Budget for a Linux box and an Xvfb install if you plan to use it.
Install and version-pinning traps
Three things will bite you on a first install, all verified on 9 October 2026.
Camoufox pins Playwright below 1.63. camoufox 0.5.8's requires_dist on PyPI lists playwright (<1.63). Installing Camoufox into an environment that already has Playwright 1.63.0 silently downgrades it to 1.62.0. If your test suite uses an API added in 1.63, put Camoufox in its own virtual environment. I hit this exactly: pip install camoufox resolved to camoufox 0.5.8, playwright 1.62.0, fpgen 1.3.0.
The launcher pins one exact browser build and refuses others. camoufox/browser-pin.json in the installed 0.5.8 package reads {"tag": "v156.0.1-beta.36", "repo": "daijro/camoufox", "version": "156.0.1"}. The commit that introduced this behaviour on 6 October 2026 is titled "Install the paired browser on first launch, and refuse a browser the library cannot drive" (#837). An older cached browser will not be driven by a newer launcher.
Resolving that build calls the GitHub API, and anonymous calls are rate limited. My own camoufox fetch failed with Error: 403 Client Error: rate limit exceeded for url: https://api.github.com/repos/..., and curl -s https://api.github.com/rate_limit confirmed {'limit': 60, 'remaining': 0}. Reading camoufox/pkgman.py shows the fix: line 78 is GITHUB_TOKEN = os.getenv("GITHUB_TOKEN"), and the request headers become {"Authorization": f"Bearer {GITHUB_TOKEN}"} when it is set. Export a token in CI or your first-launch install is one shared runner IP away from failing.
export GITHUB_TOKEN=ghp_xxx
python -m camoufox fetch
One more change worth noting if you are on the JavaScript side: the commit on 6 October 2026 titled "Publish the npm package as camoufox instead of @camoufox/camoufox" (#838) renamed the npm package. Older tutorials reference the scoped name.
Stock Playwright Firefox has none of these problems. pip install playwright && playwright install firefox pulls a 110 MB zip from Microsoft's CDN with no API calls and no pinning puzzle. That reliability is a real part of the comparison.
When Gecko is the right client at all
Being a Firefox client is a strategic position, not a neutral default, and the arithmetic cuts both ways.
The upside is that almost every published detection technique targets Chrome, because almost all traffic is Chrome. The window.chrome object shape, CDP side effects, Blink-only API presence and the artefacts left by Chromium-focused patch libraries are all inapplicable on Gecko. You are clean on a large body of checks for free.
The downside is the bucket. Your user agent lands in a much smaller population, which means two things. Aggregate analysis notices a crawler whose traffic is 100% Firefox from a narrow set of ASNs even when every single request is internally coherent, and the SpiderMonkey limit above means rotation cannot fix it. You can be any Firefox user. You cannot be a Chrome user.
So, concretely:
Choose Camoufox when the target's defences are fingerprint-heavy, you need identities that survive a coherence audit, you need a Firefox addon loaded, or you want timezone and locale derived from the proxy exit without writing that lookup yourself. Accept the per-launch rotation cost, the single-maintainer risk, and the install pinning.
Choose stock Playwright Firefox when you are testing your own application, you need Playwright's full current API and tooling including the trace viewer, you want a cross-engine matrix, or you are scraping targets that do not fingerprint at all. Add firefox_user_prefs={"pdfjs.disabled": False} either way, because it costs nothing.
Choose Chromium instead when the target reads Chrome-specific signals, when you need extensions in a persistent context, or when being in the majority bucket is itself the goal. The cross-engine comparison lives in our undetected-chromedriver vs Patchright vs Camoufox writeup, and the engine-level trade in headless Chrome vs headless Firefox.
Choose no browser at all when the page renders its data server-side or hands it to you in a JSON endpoint, which is more often than people assume. A browser costs hundreds of MB of RAM per identity to parse HTML that curl could have fetched. SparkProxy's Scraping API handles the rendering and proxy layers for you at https://scrape.sparkproxy.io/api/v1 with an X-API-Key header and 1,000 free credits:
import base64
import requests
BASE = "https://scrape.sparkproxy.io/api/v1"
HEADERS = {"X-API-Key": "YOUR_KEY"}
def fetch(url, render=False, country=None):
params = {
"url": url,
"render_js": "true" if render else "false",
"proxy_type": "premium",
"json_response": "true",
}
if country:
params["country_code"] = country
r = requests.get(BASE + "/scrape", headers=HEADERS, params=params, timeout=120)
r.raise_for_status()
env = r.json()
html = base64.b64decode(env["body"]).decode("utf-8", "replace")
return env["status_code"], env["credits_used"], env["duration_ms"], html
code, credits, ms, html = fetch("https://www.sparkproxy.io/pricing", render=False, country="de")
print(code, credits, "credits", ms, "ms", len(html), "bytes")
Two details that trip people up. render_js defaults to true and costs 5 credits, so pass render_js=false explicitly for a plain fetch at 1 credit. And json_response=true is the only thing that returns the envelope with job_id, status_code, duration_ms and credits_used; the body inside it is base64 and must be decoded before you match any marker against it. The separate format parameter is content conversion only, taking html, md, mdx, json, screenshot or pdf, so format=json does not give you the envelope. country_code adds 5 credits, js_scenario adds 5 and needs render_js=true with at most 50 steps, and extract_rules replaces the response with an extracted object rather than stacking with json_response. Batch mode needs render_js=false and the whole batch costs 1 credit.
The honest summary of this whole comparison: stock Playwright Firefox is the better engineering tool and Camoufox is the better disguise, and most teams discover which one they needed by watching a block rate rather than by reading a feature table. Measure your own targets with the probe at the top of this post before you commit to either.
Frequently asked questions
FAQ
No. Camoufox is a separate Firefox build with its anti-fingerprinting changes compiled into the C++ source, plus a patched copy of Juggler. Nothing in Playwright's firefox_user_prefs or context options reproduces it, and I measured that even dom.webdriver.enabled: False fails to clear navigator.webdriver on the stock build.
Yes. Measured true on Playwright 1.63.0 with Firefox 155.0, headless and headed, and it stayed true with dom.webdriver.enabled set to False. The getter still reports [native code], so the flag is asserted below the preference layer.
No. Playwright's documentation states extensions "only work in Chromium when launched with a persistent context". Camoufox accepts an addons list of extracted addon directories, each containing a manifest.json, and ships uBlock Origin as a default addon.
Not immediately. camoufox 0.5.8 declares playwright (<1.63), so installing it alongside Playwright 1.63.0 downgrades Playwright to 1.62.0. Keep Camoufox in its own virtual environment if your main suite needs a newer Playwright API.
Because it changes only the user agent string. I set a macOS user agent and measured navigator.platform still reporting Win32 and navigator.oscpu still reporting Windows NT 10.0; Win64; x64. That contradiction is easier to detect than the original Windows identity was.
It depends on the target. Most published detection techniques are written against Chrome, so Gecko skips a lot of them for free. The cost is that your traffic sits in a far smaller user agent population, and because SpiderMonkey behaviour cannot be spoofed, no amount of fingerprint rotation moves a Camoufox session out of the Firefox bucket.
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.
