Automation Plugins vs a Native Multi-Account Browser: 4 Layers JavaScript Can't Reach

9 min read

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:

SurfaceWhat the plugin does
navigator propertiesRedefines getters such as languages or plugins
window.chrome objectsAdds or reshapes objects that headless builds omit
Permissions APIOverrides query responses
WebGL vendor/renderer stringsOverrides the getter that returns them
navigator.userAgentRewrites the page-visible string
iframe contentWindowNormalises 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

Tags
  • multi-account-browser
  • browser-automation
  • tls-fingerprinting
  • client-hints
  • browser-fingerprinting
  • buildinpublic
  • saasstudio
  • aiworkforce
  • buildwithclaude

Ready to manage multiple accounts?

Seven days free, then $9.99/month — one plan, everything included.

We'd like to use Google Analytics, a Google service, to understand how our website is used. It sets two cookies in your browser and runs only if you click Accept. You can change your choice at any time with Cookie settings. Cookie Policy

Sign-in cookies and the cookie that remembers this choice are always on; the website needs them to work.

Google Analytics, a Google service, helps us understand how our website is used. It sets two cookies, _ga and _ga_TVZHQ99TZW. It is now onoff in this browser. If your browser sends a Global Privacy Control or Do Not Track signal, it stays off. Cookie Policy