DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Shipping Playwright's Chromium inside a packaged Electron app

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
}
Enter fullscreen mode Exit fullscreen mode

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/...
Enter fullscreen mode Exit fullscreen mode

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",
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (2)

Collapse
 
raknaos profile image
Raknaos

The asar rule is the one worth keeping on a sticky note: anything the OS executes, opens by path, or memory-maps has to sit outside the archive, and Node's patched fs hides that constraint until the day you try to spawn something. The dev-build trap next to it is nastier because it is silent in the wrong direction — the global cache already holds the newer revision, so a version bump looks fine locally and only breaks once packaged.

Do you assert the revision anywhere in CI, e.g. resolve the browser path and fail if it does not exist in the unpacked tree? Otherwise the pin in package.json and the hardcoded chromium-1228 are two separate facts that can drift apart with no test noticing.

Collapse
 
daniel_pertu profile image
Daniel Pertu

Yes i just hardcode it. Might not be the best way to do it but it works for now. I do have a test which checks the versions before deploying though.