Electron already contains a Chromium. Our app bundles a second one.
That sounds absurd until you try to scrape with the first. Electron's Chromium is the one rendering your UI — it shares a process tree with your app, its automation surface is not what Playwright expects, and using it for background page loads means your scraping and your interface are competing for the same renderer. We drive a separate Playwright-managed Chromium instead, and the awkward part is getting that browser into a signed, packaged, cross-platform build.
Here is what we learned.
Playwright's browser lookup does not survive packaging
In development, Playwright finds its browsers in a global cache directory. In a packaged Electron app that directory does not exist on the user's machine, so Playwright falls back to auto-discovery and typically tries to launch a headless shell binary that was never shipped. The error you get is a path that does not exist, which tells you nothing about why it chose that path.
The fix is to stop letting it choose. Resolve the executable yourself and pass it in:
function getBrowserExecutable(): string {
const browsersRoot = process.env.PLAYWRIGHT_BROWSERS_PATH
?? path.join(__dirname, '..', 'playwright-browsers');
const isMac = process.platform === 'darwin';
const isWin = process.platform === 'win32';
if (isMac) {
const arch = process.arch === 'arm64' ? 'chrome-mac-arm64' : 'chrome-mac-x64';
return path.join(
browsersRoot,
'chromium-1228',
arch,
'Google Chrome for Testing.app',
'Contents', 'MacOS', 'Google Chrome for Testing'
);
} else if (isWin) {
return path.join(browsersRoot, 'chromium-1228', 'chrome-win64', 'chrome.exe');
}
// ...linux fallback
}
Three details that each cost time:
The build number is pinned in a path. chromium-1228 is the revision that Playwright 1.61.1 expects. It is a hardcoded string in a path, which means bumping Playwright silently breaks the packaged app while the dev build keeps working — the dev build has the new revision in its global cache. Pin the Playwright version exactly ("playwright": "1.61.1", no caret) and treat the revision as part of the upgrade.
macOS arm64 and x64 are different directory names. chrome-mac-arm64 versus chrome-mac-x64. If you build a universal binary, both have to be present and the choice has to be made at runtime from process.arch.
On macOS the executable is buried in a bundle. Not the .app, but Contents/MacOS/Google Chrome for Testing inside it. Pointing Playwright at the .app directory fails with a permissions-shaped error that sends you off investigating code signing for an hour.
The binary cannot live inside the asar
electron-builder packs your app into an app.asar archive. Code reading from it with Node's fs is fine — Electron patches fs to understand asar. Executing a binary from it is not: the OS loader has no idea what an asar is.
So the browser has to be unpacked, and it lands at:
<resources>/app.asar.unpacked/playwright-browsers/chromium-1228/...
which is exactly what the __dirname-relative path above resolves to at runtime, because __dirname is itself inside app.asar.unpacked. The same code works in dev (<repo>/app/playwright-browsers/...) without a branch.
The general rule for Electron: anything the OS executes, opens by path, or memory-maps must be unpacked. Anything you only readFile can stay packed.
Copy the browsers in as a build step
The browsers are not in node_modules in a shape you can ship, so there is an explicit prebuild step:
"prebuild":"pnpm clean && pnpm compile && pnpm renderer && pnpm copy:browsers","copy:browsers":"node scripts/copy-browsers.js",
Making this a real script rather than a files glob pointing at Playwright's cache matters: the cache may hold three revisions and two other browser families, and shipping WebKit and Firefox by accident is a few hundred megabytes of installer nobody noticed.
While you are in there, pin the user agent
Unrelated to packaging, but it bites the same day:
// Match the Chromium version bundled with Playwright 1.61
const USER_AGENT =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36';
The default UA of a Playwright Chromium advertises HeadlessChrome, which is the single cheapest bot signal a site can check. Overriding it is table stakes — but keep the version number in the string aligned with the browser you actually bundle, because a UA claiming Chrome 130 attached to a client that negotiates like Chrome 118 is a worse signal than the honest one.
Size, and what you owe the user
Two Chromiums is roughly 400MB of installer. That is a real cost and the honest thing is to be up front about it on the download page rather than in a surprise progress bar: notifio.app/download.
If you want to see what all of it is in service of before committing to the download, notifio.app has the live demos of what the bundled browser is actually doing in the background.