The word “isolated” in Playwright testing is doing too much work.
Open any guide on isolated Playwright testing and you will read about two things: workers (parallelism) and per-test BrowserContext (each test() gets fresh cookies). Both are real, both are on by default since v1.10, and both stop short of the layers where most flaky tests actually live. The Chromium user-data-dir is one of those layers, and it is shared across an entire run unless you say otherwise. The network surface is another. This page maps the four layers, shows what Playwright handles for free, and walks through the one knob in Assrt's runner that flips the third layer with a single flag.
The four state layers, in scope order
Each layer is a different scope at which two tests can share state. The first two are addressed by Playwright's defaults. The third is what most teams miss. The fourth is its own discipline.
Process
Workers. Playwright spawns N OS processes; each owns a slice of the spec files. A SIGSEGV in one does not crash the others. Configured via workers in playwright.config.ts. On by default, scaled to CPU count.
Solves: process crashes, true parallelism, CPU saturation.
Browser context
Every test() call gets a fresh BrowserContext. Cookies, localStorage, sessionStorage, and IndexedDB written by one test are gone before the next one boots. On by default. The storageState fixture lets you preload auth at this layer.
Solves: per-test cookie and localStorage cross-talk.
Browser profile (user-data-dir)
The directory Chromium itself writes to. Service workers, the HTTP cache, the GPU shader cache, the safe-browsing database, certificate overrides, the DNS cache. Lives below BrowserContext. Persists across the entire run (and often across runs) unless you opt out.
Solves: PWA test pollution, HTTP cache leaks, service worker ghost responses.
Network
The staging API, the database, the third-party services. Two tests in two contexts in two workers still hit the same row in the same Postgres. Playwright gives you route(), fulfill(), and routeFromHAR() for this layer. None of them are on by default.
Solves: cross-test database races, third-party flakiness, slow auth providers in CI.
What Playwright handles for free, and where the docs stop
Workers and per-test contexts have been the default since v1.10. If you write test("...", async ({ page }) => {}) and never touch the config, you already get OS-process isolation between groups of tests and a fresh BrowserContext for every test inside a group. That is the half of the “isolated Playwright testing” story that almost every guide covers and most readers take away.
The thing the typical guide does not say: Layer 3 is not addressed by either of those. The BrowserContext is a logical partition inside one Chromium process. Underneath, the process is still using one user-data-dir. If a test installs a service worker, the registration goes into the user-data-dir. If a test caches a 200 response on a CDN URL, that goes into the HTTP cache in the user-data-dir. Both can survive a context tear-down and show up in the next test's context if the URL matches.
// playwright.config.ts — what 'isolated' means by default
import { defineConfig } from "@playwright/test";
export default defineConfig({
// Layer 1 (process): one OS process per worker.
// Crashes in one worker do not affect others.
workers: process.env.CI ? 4 : undefined,
use: {
// Layer 2 (context): every test() call gets a brand new
// BrowserContext. Cookies, localStorage, IndexedDB created
// by one test cannot be read by the next.
//
// Layer 3 (profile): NOT addressed here. Chromium writes
// service workers, the HTTP cache, the safe-browsing list,
// and the GPU shader cache to user-data-dir, which by
// default is shared across all contexts in the same launch.
//
// Layer 4 (network): NOT addressed here. The staging API
// is the same for every test in every worker. If two tests
// mutate the same row, order matters.
},
});The fix at Layer 3 is to either (a) launch a fresh user-data-dir per test (slow, because Chromium cold-starts each time), or (b) launch a fresh user-data-dir per run and accept that within one run Chromium shares state below the context level. Most teams pick (b) without realising they picked it. Assrt's --isolated flag is option (b) wired up explicitly: an in-memory user-data-dir per run, no persistence, fresh cache.
One flag, three modes, the exact line of code that flips them
The whole profile-layer decision in Assrt happens in one branch of one function. It is short enough to read in full. The branch is inside launchLocal() in src/core/browser.ts (lines 296 to 347 in the open-source repo). The three log lines you see on stderr are the three modes:
// node_modules/@m13v/assrt/dist/core/browser.ts
// (matches src/core/browser.ts:296-347 in the open-source repo)
const args = [
cliPath,
"--viewport-size", "1600x900",
"--output-mode", "file",
"--output-dir", outputDir,
"--caps", "devtools",
];
if (extension) {
// Mode 1: skip profile entirely; attach to your real Chrome
args.push("--extension");
console.error("[browser] launch mode: extension (connecting to existing Chrome)");
} else if (isolated) {
// Mode 2: in-memory user-data-dir; nothing survives the run
args.push("--isolated");
console.error("[browser] profile mode: isolated (in-memory, no persistence)");
} else {
// Mode 3 (default): shared profile at ~/.assrt/browser-profile
const userDataDir = join(homedir(), ".assrt", "browser-profile");
mkdirSync(userDataDir, { recursive: true });
await McpBrowserManager.killOrphanChromeProcesses(userDataDir);
// ... SingletonLock/SingletonSocket/SingletonCookie scrub ...
args.push("--user-data-dir", userDataDir);
console.error(`[browser] profile mode: persistent (${userDataDir})`);
}Three log lines, three Layer-3 stances. The default is persistent because that is what you want for a long-lived dev session that wants to keep your login. The --isolated flag (or ASSRT_ISOLATED=1 in the env, parsed at cli.ts:114 and server.ts:501) is the fast switch to a clean profile every time. The --extension mode is the explicit anti-isolation mode for when you need to drive your real Chrome.
“Setting ASSRT_ISOLATED=1 makes browser.ts:307-309 push --isolated to @playwright/mcp and log 'profile mode: isolated (in-memory, no persistence)'. The unset path uses ~/.assrt/browser-profile and inherits everything from the previous run.”
src/core/browser.ts:307-347, src/cli.ts:114, src/mcp/server.ts:501
The same command, three different runs
The plan file is the same. The URL is the same. The thing that changes is what state the browser carries into the run.
// What flipping the profile dial actually does // Persistent (default) — inherits ~/.assrt/browser-profile npx @m13v/assrt run --url http://localhost:3000 --plan-file login.md // stderr: [browser] profile mode: persistent (~/.assrt/browser-profile) // Effect: cookies, localStorage, service workers from previous // runs are still in the user-data-dir. // Isolated — fresh in-memory user-data-dir per run npx @m13v/assrt run --url http://localhost:3000 --plan-file login.md --isolated // stderr: [browser] profile mode: isolated (in-memory, no persistence) // Effect: clean Chromium, no carryover from previous runs. // Equivalent: ASSRT_ISOLATED=1 npx @m13v/assrt run ... // Extension — attach to your real Chrome (no isolation by design) npx @m13v/assrt run --url http://localhost:3000 --plan-file login.md --extension // stderr: [browser] launch mode: extension (connecting to existing Chrome) // Effect: drives the Chrome window already on your taskbar.
Why storageState alone is not Layer 3
The closest thing in vanilla Playwright to a profile dial is the storageState fixture. It is a real solution, but it lives at Layer 2: it dumps and restores cookies and localStorage. It does not touch the user-data-dir, which means service workers, HTTP cache, and IndexedDB state below the context can still leak.
The two ways to address auth state, at two different layers
// Layer-2 approach: persist cookies + localStorage
// only. user-data-dir is shared across the entire run.
// global-setup.ts
import { chromium } from "@playwright/test";
async function globalSetup() {
const browser = await chromium.launch();
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto("http://localhost:3000/login");
await page.fill("#email", "test@example.com");
await page.fill("#password", "secret");
await page.click("button[type=submit]");
await ctx.storageState({ path: "auth.json" });
await browser.close();
}
// playwright.config.ts
export default defineConfig({
globalSetup: require.resolve("./global-setup"),
use: { storageState: "auth.json" },
});
// Pro: tests start logged in.
// Con: service worker installed by test 4 is still
// registered when test 12 runs.They are not competing solutions. They are tools at different scopes. Use storageState when you want to skip the login form per test in a long suite. Use --isolated when you want a clean Chromium under the contexts. Use both when you have a long suite that touches a service worker.
What two concurrent runs against the persistent profile look like
One detail that surprises people: if two runs both try to launch against the persistent profile within the same second, only one wins. The other is automatically demoted to --isolated for that single run. No error, no thrown exception, no failed test; just a different log line on stderr. This is the catch block at browser.ts:336 firing.
Two assrt_test calls, 50ms apart, against ~/.assrt/browser-profile
The takeaway: if you genuinely run two test sessions in parallel, you do not actually have to opt into --isolated for the second one. The runner detects the collision and demotes itself. You will see one persistent run line and one isolated run line in the logs.
What the disk looks like in each mode
Toggle the panel to see the difference. In persistent mode the profile lives at a stable path and grows over time. In isolated mode it is a temp dir that disappears with the process.
~/.assrt at the end of one run
After the run ends, the profile directory still exists and contains everything Chromium wrote during the run. The next run inherits all of it. Cookies, localStorage, service workers, the HTTP cache, the safe-browsing database, certificate overrides — all carried over.
- ~/.assrt/browser-profile/Default/Cookies (sqlite, persists)
- ~/.assrt/browser-profile/Default/Local Storage/leveldb/ (persists)
- ~/.assrt/browser-profile/Default/Service Worker/ (registrations persist)
- ~/.assrt/browser-profile/Default/Cache/ (HTTP cache persists)
- ~/.assrt/browser-profile/SingletonLock (gone after clean exit)
Which dial for which test
A few opinions, in order of how often they come up.
- CI runs: isolated, always. There is no reason to share Chromium state between CI builds. Persistent state on a CI runner is either a security issue (auth cookies leaking between PRs on the same shared runner) or a flakiness source (a service worker registered by an earlier build still cached). Set
ASSRT_ISOLATED=1in the workflow env and forget about it. - Local dev runs against your own app: persistent. You log in once, the cookie is in the user-data-dir, every subsequent
assrt_testcall againstlocalhost:3000inherits it. This is the “sit on top of an already-set-up session” case the persistent default exists for. - Debugging a flake nobody can reproduce: try isolated first. If a test fails on CI but passes locally, the most common root cause is layer-3 state your local profile is silently providing. A single
--isolatedrun on your own machine reproduces the fresh-Chromium reality CI sees, often in seconds. - PWA, push, or service-worker tests: isolated. Service worker registrations live in the user-data-dir. If your test installs one, an isolated run guarantees a clean slate. A persistent run will silently inherit the previous registration and quietly invalidate your test's assumptions.
- MFA, anti-bot, or anything Cloudflare touches: extension. Both isolated and persistent modes look like a fresh Chromium to Cloudflare Turnstile, which is a fingerprint Turnstile dislikes. Run with
--extensionto drive your real Chrome and bypass the gate. Not an isolation mode; the explicit anti-isolation escape hatch.
Want a second opinion on which layer is causing your flake?
Bring a CI log, a local-passing-but-CI-failing repro, or a list of tests that flake every Tuesday. Fifteen minutes, real fix or a real next experiment to run.
Frequently asked questions
When people say 'isolated Playwright testing,' what are they actually referring to?
Almost always one of two things: parallel workers (Playwright spawns N processes that each run a slice of the spec files, so a worker crash does not take everything down) and per-test browser contexts (each `test()` block gets a fresh BrowserContext, so cookies, localStorage, and IndexedDB created by one test do not leak into the next). Both are real, both are useful, and both are the default behavior of `playwright test` since v1.10. The trouble is that the same word is also doing duty for at least two more layers people rarely name explicitly: the user-data-dir the underlying Chromium is using, and the network surface the test sees. A test can be context-isolated but profile-leaky, or context-isolated but network-shared, and the failures look like flakes.
What is profile-level state, and why is it not solved by a fresh browser context?
A BrowserContext is a Playwright abstraction. The user-data-dir is what Chromium actually writes to. They overlap a lot but they are not the same thing. Service workers, push subscriptions, IndexedDB transactions that Chromium chose to flush to disk, the HTTP cache, the GPU shader cache, the safe browsing list, the DNS cache, the cert error overrides — all of that lives in the user-data-dir, not in the BrowserContext. If you launch with `launchPersistentContext` (or run via Playwright MCP with no `--isolated` flag), the same user-data-dir is reused across the entire run and across runs, so two tests can share state through layers below the BrowserContext level. The classic symptom is a service worker installed by test 1 returning a cached response in test 7 and a different cached response in test 12 depending on which spec ran first.
Where does Assrt's --isolated flag fit on this map?
It is the knob for the profile layer specifically. When you run `npx @m13v/assrt run --url <url> --plan-file ... --isolated`, the runner pushes `--isolated` onto the underlying @playwright/mcp invocation. That maps to launchPersistentContext with an in-memory user-data-dir, so the entire user-data-dir vanishes when the run ends. No SingletonLock cleanup, no orphan-PID walk, no shared profile path. The trade-off is honest: every run starts logged out, every signup creates a new account, every OAuth dance happens from scratch. The default (no flag, persistent at `~/.assrt/browser-profile`) inherits cookies, localStorage, and most importantly any auth token the previous run captured, which is exactly what you want for a 30-test session that all assume a logged-in user.
Is Playwright's `test.use({ storageState: 'auth.json' })` the same thing as profile isolation?
Close but not the same. `storageState` writes a JSON file containing cookies and localStorage, then injects it into a fresh BrowserContext at test start. So you get reliable login state at the BrowserContext layer, which is great. What you do NOT get is anything below that: service worker registrations, the HTTP cache, IndexedDB databases, push subscription endpoints. For most teams that is plenty. For a PWA test, or anything that exercises a service worker, you can still see cross-test pollution because the service worker registration was installed into the user-data-dir, not into the storageState dump. That is the gap a profile-level dial fills. The right mental model: storageState is BrowserContext-scoped state, --isolated is user-data-dir scoped, --extension is real-Chrome-scoped (no isolation, intentionally).
What does network isolation look like in this picture, and how does Playwright handle it?
It is a separate axis. Even with workers, fresh contexts, and a clean profile, two tests still hit the same staging API and still race on the same backend rows. Playwright gives you `route()` for per-context request interception, `fulfill()` for canned responses, and HAR replay via `routeFromHAR()` if you want to lock down the network surface entirely. None of that is on by default. If your tests touch a database that is not reset between runs, the test order will eventually matter and the failure will look like flake. The three things that help, in order of investment: deterministic seed data per worker (using process.env.TEST_PARALLEL_INDEX), MSW or `route()` for the chatty endpoints you do not actually want to exercise, and HAR replay for the slow third-party calls (Stripe, Auth0, Algolia) that should not run on every CI build.
What is the actual log line I should look for to confirm I am running isolated?
Look at stderr from the runner. The decision happens at browser.ts:307-347 and prints exactly one of three lines: `[browser] launch mode: extension (connecting to existing Chrome)`, `[browser] profile mode: isolated (in-memory, no persistence)`, or `[browser] profile mode: persistent (/Users/<you>/.assrt/browser-profile)`. If you set `ASSRT_ISOLATED=1` and you are still seeing the persistent line, the env var is not in the runner's environment. If you set `--isolated` on the CLI, it goes through cli.ts:77-80 and into the agent's launchLocal() call. If you are calling it from the MCP tool, the parameter is `isolated: true` on the assrt_test schema at server.ts:354.
I want every CI run to be fully isolated and every local dev run to inherit my login. How?
Set `ASSRT_ISOLATED=1` in your CI environment (GitHub Actions: under `env:` at the workflow or job level). Locally, do nothing; the persistent default kicks in. Both the CLI (cli.ts:114) and the MCP server (server.ts:501) read the env var the same way, so the behavior is consistent across `npx @m13v/assrt run` and the Claude Code MCP path. If you want belt and suspenders, hard-code `--isolated` in your CI command line. The two are equivalent in effect; pick whichever your team will more reliably remember to keep in sync.
What happens if I run two `assrt_test` calls at the same time and both want the persistent profile?
The first one wins. The second one tries to remove the SingletonLock symlink Chromium writes when it boots, that fails because the first Chromium is still holding it, and the catch block at browser.ts:336 pushes `--isolated` onto the args and mutates the local `isolated` variable to true. The second call finishes its launch in in-memory mode and prints the isolated log line. Neither call throws. The cost is that the second call starts logged out for that one run; the next run after both finish goes back to inheriting the persistent state.
If --isolated is in-memory, how does Playwright still record video and write screenshots to disk?
The user-data-dir being in-memory does not mean Playwright cannot write to disk; it just means Chromium's profile is not persisted. The Playwright runner still writes its own artifacts to wherever the test is configured to put them. In Assrt that is `tmpdir()/assrt/<runId>/` for screenshots, video, and the event log (server.ts:430-432), and `~/.assrt/playwright-output/` for the snapshot YAMLs the agent reads (browser.ts:291). Both paths are stable across isolated and persistent mode; the only difference is what is in the user-data-dir.
Is there a case where I want NEITHER isolated nor persistent — just my real Chrome?
Yes, and Assrt has a third mode for it: `--extension`. That skips the profile layer entirely and connects @playwright/mcp to your already-running Chrome via the Playwright MCP browser extension. In that mode the agent drives your real Chrome with your real cookies, your real session, and your real Cloudflare Turnstile fingerprint. It is the inverse of isolated: maximum state leakage, maximum realism. It is wrong for CI and right for a quick `did this auth flow break?` debugging session against production where you do not want to re-do MFA in a clean profile.
More from the test-isolation rabbit hole
Adjacent guides
Playwright agent isolation, the OS-housekeeping layer
What happens at the kernel and filesystem layer when two AI agents both spawn Chromium against one host. SingletonLock, ps walks, orphan PIDs.
AI agent browser isolation
When the test driver is an AI agent, isolation is also about the agent's own context bleed. A different angle on the same question.
Playwright auto-retry assertions, what they fix and what they hide
Web-first assertions paper over a lot of layer-3 and layer-4 problems. Sometimes that is great; sometimes it hides a real bug.
How did this page land for you?
React to reveal totals
Comments (••)
Leave a comment to see what others are saying.Public and anonymous. No signup.