The Isolation Ladder
A1 diagnosed the disease: 233 specs, one mutable tenant. This is the treatment menu — seven rungs between "share everything" and "a universe per test", each with a price. By the end you will be able to name the rung a given resource sits on, and the rung above it.
~18 min · after A1: Naming the Flake · unlocks A3 and A4
1. Two ways to kill a race
A1 gave you the precondition for a concurrency bug: shared mutable state. Delete either word and the bug becomes structurally impossible. That gives exactly two families of fix:
Serialisation
Keep the sharing; forbid the interleaving. --workers=1, test.describe.serial, a lock, a lease.
Cost scales with usage: every additional test that touches the resource waits behind every other. Wall-clock cost is unbounded.
Isolation
Keep the concurrency; delete the sharing. Each actor gets its own copy of the thing.
Cost scales with setup: a fixed price per test or per worker, paid in parallel. Wall-clock cost is bounded.
Default to isolation. Serialisation is the right answer only when the resource is genuinely singular — one preview port, one external sandbox account, one row that the product itself defines as unique. Everything else, buy your way out with a copy. This lesson is the price list.
2. The ladder
Rungs go from cheapest and weakest to most expensive and strongest. Read the Still shared column carefully — that is what the rung does not buy you, and it is where the next flake will come from.
| # | Rung | What it is | Still shared | Cost |
|---|
You do not pick a rung for the suite. You pick a rung per resource, and you pick the lowest rung that removes the collision for that resource. A suite is a vector of rungs, not a number.
This is why "just isolate everything" is bad advice and why "just run --workers=1" is worse. Company name is a singleton and belongs on rung 2. A product you create belongs on rung 4. Neither answer generalises to the other.
Where cost actually lives
| Rung | Paid when | Multiplied by |
|---|---|---|
| 1 · unique naming | Never — it's a string change | — |
| 2 · snapshot / restore | Two API calls per test | Tests using the guard |
| 3 · slot partitioning | Once per run, at seed | — |
| 4 · per-test resource | Create + destroy per test | Every test |
| 5 · per-worker tenant | Provision + login per worker | Worker count |
| 6 · per-test tenant | Provision + login per test | Every test |
Note the shape: rungs 3 and 5 are cheap because they amortise over a worker. That is also precisely why they leave residue behind — see A4. Cheapness and leakage are the same property viewed from two sides.
3. Recall check
Close the ladder in your head and write the rungs from memory, in order, cheapest first. Then reveal. The gap is what to re-read.
0 share everything · 1 unique naming · 2 snapshot & restore · 3 slot partitioning by worker index · 4 per-test create & destroy · 5 per-worker tenant · 6 per-test tenant · (off the top: per-worker database)
If you produced the rungs but not the still shared column, you have fluency without understanding. The column is the whole point — each rung is defined by what it fails to fix.
4. Pick the rung
Each scenario names a resource and a symptom. Choose the lowest rung that removes the collision — not the strongest one available.
Over-climbing is a wrong answer here, same as under-climbing. Buying rung 6 for a problem rung 1 solves is how suites get slow.
5. Bug hunt: rung mismatches
Each of these attempts a rung and lands somewhere weaker than the author thinks. Click the line where the isolation actually leaks. One is clean.
All four are patterns present, or nearly present, in your suite.
6. Your suite, placed
Read from apps/frontend/e2e. Every one of these is a deliberate choice someone already made — the suite is not naive, it is unevenly isolated, which is harder to see and exactly why the flakes scatter.
| Resource | Rung | Evidence & residual risk |
|---|---|---|
| Tenant (company) | 0 | One DEFAULT_USER, one company, one storageState, eleven projects. "shared, JWT-bound company — there is no per-test company." This is the floor everything else is built on. |
| Departments, folders | 1 | buildDepartment and uploadFolderFixture both use crypto.randomUUID(). Correct and cheap. Residual: absolute counts over these collections still collide. |
| Company name | 2 | companyNameGuardFixture snapshots and restores in a finally. Correctly written. Residual: two workers inside the guard at once still race — rung 2 makes corruption transient, not impossible. |
| Seeded products | 3 | seededProductFixture partitions a 15-product pool by TEST_PARALLEL_INDEX. Real slot partitioning. Two residuals, both live: the slot is shared by every test in that worker, and products[index % products.length] depends on a list order the query never pins. |
| Asset folders | 4 | seededFolderFixture creates a UUID-named container per test and deletes it in finally. The strongest isolation in the suite. This is what good looks like. |
| Frame types | 0 | Run-scoped seed, read by all workers. Deliberately demoted from worker scope — the comment records that worker-scoped seeding grew a virtualised grid past its render window. Shared-but-read-only is a legitimate rung 0. |
Read the table as a diagnosis, not a scorecard. Rungs 1, 2 and 4 are correctly applied. The suite's flakiness is concentrated in the two rung-0 rows and the rung-3 row — and the rung-0 tenant row is upstream of everything, because any test that mutates a company-level singleton is racing all ten other projects regardless of how well its own fixtures behave.
Shared but immutable is not shared
The frame-types row is worth pausing on, because it is the exception that keeps rung 0 from being a slur. Immutability is isolation. A resource that every worker reads and none writes cannot produce a concurrency flake — the precondition requires mutable. Making shared data read-only is often cheaper than making it per-worker, and it is a legitimate destination, not a compromise.
The trap: read-only is a property of every test in the suite, not of the fixture. One spec that edits a frame type demotes the whole resource back to rung 0-mutable, silently, for everyone.
7. War game
Reason it through in writing before revealing. Committing to a wrong answer out loud is what builds the diagnostic; reading the answer does nothing.
Retain this
- Place one resource today. Open any spec in your suite, pick the resource it writes to, and say the rung out loud. Then say the rung above it and what it would cost. Two sentences. That is the whole skill.
- Fix the cheapest real one.
findSeededProductresolves a slot by position in an unsorted list. Pinning the sort is a one-line change that moves a rung-3 resource from "probably partitioned" to "actually partitioned." Do that before anything architectural. - Retrieval, one week out. Without opening this page: the seven rungs, and for each, the thing it leaves shared.
The Collision Surface Audit
The ladder tells you what to buy. A3 is the procedure for finding what you're standing on — including the surfaces that aren't in the database at all.
Fixture Scope Is a Sharing Decision
Rungs 3 and 5 are cheap because they amortise over a worker. A4 is about the lever that sets that — and the ways a fixture leaks state it looks like it owns.
Sources: Playwright · Parallelism · Luo et al., FSE 2014 · RESOURCES.md · STATUS.html · A1