JavaScript Engine Quirks: What V8 Internals Reveal About a Browser

13 min read

A browser can say one thing and run another, and the difference can come down to fifteen characters.

Picture a browser whose user agent says Firefox on Windows 11. Its canvas output is stable, its WebGL renderer string is plausible, and its audio hash is consistent. Then a script calls Function.prototype.toString on a built-in method.

V8 (Chrome's engine) returns function values() { [native code] }. SpiderMonkey (Firefox's engine) returns something different, with extra newlines and different spacing. If the browser claims Firefox but answers like Chrome, a test script can see that the claimed identity and the real engine disagree.

That is the fingerprinting layer most tutorials skip. Canvas and WebGL get all the attention. But underneath those visual signals, the JavaScript engine itself has tells: implementation quirks baked into V8, SpiderMonkey, and JavaScriptCore that browser extensions can't touch. Function.toString output. TypedArray constructor edge cases. Regex parsing behaviors. Error message wording.

If you run a site, test a product across browsers, or manage several multi-account browser profiles for a team, it helps to know what these signals are and how to check them.

See what your own browser reports first. There is a public viewer at justbrowser.app/fingerprint-checker: client-side, no signup. It compares WebRTC candidates against your public IP, and checks whether the IP's UTC offset agrees with your browser's timezone. Worth running before you read the rest of this, so the signals below have your own values attached to them.

What We're Building

By the end of this tutorial, you'll understand:

  1. How Function.prototype.toString exposes engine identity
  2. Why TypedArray constructors leak implementation details
  3. What regex parsing differences reveal about the real browser
  4. How to test your profiles for engine-level consistency
  5. Why consistency has to come from the engine itself, not from a script layered on top

This tutorial focuses on string formatting and constructor behaviors — things that happen when you inspect objects, not when you compute numbers. For how one large platform combines browser signals like these with account data, see the Facebook fingerprinting and Business Manager guide.

