Opening a Playwright profile in real Chrome broke it permanently
We keep a set of persistent Playwright profiles — one per account — so our scheduled jobs don't have to log in every time. launchPersistentContext against a saved user-data-dir, cookies already there, done.
One morning every one of those jobs failed with a TimeoutError out of launchPersistentContext. The browser never got far enough to report anything else.
My first assumption was the obvious one: the sessions expired, we need to log in again. That assumption cost me the most time, because it was wrong and it's the kind of wrong that looks right — expired sessions are the normal failure mode for saved-login automation.
The thing that actually changed
Nothing about the accounts had changed. What had changed was earlier the same day: I had written a small status tool to check whether each saved session was still valid, and pointed it at every profile directory.
That tool launched with channel: 'chrome' — the real, installed Google Chrome. Chrome 152.
The scheduled jobs launched with Playwright's bundled Chromium. Version 149.
That is the whole bug.
What Chrome does to a profile it opens
A Chrome user-data-dir records the version that last wrote it. You can read it directly:
cat "<user-data-dir>/Last Version"
# 152.0.7360.xx
When a newer Chrome opens a profile, it migrates the on-disk state forward — preferences, the Local State file, the leveldb stores. Then it stamps the new version.
When an older build is then pointed at that same directory, Chrome refuses. It will not downgrade a profile, because it can't assume it understands data written by a newer schema. In our case that refusal didn't surface as a clean error message through Playwright — the launch just never completed, and Playwright timed out waiting for it.
So a read-only "let me just check the sessions" tool silently broke every production job that touched the same directories. Six profiles, all at once.
Is it reversible?
We could not find a safe way back. The profile data has already been migrated forward; pointing an older binary at it is the unsupported direction, and deleting the version stamp only removes the guard that exists precisely because the data underneath may no longer be readable by the older build.
The two honest options are:
- Move the binary forward — use the same newer Chrome everywhere.
- Throw the profile away and log in again, which is the thing we built persistent profiles to avoid.
Treat "I opened this profile with a different browser binary" as one-way.
The fix
Stop having two browser binaries in the same codebase. We made the real Chrome channel the default everywhere, with a fallback for machines that don't have Chrome installed, and an env override so a single job can be pinned if it ever needs to be:
const channel = process.env.X_BROWSER_CHANNEL
?? (hasRealChrome() ? 'chrome' : undefined); // undefined = bundled Chromium
const ctx = await chromium.launchPersistentContext(userDataDir, {
headless: true,
channel,
});
The important part isn't the code, it's the invariant: one user-data-dir is owned by exactly one browser binary. If a helper script wants to look at a profile, it launches the same way the production job does — or it doesn't touch the profile at all.
Worth knowing: Playwright's bundled Chromium version moves when you upgrade Playwright, and your system Chrome auto-updates on its own schedule. Those two clocks are not synchronised. If you mix them, the breakage doesn't arrive when you write the code — it arrives on whatever random morning the two versions drift apart.
Cheap guardrails
- Log the resolved channel and the browser version at the start of every automation run. If a job silently switches binaries, you want that in the log before the failure, not after.
- Before adding any new tool that touches an existing profile directory, check what binary the existing jobs use. This is a ten-second check that would have saved us the whole incident.
- Keep throwaway profiles for exploration. Never point an experiment at a production user-data-dir.
- When saved-login automation fails, don't jump to "the session expired". Dump the cookies and compare — ours were all still there, which was the clue that the failure was below the session layer entirely.