Stack Depth Fingerprinting: How Recursion Limits Identify Browsers
How JavaScript stack depth and recursion limits vary by browser and platform to create fingerprints, and how to control stack behavior.
Introduction
Every JavaScript engine has a limit on how deeply functions can recurse. When a function calls itself too many times, the engine throws a "Maximum call stack size exceeded" error. This limit is not standardized. It varies between browsers, browser versions, operating systems, and even between the main thread and Web Workers on the same browser. Tracking systems exploit this variation by measuring exactly how many recursive calls are possible before the stack overflows. The resulting number, the maximum recursion depth, becomes a fingerprint signal. Because this value depends on low-level engine internals and platform-specific stack allocation, it is difficult to control from JavaScript. This article explains how stack depth fingerprinting works and how BotBrowser provides precise control through the --bot-stack-seed flag.
Privacy Impact
Stack depth fingerprinting is one of the more obscure fingerprinting techniques, but it provides useful entropy precisely because most privacy tools do not address it. Research from academic groups studying browser fingerprinting has shown that recursion limits can distinguish between browser versions, operating systems, and even 32-bit vs. 64-bit builds of the same browser.
The technique is effective because:
- It varies across browsers. Chrome, Firefox, and Safari each allocate different default stack sizes, producing different maximum recursion depths.
- It varies across platforms. The same browser version on Windows, macOS, and Linux may report different depths due to OS-level stack allocation differences.
- It varies across contexts. The main thread, dedicated workers, shared workers, and WASM modules each have different stack limits within the same browser.
- It is stable. On the same browser/OS/hardware combination, the recursion limit is consistent across sessions.
A study from the Brave browser team noted that stack depth provides approximately 2-3 bits of entropy. While this is modest, it is valuable in composite fingerprints because it correlates with browser engine internals that other signals do not capture. A mismatch between the reported user agent and the observed stack depth is a strong inconsistency signal.
Technical Background
How JavaScript Stack Depth Works
When a JavaScript function is called, the engine pushes a new frame onto the call stack. Each frame contains the function's local variables, parameters, and return address. The call stack occupies a fixed-size memory region. When the stack is full, the engine throws a RangeError.
The maximum depth depends on:
- Stack size allocation. The OS allocates a certain amount of memory for the stack. Linux defaults to 8 MB (configurable via
ulimit -s), macOS allocates 8 MB for the main thread and 512 KB for secondary threads, and Windows defaults to 1 MB. - Frame size. Each call frame has a different size depending on the number of local variables and the engine's internal bookkeeping. A function with no locals uses less stack space per frame than one with many locals.
- Engine optimizations. V8 (Chrome), SpiderMonkey (Firefox), and JavaScriptCore (Safari) each implement the call stack differently, with different header sizes, alignment requirements, and optimization passes.
- Execution context. The main thread typically has a larger stack than Web Workers. WASM modules may have their own stack configuration.
Measuring Stack Depth
A tracking script measures stack depth by counting recursive calls until an exception occurs:
function measureDepth() {
let depth = 0;
function recurse() {
depth++;
recurse();
}
try {
recurse();
} catch (e) {
return depth;
}
}
The result depends on the function's frame size. A minimal function (no locals, no arguments) produces a higher count than a function with many variables, but both values are stable for a given browser/OS combination.
Variation Across Contexts
Stack depth measurements differ between execution contexts:
- Main thread typically has the largest stack (often 8 MB or more).
- Dedicated Workers have smaller stacks (often 1 MB or 512 KB).
- WASM modules may have their own stack limits separate from JavaScript.
These differences are consistent within a browser/OS combination but vary across combinations. The ratio between main thread depth and worker depth is itself a distinguishing signal.
Why Existing Protections Fail
Stack depth is determined by the JavaScript engine's internal stack management. It cannot be modified from JavaScript because:
- The exception is thrown by the engine, not by JavaScript code.
- The stack size is allocated by the OS when the thread is created.
- Intercepting the RangeError does not change the depth at which it occurs.
- Browser extensions run in the same engine and have no access to stack configuration.
Common Protection Approaches and Their Limitations
There are essentially no JavaScript-level protections for stack depth fingerprinting. The value is determined by the engine and OS, and no extension or script can change it.
User agent spoofing changes the reported browser but does not change the actual engine behavior. Claiming to be Firefox while running in Chrome does not change the Chrome-specific stack depth, creating a detectable inconsistency.
Virtual machines may change the stack depth (due to different OS configurations) but introduce other fingerprinting signals.
Compiling custom browser builds with modified stack sizes is possible but impractical for most users. It also requires matching all other engine-level signals to maintain consistency.
Randomizing the measurement by catching exceptions early does not help. A tracking script can call the measurement function multiple times and take the maximum, or use different function shapes to triangulate the actual limit.
The only effective approach is to control the engine's actual stack behavior, which requires modification at the browser level.
BotBrowser's Engine-Level Approach
BotBrowser provides direct control over JavaScript stack depth behavior through the --bot-stack-seed flag.
Three Control Modes
The --bot-stack-seed flag accepts three types of values:
profile - Match the profile's exact stack depth. The recursion limit matches what the profiled device would produce. This is the most accurate option for maintaining a consistent device identity.
real - Use the native stack depth. No modification is applied. The recursion limit reflects your actual hardware and OS configuration.
Integer seed (1-UINT32_MAX) - Generate a deterministic per-session stack depth variation. Each seed produces a different but stable depth value. This is useful when you need distinct sessions that each have consistent stack depth but differ from each other.
Multi-Context Coverage
BotBrowser's stack depth control applies across all JavaScript execution contexts:
- Main thread recursion limits are controlled.
- Web Workers (dedicated and shared) have their stack depths controlled independently but consistently with the profile.
- WASM modules stack behavior is also controlled.
The ratio between main thread and worker depths matches what the profiled device would exhibit, ensuring that cross-context measurements produce consistent results.
Consistency with Other Signals
The controlled stack depth aligns with the profile's user agent, platform, and browser version. If the profile specifies Chrome 120 on Windows 10, the stack depth matches what Chrome 120 on Windows 10 actually produces. There is no mismatch between the user agent claim and the observable engine behavior.
Seed-Based Variation
When using an integer seed, the stack depth is derived deterministically from the seed value. The same seed always produces the same depth. Different seeds produce different depths within a realistic range for the profiled browser/OS combination. This allows you to create multiple distinct identities that each have a plausible stack depth.
Configuration and Usage
Profile-Matched Stack Depth
chrome --bot-profile="/path/to/profile.enc" \
--bot-stack-seed=profile \
--user-data-dir="$(mktemp -d)"
Seed-Based Variation
# Deterministic stack depth from seed
chrome --bot-profile="/path/to/profile.enc" \
--bot-stack-seed=42 \
--user-data-dir="$(mktemp -d)"
# Different depth with different seed
chrome --bot-profile="/path/to/profile.enc" \
--bot-stack-seed=43 \
--user-data-dir="$(mktemp -d)"
Native Stack Depth
chrome --bot-profile="/path/to/profile.enc" \
--bot-stack-seed=real \
--user-data-dir="$(mktemp -d)"
Combined with Other Deterministic Flags
chrome --bot-profile="/path/to/profile.enc" \
--bot-stack-seed=profile \
--bot-noise-seed=42 \
--bot-time-seed=42 \
--user-data-dir="$(mktemp -d)"
Playwright Integration
const { chromium } = require('playwright');
const browser = await chromium.launch({
executablePath: '/path/to/botbrowser/chrome',
args: [
'--bot-profile=/path/to/profile.enc',
'--bot-stack-seed=profile'
]
});
const page = await browser.newPage();
await page.goto('https://example.com');
// Measure stack depth
const depth = await page.evaluate(() => {
let d = 0;
function r() { d++; r(); }
try { r(); } catch(e) {}
return d;
});
console.log(`Stack depth: ${depth}`);
Puppeteer Integration
const puppeteer = require('puppeteer');
const browser = await puppeteer.launch({
executablePath: '/path/to/botbrowser/chrome',
defaultViewport: null,
args: [
'--bot-profile=/path/to/profile.enc',
'--bot-stack-seed=profile'
]
});
const page = await browser.newPage();
await page.goto('https://example.com');
Verification
Depth measurement. Run a recursive function in the browser console and record the depth. Compare it across sessions with the same profile and stack seed. The depth should be identical.
Worker depth measurement. Run the same recursion test in a Web Worker. The depth should be different from the main thread (workers have smaller stacks) but consistent across sessions.
Cross-machine verification. Run the same test on a different machine with the same profile and seed. The stack depth should match.
Seed variation. Change the seed and verify that the depth changes. This confirms the seed is actively controlling the behavior.
Best Practices
- Use
--bot-stack-seed=profilefor maximum accuracy. This matches the exact stack depth of the profiled device, ensuring consistency with the user agent and platform. - Combine with
--bot-noise-seedand--bot-time-seed. Stack depth, rendering noise, and timing behavior are all part of the overall browser fingerprint. Control all three for comprehensive protection. - Do not set unrealistic depth values. If using a seed, the generated depth falls within the realistic range for the profiled browser. Manual specification of extreme values could be inconsistent with other signals.
- Test in worker contexts. Stack depth varies between main thread and workers. Verify both.
- Understand the scope. Stack depth is one signal among many. It contributes to the overall fingerprint but is not sufficient on its own. Always use a complete profile.
FAQ
Q: How much entropy does stack depth provide? A: Approximately 2-3 bits on its own. The value distinguishes between browser families, platform types, and sometimes specific versions. Its value increases in composite fingerprints where other signals have been controlled.
Q: Does stack depth change with browser updates? A: Sometimes. Major V8 engine changes can alter the frame size or stack allocation, which changes the maximum recursion depth. This is one reason why profiles should be updated to match current browser versions.
Q: Can a website measure my stack depth without my knowledge? A: Yes. The measurement is a simple recursive function call that executes in milliseconds and produces no visible effect. No permissions are needed.
Q: Does the stack depth affect normal JavaScript execution? A: The controlled stack depth only applies to the measured limit. Normal web application code rarely approaches the recursion limit. Typical web applications use far less stack depth than the maximum.
Q: Is the WASM stack depth controlled separately?
A: Yes. WASM modules have their own stack configuration. BotBrowser's --bot-stack-seed flag controls WASM stack behavior alongside JavaScript stack behavior.
Q: What if I need to run deeply recursive algorithms?
A: The stack depth control sets the measured limit, not an artificial cap. Using --bot-stack-seed=real gives you the native stack depth if your application needs deep recursion.
Q: Does stack depth differ between headless and headed mode? A: In standard Chromium, the stack depth is the same regardless of display mode. BotBrowser maintains this consistency with its controlled values.
Summary
JavaScript stack depth is an obscure but effective fingerprint signal that varies between browsers, platforms, and execution contexts. Because it is determined by engine internals and OS-level stack allocation, it cannot be controlled from JavaScript. BotBrowser's --bot-stack-seed flag provides direct control over recursion limits at the engine level, with options to match a profile's exact depth, use the native depth, or generate deterministic variation from a seed. Combined with noise and timing seeds, stack depth control completes BotBrowser's comprehensive approach to deterministic browser behavior.
For related topics, see What is Browser Fingerprinting, Deterministic Browser Behavior, Navigator Property Protection, and Performance Timing Fingerprinting.