If a Playwright task worked yesterday and fails today, the first thing you may check is the selector.
Then the wait condition.
Then the proxy.
Then the retry count.
Those checks are useful, but they can miss a more basic problem: the browser profile may no longer be the same runtime environment.
That is profile drift.
A task can fail before your script logic is wrong. It can fail because the profile, login state, browser version, locale, timezone, extension state, proxy mapping, or account ownership changed since the last successful run.
This post gives you a practical preflight pattern for catching that drift before the task touches the real page.
The failure pattern
Profile drift usually shows up as small inconsistencies:
- The task opens the right site, but the wrong account is signed in.
- The dashboard asks for login even though the profile was logged in yesterday.
- The same script passes locally but fails on another worker.
- The account uses a different proxy route than expected.
- A persistent context launches, but local storage is no longer valid.
- Headless mode fails while a visible run looks fine.
None of these automatically mean “Playwright is broken.”
They often mean the script is running inside a browser state that no longer matches the account environment the task expects.
What profile drift means
A browser profile is not just a folder.
For automation, it is part of the account runtime.
It may contain cookies, local storage, extensions, browser preferences, language, timezone, and other state that affects how the target page behaves.
Profile drift happens when that runtime changes without the task knowing.
Common causes include:
- the wrong
userDataDiris used - the login session expired
- cookies exist, but local storage changed
- a proxy mapping was edited
- timezone or locale no longer matches the account region
- the browser version changed after an update
- an extension was added, removed, or updated
- another worker opened the same profile
- a teammate copied the profile but not its metadata
- the task moved from a local machine to CI with different assumptions
This is why “the profile exists” is not enough.
The task needs to prove it is using the expected profile state.
Keep a small runtime manifest
A simple way to reduce drift is to keep a manifest for each profile.
It does not need to describe everything. It only needs to record the fields that matter before the task runs.
{
"profileId": "store-us-018",
"expectedAccount": "store_us_primary",
"userDataDir": "./profiles/store-us-018",
"proxyId": "proxy-us-dallas-01",
"expectedRegion": "US",
"timezone": "America/Chicago",
"locale": "en-US",
"browserChannel": "chrome",
"checkUrl": "https://your-app.example/dashboard",
"lastVerifiedAt": "2026-06-20T10:30:00Z",
"lastTaskId": "daily-status-check-431"
}
Replace checkUrl with a page that belongs to your own workflow.
The manifest includes proxy and region fields because many teams treat them as blocking checks, even though the short sample below only implements locale, timezone, and login-state checks.
This turns the profile into a contract.
The task is no longer saying:
“Open this profile and hope it is right.”
It is saying:
“Open this profile only if the runtime still matches the account environment I expect.”
Add a preflight before the real task
Here is a simplified Playwright preflight check.
It verifies locale, timezone, and login state before the task continues.
import { chromium } from "playwright";
import fs from "node:fs/promises";
const manifest = JSON.parse(
await fs.readFile("./profile-manifest.json", "utf8")
);
await fs.mkdir("evidence", { recursive: true });
async function blockRun(reason, details = {}) {
const report = {
reason,
details,
checkedAt: new Date().toISOString()
};
await fs.writeFile(
`evidence/${manifest.profileId}-preflight-failed.json`,
JSON.stringify(report, null, 2)
);
throw new Error(`Profile preflight failed: ${reason}`);
}
let context;
try {
context = await chromium.launchPersistentContext(
manifest.userDataDir,
{
channel: manifest.browserChannel,
locale: manifest.locale,
timezoneId: manifest.timezone,
headless: false
}
);
const page = context.pages()[0] ?? await context.newPage();
const runtime = await page.evaluate(() => ({
language: navigator.language,
languages: navigator.languages,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
userAgent: navigator.userAgent
}));
if (runtime.language !== manifest.locale) {
await blockRun("locale drift", {
expected: manifest.locale,
actual: runtime.language
});
}
if (runtime.timezone !== manifest.timezone) {
await blockRun("timezone drift", {
expected: manifest.timezone,
actual: runtime.timezone
});
}
await page.goto(manifest.checkUrl, {
waitUntil: "domcontentloaded"
});
const loginPromptVisible = await page
.getByText(/log in|sign in/i)
.first()
.isVisible()
.catch(() => false);
if (loginPromptVisible) {
await page.screenshot({
path: `evidence/${manifest.profileId}-login-required.png`,
fullPage: true
});
await blockRun("missing or expired login state", {
profileId: manifest.profileId,
url: page.url()
});
}
} finally {
await context?.close();
}
This is not a full production system.
It is a decision pattern.
Before the task performs account-sensitive actions, it checks whether the runtime still matches the expected profile state.
This sample only checks locale, timezone, and login state. If proxy route, browser version, profile ownership, or worker locking matters for your workflow, add those checks to the same preflight layer instead of treating them as separate debugging steps.
Also, the login detector in this example is intentionally simple. In production, use a stable login marker, route pattern, response signal, or test id instead of a broad text match like /log in|sign in/i.
What should stop the run
Some drift signals should block the task immediately.
For example:
- wrong profile path
- wrong account
- missing login state
- unexpected login page
- wrong proxy group
- unexpected region
- timezone or locale mismatch
- browser version mismatch after a release
- profile already used by another worker
- security checkpoint or MFA page
These are not good retry candidates.
Retrying inside the wrong environment can make the failure harder to diagnose.
What should only create a warning
Not every difference should stop execution.
Some signals should create a warning and save evidence, but still allow the task to continue.
Examples:
- the profile has not been verified recently
- viewport changed, but the task is not layout-sensitive
- a non-critical extension updated
- page copy changed, but stable locators still work
- the task starts from a different URL but reaches the expected page
- a temporary banner appears before the main workflow
Blocking everything creates noise.
Ignoring everything creates silent failures.
The useful middle ground is a run, warn, or stop decision.
Save evidence when drift is detected
When profile drift is detected, save enough information for a teammate to understand the failure later.
At minimum, capture:
- profile ID
- expected account
- current URL
- screenshot
- task ID
- worker ID
- expected proxy ID
- expected region
- runtime locale
- runtime timezone
- browser version
- drift summary
- last successful task ID
The goal is not only to fail safely.
The goal is to make the failure reviewable.
A useful drift report should answer three questions:
- What did the task expect?
- What did it actually see?
- Why did it stop?
Why retries are the wrong first fix
Retries help with temporary failures.
A timeout can be retried.
A temporary 500 response can be retried.
A flaky selector can be retried while you collect evidence.
Profile drift is different.
If the task is running in the wrong account context, retrying may repeat actions in the wrong profile, overwrite useful state, trigger extra verification, or hide the original cause.
A better sequence is:
- Preflight the profile.
- Stop on blocking drift.
- Save evidence.
- Repair or review the environment.
- Run the task only after the profile is verified again.
A team workflow note
For one local script, a JSON manifest and a few screenshots may be enough.
For a team, profile state usually needs to be easier to share and review. Profiles, proxies, session checks, screenshots, task logs, and review notes should not live in five disconnected places.
For a deeper account-context checklist, see this account context check for browser tasks.
Teams that manage long-lived browser profiles usually need a shared profile environment, not scattered folders, proxy notes, screenshots, and task logs.
In team environments, this is usually a workflow problem, not just a script problem.
The rule applies to any automation stack:
A browser task should prove its runtime before it acts.
Final checklist
Before a Playwright task uses a persistent browser profile, check:
- Is this the intended profile?
- Is the expected account signed in?
- Is the proxy mapping still correct?
- Do timezone and locale match the expected account region?
- Did the browser version change?
- Did extension state change?
- Is another worker using the same profile?
- Is the task starting from the expected page state?
- Can the task save screenshot and URL evidence if it stops?
- Is there a clear decision between run, warn, stop, and review?
Do not debug every Playwright failure as code first.
Sometimes the code is fine.
The runtime changed.
Top comments (0)