Prerequisites

  • A browser profile from a multi-account browser (JustBrowser, Multilogin, GoLogin, AdsPower — whatever you're testing)
  • DevTools access
  • Basic JavaScript familiarity (you'll be running console commands)
  • Access to real Chrome and real Firefox for comparison
  • 25 minutes

If you want context on standard fingerprint vectors first, the WebGL fingerprinting deep-dive covers GPU-based detection.

Step 1: Function.prototype.toString — The Fingerprint Nobody Talks About

Open your browser console and run this:

Array.prototype.values.toString()

In Chrome (V8), you get:

"function values() { [native code] }"

In Firefox (SpiderMonkey), you might get:

"function values() {\n    [native code]\n}"

Notice the newlines. The spacing. These aren't JavaScript-configurable. They're hardcoded in the engine's C++ source. V8's FunctionToString runtime implementation formats the output one way. SpiderMonkey's does it another way.

Extensions run JavaScript on top of the engine. They can't change how the engine formats this string; the most they can do is replace toString itself with a JavaScript function, and Step 2 shows why that replacement is visible.

Here's a more thorough test:

function functionToStringFingerprint() {
  const tests = [
    () => Array.prototype.values.toString(),
    () => Object.prototype.toString.toString(),
    () => Function.prototype.toString.toString(),
    () => (function named(){}).toString(),
    () => (async function(){}).toString(),
    () => (function*(){}).toString(),
    () => (async function*(){}).toString(),
    () => (() => {}).toString(),
    () => (async () => {}).toString()
  ];

  return tests.map(fn => {
    try {
      return fn().replace(/\s+/g, ' ').trim();
    } catch (e) {
      return 'ERROR: ' + e.message;
    }
  });
}

console.log(JSON.stringify(functionToStringFingerprint(), null, 2));

Run this on real Chrome. Run it on real Firefox. Run it in a profile whose user agent says Firefox.

If that profile returns Chrome-style formatting, there is a V8 engine underneath a Firefox user agent. That is an inconsistency anyone running this script can see. For teams running many profiles, it is a useful thing to know before you pick which browser a profile should report.

Step 2: User-Defined Functions as a Control, and Why Overrides Show

Now run the same check on functions you write yourself:

const arrowBasic = () => {};
const arrowBody = () => { return 1; };
const arrowExpression = x => x * 2;
const arrowAsync = async () => {};

console.log(arrowBasic.toString());
console.log(arrowBody.toString());
console.log(arrowExpression.toString());
console.log(arrowAsync.toString());

Since ES2019 the spec requires every engine to return the exact source text for functions defined in JavaScript, spacing and all, so these print identically in V8, SpiderMonkey and JavaScriptCore. That makes them a useful control: if your own functions print the same everywhere but built-in methods don't, the difference you are seeing comes from the engine, not from your code.

A page script or extension can't change how the engine formats built-ins. It could try to override Function.prototype.toString, but:

Function.prototype.toString = function() {
  return 'spoofed';
};

...is itself visible. Test scripts check whether Function.prototype.toString has been modified by examining its own toString() output and property descriptors. Native functions have specific characteristics that wrapped functions don't.

Fingerprinting scripts literally call Function.prototype.toString.toString() to verify the method hasn't been modified.

Step 3: TypedArray Constructor Edge Cases

TypedArray constructors reveal engine identity through error handling. Try these:

function typedArrayFingerprint() {
  const results = [];

  // Test 1: Invalid length
  try {
    new Uint8Array(-1);
  } catch (e) {
    results.push({ test: 'negative_length', error: e.message, type: e.name });
  }

  // Test 2: NaN length
  try {
    new Uint8Array(NaN);
    results.push({ test: 'nan_length', error: 'no_error', result: 'created' });
  } catch (e) {
    results.push({ test: 'nan_length', error: e.message, type: e.name });
  }

  // Test 3: Very large length
  try {
    new Uint8Array(Number.MAX_SAFE_INTEGER);
  } catch (e) {
    results.push({ test: 'huge_length', error: e.message, type: e.name });
  }

  // Test 4: Float length
  const floatArray = new Uint8Array(3.7);
  results.push({ test: 'float_length', length: floatArray.length });

  // Test 5: Object coercion
  try {
    new Uint8Array({ length: 5, 0: 1, 1: 2 });
    results.push({ test: 'array_like', error: 'no_error' });
  } catch (e) {
    results.push({ test: 'array_like', error: e.message });
  }

  return results;
}

console.log(JSON.stringify(typedArrayFingerprint(), null, 2));

The error messages vary by engine:

  • V8: "Invalid typed array length"
  • SpiderMonkey: a RangeError worded differently, in lower case
  • JavaScriptCore: different wording again

Error message strings are hardcoded in engine source. They're not localizable. They're not JavaScript-accessible. They come from C++ throw statements that execute before any JavaScript error handling.

An extension can catch the error after it's thrown and rethrow a different one. But a test script can look at the stack trace and message, and JavaScript-wrapped error translation adds measurable latency compared with a native throw.

Step 4: Regex Engine Differences

Regular expression parsing reveals engine internals. Different engines support different regex features and handle edge cases differently:

function regexFingerprint() {
  const tests = [];

  // Lookbehind (ES2018 — not all engines identical)
  try {
    const re = new RegExp('(?<=a)b');
    tests.push({ feature: 'lookbehind', supported: true, result: 'ab'.match(re)?.[0] });
  } catch (e) {
    tests.push({ feature: 'lookbehind', supported: false, error: e.message });
  }

  // Unicode property escapes
  try {
    const re = new RegExp('\\p{Script=Greek}', 'u');
    tests.push({ feature: 'unicode_property', supported: true });
  } catch (e) {
    tests.push({ feature: 'unicode_property', supported: false });
  }

  // Named capture groups
  try {
    const re = /(?<year>\d{4})/;
    const match = '2026'.match(re);
    tests.push({ feature: 'named_groups', groups: match?.groups });
  } catch (e) {
    tests.push({ feature: 'named_groups', supported: false });
  }

  // Invalid regex error messages
  try {
    new RegExp('[');
  } catch (e) {
    tests.push({ feature: 'invalid_regex_error', message: e.message });
  }

  // Very long pattern handling
  try {
    new RegExp('a'.repeat(65536));
    tests.push({ feature: 'long_pattern', supported: true });
  } catch (e) {
    tests.push({ feature: 'long_pattern', error: e.message });
  }

  return tests;
}

console.log(JSON.stringify(regexFingerprint(), null, 2));

The invalid_regex_error message is particularly telling. V8 says something like "Invalid regular expression: /[/: Unterminated character class". SpiderMonkey reports the same error in its own lower-case wording, without echoing the pattern. The exact wording fingerprints the engine.

For traffic quality monitoring, these deep signals help a site owner tell which engine is really behind a visit. And if you manage browser profiles, they tell you what a site can learn about the engine you run.

Step 5: Building a Full Engine Fingerprint

Here's a combined test that generates an engine signature:

async function engineSignature() {
  const sig = {};

  // Function toString format
  sig.funcFormat = Array.prototype.values.toString()
    .includes('\n') ? 'multiline' : 'compact';

  // Arrow function format
  sig.arrowFormat = (() => {}).toString();

  // Error message style
  try {
    new Uint8Array(-1);
  } catch (e) {
    sig.errorStyle = e.message.includes('Invalid') ? 'v8' :
                     e.message.includes('invalid') ? 'spidermonkey' : 'other';
  }

  // Regex error style
  try {
    new RegExp('[');
  } catch (e) {
    sig.regexError = e.message.substring(0, 30);
  }

  // Claimed browser
  sig.claimed = navigator.userAgent.includes('Firefox') ? 'Firefox' :
                navigator.userAgent.includes('Chrome') ? 'Chrome' : 'Other';

  // Consistency check
  sig.consistent = (
    (sig.claimed === 'Chrome' && sig.errorStyle === 'v8') ||
    (sig.claimed === 'Firefox' && sig.errorStyle === 'spidermonkey') ||
    sig.claimed === 'Other'
  );

  return sig;
}

engineSignature().then(sig => {
  console.log(sig);
  console.log('Engine matches claim:', sig.consistent ? 'YES' : 'NO ⚠️');
});

Run this on your profiles. If consistent is false, the claimed browser and the real engine disagree, and any script can see it.

Common Errors and Fixes

Error: Profile claims Firefox but has V8 error messages

Cause: Your multi-account browser runs on Chromium's V8 engine under the hood, but the profile's user agent names a different browser.

Fix: Report each browser from its own engine: Chrome-based browsers from Chromium, Firefox from a real Firefox engine. Many multi-account browsers are Chromium forks, so a Firefox user agent on one of them runs over V8, and the mismatch is easy to see.

Error: Function.toString output doesn't match claimed browser

Cause: Native function formatting is determined at the engine level. Extensions can't change it without leaving evidence of the change.

Fix: Keep the reported browser and the real engine the same. In JustBrowser, Chrome profiles run its own custom Chromium engine with native C++ integration and produce Chromium engine output, and Firefox profiles run a real Firefox engine, so each produces the output of the browser it reports.

Error: TypedArray and regex tests agree but other signals still look inconsistent

Cause: Engine fingerprinting is one layer of many. Canvas, WebGL, TLS fingerprint (JA3/JA4), timezone and proxy location can each disagree with the others.

Fix: Layer your testing. Run engine signature tests (this tutorial), visual fingerprint tests, and TLS fingerprint checks. Track your own site's analytics through JustAnalytics to see how sessions from different browsers behave. A profile should be consistent across all layers, not just one.

Error: Extension-based tools show evidence of wrapped built-ins

Cause: Extensions modify Function.prototype.toString or other built-in methods. Test scripts check whether these methods have been wrapped by examining their own toString output and property descriptors.

Fix: Browsers with native engine integration don't wrap methods in page JavaScript; the identity parameters are set inside the engine. This is the architectural difference between extension-based tools and native engine forks. Worth being precise about who is which: Multilogin's Mimic and AdsPower's SunBrowser are custom Chromium builds, not extensions, so check how any tool you evaluate applies its settings rather than assuming.

In our view the extension-based approach is the weaker design, because it can only work above the engine. Rewriting an engine is expensive and extensions are cheap to build, which is why both still exist.

Next Steps

Now that you understand engine-level fingerprinting:

  1. Audit your profile templates — Run the engineSignature test on every template. Document which ones have mismatches.

  2. Report the browser you are running — If your multi-account browser is Chromium-based, report Chrome or Edge. Don't report Firefox unless you're running SpiderMonkey.

  3. Layer your testing — Engine fingerprints are one signal. Combine with visual fingerprint testing, TLS fingerprint checks, and proxy quality checks with ISP proxies for complete coverage.

  4. Re-run the tests after browser updates — These techniques come from academic research, and engines change between releases. A result from last month may not hold today.

For analytics on your own site, JustAnalytics shows which browsers your visitors use without cookies. And for click fraud protection on the traffic side, you can check what kind of browsers your incoming visitors are using.

A well-configured profile is one that doesn't contain contradictions. Engine fingerprints expose the deepest ones: where the reported browser differs from the JavaScript runtime actually underneath.

There is no JavaScript one-liner that fixes this, because the signals are produced by the engine before page code runs. Consistency has to be built into the engine.

Frequently Asked Questions

Why does Function.prototype.toString reveal the real JavaScript engine?

For functions written in JavaScript, the spec has required engines to return the exact source text since ES2019, so those print the same everywhere. Built-in functions are different: V8 returns function name() { [native code] } while SpiderMonkey puts [native code] on its own line, and JavaScriptCore has its own format. That string is generated inside the engine. A browser extension can only replace toString with a JavaScript function of its own, and the replacement is itself detectable.

Can TypedArray behavior really fingerprint a browser?

Yes. TypedArray constructors handle edge cases differently across engines. Passing NaN as a length argument, using very large values, or providing invalid ArrayBuffers triggers engine-specific error messages and behaviors. V8 throws 'Invalid typed array length' while SpiderMonkey words its RangeError differently. The exact wording and error types come from native engine code that extensions can't modify.

How do regex internals reveal the engine behind a user agent string?

Regular expression engines differ in subtle ways across browsers. V8, SpiderMonkey, and JavaScriptCore handle lookbehind assertions, Unicode property escapes, and edge cases like very long patterns differently. Error messages for invalid regex patterns vary by engine. A browser whose user agent says Firefox but whose regex errors read like Chrome has an inconsistency that a test script can see.

How do you keep engine signals consistent with the browser a profile reports?

Report the engine you actually run. A Chromium-based multi-account browser such as JustBrowser reports a Chromium-based browser, and its engine behaviour then matches that report. Extension-based tools run JavaScript on top of the existing engine, so they can't change how Function.toString generates strings or how TypedArray constructors throw errors. JustBrowser is a custom Chromium engine with native C++ integration, so identity parameters are set inside the engine rather than patched on top of it.


Try JustBrowser

A multi-account browser for teams, built on a custom Chromium engine with native C++ integration — not extension-based. Each profile keeps its own cookies, storage, proxy and 40+ identity parameters, and teammates can share profiles. Local REST API + CDP for Playwright, Puppeteer, 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.

Get started → · How it differs from Multilogin / GoLogin / AdsPower

Tags
  • v8-fingerprint
  • javascript-engine-fingerprinting
  • multi-account-browser
  • browser-detection
  • function-tostring
  • 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