Playwright Init Scripts and Fingerprint Order
page.addInitScript and context.addInitScript have no defined evaluation order. See what that does to a patched fingerprint, and how to prove which write won.
BotBrowser Team
Prefer the maintained product doc?
This article has a matching page in the docs center. Use the docs for the canonical setup flow, current flags, and long-term reference.
Playwright's init scripts are the standard way to change a document's JavaScript environment before the page runs, and the API documentation states that the order of evaluation across browserContext.addInitScript() and page.addInitScript() is not defined. Every fingerprint-relevant value written by an init script therefore shares its property with any other write to the same name, and when two writes exist the winner is decided by an order the API does not promise. BotBrowser supplies those same document values from a profile inside the browser rather than from injected page scripts, which is a different delivery path, and it does not decide which of your init scripts ran last, so it cannot order two writers for you and does not promise a third-party site's verdict.
TL;DR
browserContext.addInitScript()andpage.addInitScript()both run after the document is created and before its own scripts run, and the Playwright API states the order between them is not defined.- Two init scripts that write the same property are a race, not a cascade. The API does not promise which write lands last, so a value that looks correct in one run has no guarantee behind it in the next.
- A profile-supplied value and an init-script patch reach the page through different paths: the profile is applied by the browser before document scripts run, while the patch is page JavaScript that runs at the start of the document lifecycle. An init-script write lands on top of the profile value, which is a predictable order rather than a race, so the two mechanisms are easier to reason about together than two init scripts are.
- Prove the outcome by reading the value, the property descriptor and
Function.prototype.toStringback inside the page, then recording which script you expected to win. - Keep one writer per property. That is the only ordering rule the API lets you enforce, because it removes the competition rather than resolving it.
Contents
- What the two init-script scopes promise
- Why two writers make a fingerprint unreliable
- Reading back what the document kept
- Where a profile differs from an injected script
- Practical conclusion
What the two init-script scopes promise
The two APIs differ in scope and agree on timing. browserContext.addInitScript() runs whenever a page is created in that context or navigated, and in any child frame that is attached or navigated. page.addInitScript() runs for that page and its child frames. Both are documented as running after the document is created and before any of its own scripts run, which is what makes them suitable for amending the JavaScript environment.
The documented limitation is the interesting part. The API reference for page.addInitScript states that the order of evaluation of multiple scripts installed through browserContext.addInitScript() and page.addInitScript() is not defined. That is a statement about ordering, not about whether the scripts run: both run, and neither is guaranteed to run before the other.
That distinction matters for any value a fingerprint surface reads. A single write is deterministic. Two writes to the same property, from different scopes, are a race whose result the API declines to specify. A test suite that adds a context-level script for a general environment and a page-level script for one case has created exactly that race without necessarily noticing.
const { chromium } = require('playwright-core');
(async () => {
const browser = await chromium.launch({
executablePath: 'path/to/botbrowser/chrome',
args: ['--bot-profile=/absolute/path/to/profile.enc'],
});
const context = await browser.newContext();
// Scope A: every page in this context.
await context.addInitScript(() => {
Object.defineProperty(Navigator.prototype, 'hardwareConcurrency', {
get: () => 8,
configurable: true,
});
});
const page = await context.newPage();
// Scope B: this page only, same property, no promised order against A.
await page.addInitScript(() => {
Object.defineProperty(Navigator.prototype, 'hardwareConcurrency', {
get: () => 16,
configurable: true,
});
});
// The init-script write is reported through the console channel, which Playwright
// forwards to the runner, so the value is observable after the page loads.
page.on('console', m => console.log('init-script says:', m.text()));
await page.addInitScript(() => {
console.log('cores:' + navigator.hardwareConcurrency);
});
await page.goto('https://app.example.test/health');
await browser.close();
})();
Whichever value arrives on the console channel is the whole problem. The reported number is 8 or 16 depending on which script the engine evaluated last, and nothing in the code above says which one is correct; on one Chromium build a repeated run may keep returning the same number, which is the outcome that hides the defect rather than the outcome that disproves it. The write goes to the browser console rather than to the document title on purpose: an init script runs before the document exists, so a title it sets is replaced by the page's own title element, while a console message survives. The example is deliberately small because a real suite reaches this state by accumulation: a base script adds one thing, a per-case script adds another, and nobody re-reads the pair afterwards.
Playwright also documents a second scope detail worth knowing before the race discussion: browserContext.addInitScript() covers every page created in the context and every navigated child frame, so a script installed once at that scope runs many times. Counting installations is therefore not counting executions, and the section below treats that difference as part of the same ordering problem rather than as a separate caveat.
A reader can confirm the documented behaviour without any fingerprint surface at all. Install one context script that appends a marker to a window array and one page script that appends a different marker, load a page that prints the array, and compare the recorded order with the order the code implies. The array records execution order, so a mismatch is the same defect a fingerprint assertion cannot see, and a match on one build is an observation about that build rather than a guarantee. That small experiment is also the fastest way to settle an internal argument about whether the context hook really precedes the page hook.
Why two writers make a fingerprint unreliable
A fingerprint surface is a value a page reads, so a fingerprint patch is a property write, and a property write has an owner. When two owners write the same property, three outcomes are possible and only the first is comfortable: the intended value wins, the other value wins, or the resulting descriptor is a hybrid that satisfies neither writer. The hybrid case is where the two writers disagree about the descriptor shape: a redefinition succeeds only when the new descriptor is compatible with the existing one, so an attempt that conflicts with a non-configurable property is rejected rather than quietly applied.
The practical consequence is that a fingerprint assertion can pass for the wrong reason. A test that checks navigator.hardwareConcurrency === 8 tells you the value it read, not which script set it, and not whether the other script ran at all. If the other script was supposed to introduce a value that a detector would notice as inconsistent, the assertion cannot see that failure either.
There is a second, quieter failure. Init scripts run per document and per frame, so the count of writes is not the count you wrote. A context script runs again for every navigation and every attached frame, which means the effective number of writes to one property grows with navigation count rather than staying at the number of addInitScript calls. A suite that reasons about "one patch" is reasoning about a number that is not the number of executions.
The honest rule follows from the API text rather than from a workaround: keep one writer per property, so Object.getOwnPropertyDescriptor(Navigator.prototype, prop) has exactly one definition behind it. Choose the scope that matches the lifetime you want, and do not add a second script that writes the same thing. Where a page-level override is genuinely needed, let the context script skip that property rather than fight it, because the API gives you no way to order the fight.
A second consequence follows from the same sentence. Because neither scope is promised to run first, a suite cannot rely on a page script to correct a context script that ran after it. The correction is one-directional in intent, and the API does not let an author express direction. Where a page-level value must differ from the context default, the only dependable design is for the context script to skip the property entirely, so the page script is the single writer rather than the winning writer.
That is also why a suite that passes today can fail after an unrelated change. Adding a navigation, adding a frame, or moving one script from page scope to context scope changes how many writers reach a given document and in what order they arrive, so the value can move even though the patch code is identical. A reader who has seen a suite go intermittent after a refactor has usually seen this and attributed the change to the refactor rather than to the ordering it disturbed.
Reading back what the document kept
The only reliable statement about a patched value is the one you read back from inside the page after it loaded. A readback that captures three things separates the cases: the value, the property descriptor and the function source.
// The record is emitted on the console channel, which reaches the runner without a second
// in-page evaluation and without being overwritten by the page's own document.
page.on('console', m => console.log(m.text()));
await page.addInitScript(() => {
const prop = 'hardwareConcurrency';
const descriptor = Object.getOwnPropertyDescriptor(Navigator.prototype, prop);
const viaGetter = typeof descriptor?.get === 'function' ? descriptor.get : null;
console.log(
JSON.stringify({
value: navigator[prop],
hasDescriptor: Boolean(descriptor),
descriptorKind: descriptor ? (descriptor.get ? 'getter' : 'data') : 'none',
getterSource: viaGetter ? Function.prototype.toString.call(viaGetter).slice(0, 120) : null,
})
);
});
The three fields answer three different questions. value answers what the page read. descriptorKind answers whether the property is a data property or an accessor, which is how you tell a native definition from a replaced one in many cases. getterSource answers whether the accessor is native code or a rewritten function, because a native getter stringifies to function () { [native code] } while a rewritten one carries its own body.
Record the intended winner next to the readback. A row that says "expected 8 from the context script" and a readback of 16 with a rewritten getter is an attributable observation. The same readback with no expectation attached is a number nobody can act on.
A useful test for whether a property has one owner is to search the suite for the property name rather than for the script file. Two hits mean two writers, whatever the files are called and whoever wrote them last. A single hit in a script plus a profile value for the same property is also two writers, and that pair is easier to miss because the profile never appears in the source search.
Keep the readback next to the assertion that depends on it. A run record that stores only the expected value cannot distinguish a passing patch from a passing profile, and those are different claims: one says a script in the document produced the value, the other says the browser did. When the two are recorded together, a later reader can tell which layer was responsible for a green run and which layer to inspect when the value moves.
Where a profile differs from an injected script
An init script and a browser profile both change what a page reads, but they are not the same mechanism and they do not fail the same way. An init script is JavaScript that runs in the document, so it can be read back from the document, it runs once per document and frame, and it competes with any other script for the same property. BotBrowser's profile configuration documentation describes and provides a profile as supplying document-level values such as screen and window dimensions, fonts, WebGL and WebGPU parameters, media devices and speech voices, which arrive from profile data rather than from a page script, and it does not order any init script you still install, and its profile-management documentation lists hardware signals such as device memory, CPU core count and GPU model among the values a profile carries. Those values come from the profile data rather than from a script the page can inspect and overwrite in the same way.
That difference is why the ordering rule matters more for a hybrid setup than for either mechanism alone: a profile value such as navigator.hardwareConcurrency is applied before document scripts run, so the only writer that can still change it is an init script. A team that moves one value from a page patch into a profile removes a writer, which is a real simplification. A team that adds a profile and keeps the old patch keeps two writers, but only one of them can still change the value: the profile is applied before document scripts run, so the init script is the single remaining writer and the ordering question has already been answered by the launch sequence.
The BrowserContext lifecycle and storage analysis covers the context scope and what survives it; the page-patch boundary analysis covers which surfaces a page patch cannot reach at all. This page covers the step before both: deciding which single layer owns each property, and proving that layer is the one the page read.
A third case sits between the two, because it can produce a hard error rather than a quiet winner. When a context script installs a non-configurable accessor on a prototype and a page script then tries to redefine the same name with an incompatible descriptor, the engine rejects the redefinition instead of leaving the earlier definition intact by accident. Whether the rejection surfaces as an uncaught page error or as a failure inside the init script depends on how the script is written, so a suite that only asserts on its own outcome can miss a document that never finished initialising.
Two habits keep the record trustworthy over time. Name the owner in the script comment rather than in a separate document, because the person who later adds a second writer reads the script and not the document. Re-read the value after a navigation rather than once per session, because init scripts run again on every navigation, and a context-level writer that looked settled on the first load is re-executed on the second.
For the scraping workflow that runs on top of this launch, the Playwright browser setup guide covers the executable and context wiring, and the Puppeteer comparison covers the same ordering question in the other driver. The profile management guide covers the lifecycle of the profile that supplies the values described above.
Nothing in this ordering discussion is specific to one product. Any automation library that offers both a context-scoped and a page-scoped pre-document hook inherits the same question, and the answer is the same one: the library can promise that both hooks run before the document's own scripts, and it cannot promise which of the two runs first. Treating that as a design constraint rather than as a defect is what makes the resulting record reproducible.
The same reasoning applies to a value that a detector compares against another value rather than against a constant. A patched core count and an unpatched device memory figure can each be plausible in isolation and implausible together, and which pair a page observes depends on which writers ran. That is a consistency question rather than a patching question, and it is the reason the single-writer rule is worth following even when no assertion currently compares the two fields.
FAQ
Does Playwright guarantee that my context init script runs before my page init script?
No. The API reference for page.addInitScript states that the order of evaluation of multiple scripts installed through browserContext.addInitScript() and page.addInitScript() is not defined. Both run before the document's own scripts, and neither is promised to run first.
Can I make the order deterministic by awaiting them in sequence?
Awaiting installs the scripts in the order you call them, but the ordering guarantee the API withholds is about evaluation in the document, not about installation. Awaiting two addInitScript calls makes the installation deterministic and leaves the documented evaluation order undefined.
Why does the same suite produce different fingerprint values between runs?
Because two scopes write the same property and the API does not define which write lands last. Look for a context-level script and a page-level script that touch the same name; removing one of them removes the ambiguity, which is a diagnosis as well as a fix. A stable value across runs does not prove there is no problem, only that this build happened to evaluate the scripts in the same order each time.
Does a profile remove this problem entirely?
It removes the page-script competition for the values the profile supplies, because those values no longer come from a script in the document. It does not order any init scripts you still install, and it does not guarantee how a third-party site evaluates what it reads.
Sources
- Playwright Page.addInitScript
- Playwright BrowserContext.addInitScript
- Playwright authentication and storage state
- BotBrowser profile configuration
- BotBrowser profile management
Practical conclusion
Read the ordering sentence in the API reference as a constraint rather than a footnote: two init-script scopes that write the same property produce a value no one has promised. Give every fingerprint property exactly one writer, choose the scope that matches the lifetime the property needs, and prove the result by reading the value, the descriptor and the getter source back inside the loaded page. Where a profile supplies a value, treat it as a different delivery path rather than as another patch, and do not keep both for the same property.
This article is part of the guide: Browser Automation Getting Started: A First Run Map
Related Articles
Browser Core vs JavaScript Overrides for Fingerprint Consistency
Getting StartedBrowser Automation Test Fixtures and Isolation
Getting StartedPlaywright BrowserContext Lifecycle and Storage State
Getting StartedBrowser Automation Getting Started: A First Run Map
Getting StartedScraping with Playwright: Browser and Profile Setup
Getting StartedBrowser Automation Network Mocking for Owned Tests
Run the same check in BotBrowser
Load a profile, keep the signals you just read about consistent inside the browser, and confirm the result on your own machine before a rollout.