Automation Plugins vs a Native Multi-Account Browser: 4 Layers JavaScript Can't Reach
You write a Puppeteer script, add a popular helper plugin, and it behaves on your laptop. Then the same script runs from a server and the site responds differently: a changed layout, a different redirect. Same code. Something below your script is reporting a different story.
That something is usually consistency. Not any single value, but whether the values a browser reports agree with each other across layers.
This post maps those layers. It's an engineering explainer, aimed at QA teams, people automating their own workflows and agencies running separate client accounts. It isn't a guide to defeating anyone's security, and nothing below should be read as a promise that a given site will accept a given browser.
Quick Verdict
TL;DR: A JavaScript-layer plugin patches values inside the page after launch. A native multi-account browser changes the engine itself, so header-level and thread-level values come from the same profile as everything else. For your own sites, internal tools and light QA, the plugin is probably fine. Don't over-engineer. For teams keeping many profiles separate and consistent, the engine layer matters.
What a JavaScript Plugin Can Actually Reach
Automation helper plugins work by injecting scripts into each page before the site's own code runs. Typical targets:
| Surface | What the plugin does |
|---|---|
navigator properties | Redefines getters such as languages or plugins |
window.chrome objects | Adds or reshapes objects that headless builds omit |
| Permissions API | Overrides query responses |
| WebGL vendor/renderer strings | Overrides the getter that returns them |
navigator.userAgent | Rewrites the page-visible string |
iframe contentWindow | Normalises access patterns |
That's a useful list, and the maintainers of these projects have done careful work over the years.
But every row has the same shape: a function wrapped around a value, running in the page's main JavaScript context. Everything below is what that approach can't touch.
The 4 Layers Plugins Can't Reach
1. TLS fingerprints (JA3/JA4)
This is the big one.
When a browser opens an HTTPS connection, the TLS handshake finishes before any page script exists. During it, the browser sends its supported cipher suites, extensions and their ordering. Servers can hash those into a JA3 or JA4 fingerprint; the FoxIO JA4 specification documents how.
// What a server sees for one connection:
JA4: t13d1516h2_<cipher-hash>_<extension-hash>
// The leading part encodes:
// - protocol (t = TCP) and TLS version (13)
// - SNI present (d), cipher count (15), extension count (16)
// - first ALPN value (h2)
// The two hashes cover cipher and extension content/order
Different browsers produce different handshakes. A plugin can't change any of it, because JavaScript hasn't started when the handshake happens. Neither can any other page script. For how extension order feeds into the hash, see our TLS GREASE and extension order explainer.
If you're on the receiving end, ClickzProtect computes JA4 per visitor as one signal for traffic operators. Computing it per visitor is the point; there's no library of known-good fingerprints behind it.
2. Worker thread consistency
Web Workers and Service Workers run in separate threads with their own JavaScript contexts (see MDN's Web Workers API overview). A script can read navigator.userAgent on the main thread and again inside a Worker. Those two should match each other, the HTTP User-Agent header and the Sec-CH-UA client hint.
A plugin that patches pages patches the main thread. A Worker starts clean.
// Main thread (patched by a plugin):
navigator.userAgent // "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/..."
// Worker thread (a fresh context, patch not applied):
self.navigator.userAgent // "Mozilla/5.0 (X11; Linux x86_64) HeadlessChrome/..."
When the engine itself is configured from a profile, every context reads the same source. Nothing has to be copied into each thread after the fact.
3. Client Hints headers
HTTP Client Hints (Sec-CH-UA, Sec-CH-UA-Platform, Sec-CH-UA-Mobile) travel as request headers, so they're sent before your page JavaScript exists. Chrome's own User-Agent Client Hints documentation covers the full set.
A plugin can rewrite navigator.userAgentData in the page. The headers already went out.
// HTTP headers (sent before page JS runs):
Sec-CH-UA-Platform: "Linux"
// JavaScript after a plugin patch:
navigator.userAgentData.platform // "Windows"
Two sources, two answers, and the profile no longer agrees with itself. An engine that builds headers and JavaScript values from one profile config doesn't have the gap.
4. Canvas and WebGL rendering
A plugin can override getParameter so the renderer string says one GPU. It can't change how your real GPU draws pixels to a canvas.
Rendered output depends on your hardware, your driver and your OS compositor. So a profile that claims one GPU family while drawing like another is internally inconsistent, whatever the strings say. Per-profile canvas, WebGL and audio parameters in the engine exist to keep those values agreeing with each other. We won't cite checker scores for this; the useful test is your own: does a profile report the same story everywhere you look?
For the fuller picture, see our WebGL fingerprinting breakdown.
Override vs Native: the Architectural Difference
JavaScript override:
Browser launches → page loads → plugin injects script →
Object.defineProperty replaces a getter →
page script reads the property, gets the replacement →
page script inspects the getter itself (toString, timing, prototype chain) →
the replacement is visible as a replacement
Native engine change:
Engine compiled with the value set from the profile →
browser launches → page loads →
page script reads the property →
the value comes from the engine; no injected function exists
The honest difference is that a runtime override is a second layer sitting on top of the real value, while an engine-level value has a single source. That's a point about consistency, not about getting anywhere you're not allowed.
JustBrowser's engine is a custom Chromium build with native C++ integration, patched on every upstream release. It exposes 40+ identity parameters per profile. One caveat worth stating: that applies to Chrome profiles. Firefox profiles run a real Firefox engine, and their parameters are set through JavaScript instead.
When a Plugin Is Fine (Seriously)
We don't want to oversell a dedicated browser either. It's easy to reach for one you don't need.
A plugin is probably plenty if:
- You're testing your own site or an internal tool
- Cost matters most (the plugin is free)
A quick test: run your script with the plugin, see whether your workflow completes, and stop there if it does. A native browser adds separate processes per profile, API orchestration and a subscription. It only earns that cost when you're managing many separate profiles for a team.
The Realistic Engineering Decision
Use a plugin when:
- One script, one identity, your own target
- You want fast iteration without profile management
- You're prototyping
Use a multi-account browser when:
- An agency needs separate, persistent profiles per client account, with permission
- Several teammates share profiles instead of passing credentials around
- QA needs repeatable browser identities across timezones and platforms
- You want a local REST API to drive profiles from your own code
The tradeoff is real. You get a separate browser process per profile, REST orchestration instead of puppeteer.launch(), and per-profile proxy configuration. More moving parts, more to debug at 2am.
Here's what orchestration looks like. The API is local (127.0.0.1:36542, port configurable) and takes a Bearer token from the app's AI page:
import requests
from playwright.sync_api import sync_playwright
BASE = "http://127.0.0.1:36542/api/v1"
HEADERS = {"Authorization": "Bearer <token-from-the-app>"}
# Starts headless unless you say otherwise
r = requests.post(f"{BASE}/profiles/<id>/start",
json={"headless": False}, headers=HEADERS)
cdp_url = r.json()["data"]["cdp_url"]
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp(cdp_url)
ctx = browser.contexts[0] # reuse the profile's own context
page = ctx.pages[0] if ctx.pages else ctx.new_page()
page.goto("https://example.com")
requests.post(f"{BASE}/profiles/<id>/stop", headers=HEADERS)
Two details that bite people: browser.close() only disconnects, so call /stop to end the session, and don't call new_context(), because that opens an incognito context outside the profile. The full list is in the local API docs. For the longer walkthrough, see the Playwright/Puppeteer integration guide.
JustBrowser runs on Windows x64 and macOS (Apple Silicon) only. There is no Linux build, so don't plan a headless-server deployment around it.
For measuring your own site's traffic without fingerprinting your visitors, JustAnalytics is a cookieless option. A different job, but the same privacy logic.
Frequently Asked Questions
What can a JavaScript automation plugin change?
Only what runs inside the page after the browser has launched: property getters on navigator, a few objects on window, and similar surface values. Anything decided earlier (the TLS handshake, HTTP headers) or in another context (Web Workers) is out of its reach.
Why can't a plugin change a TLS fingerprint?
TLS fingerprints such as JA3 and JA4 come from the handshake: cipher suites, extensions and their order. That happens in the browser's compiled networking stack before any page script exists, so JavaScript can neither change nor observe it.
What's the difference between overriding a getter and a native engine change?
Overriding a getter injects JavaScript that intercepts a property read, which leaves a function in the page that can be inspected. A native change sits in the engine's C++ source before compilation, so the value comes from the engine itself and no injected function sits in the page.
When is a plugin good enough?
For internal tools, your own staging sites, QA runs and simple scripts, a plugin is usually plenty. Reach for a native multi-account browser when many profiles must stay consistent and separate for a team, not before.
Honestly? Most scripts don't need a dedicated browser. Know which kind of job you have before paying for heavier tools.
Try JustBrowser
A multi-account browser for teams, built on a custom Chromium engine with native C++ integration, not an extension. Real C++ engine patches at the canvas / WebGL / audio / font / screen layer, 40+ identity parameters per profile, and a local REST API with CDP for Playwright, Puppeteer and Selenium. $9.99/month or $99.99/year. 7-day free trial, card required — cancel any time in the seven days and you are not charged. Unlimited profiles, unlimited cloud sync, unlimited team seats.
Get started → · How it compares to Multilogin / GoLogin / AdsPower
Ready to manage multiple accounts?
Seven days free, then $9.99/month — one plan, everything included.