Playwright gives you parallel execution for free, right up until two tests reach for the same thing at the same time. One seeded admin account, one feature flag, one rate-limited sandbox API, and a suite that was green last week starts producing failures nobody can reproduce locally. The usual fix is a config change that slows down every test in the project in order to protect the handful that actually conflict. Playwright 1.63 adds a smaller instrument for the job: a test can declare a named lock, and only the tests sharing that name give up their parallelism. In this post I'll build a suite that genuinely races, walk through the isolation tricks that don't fit, and measure what locks buy once they are in place.
The suite I'll use throughout this post has twenty-six tests. Twenty of them load independent pages and assert on what rendered. They share nothing, they know nothing about each other, and they are the reason anyone turned on parallel execution in the first place. The other six touch two shared, mutable resources: a single account settings record, and a report queue. Those six live in separate spec files, written months apart by people who had no reason to suspect they were writing about the same row in the same table.
Under full parallelism, those six tests fail. Not always the same one, and not always the same number of them, which is the detail that makes this kind of problem so expensive. A test writes to the shared record, does a little more work, reads the record back, and finds somebody else's value sitting in it. The failure is real, the code under test is fine, and the test that reports the failure is frequently not the test that caused it.
The fix that ships in this situation is almost always the same one, and it takes a single line. It goes into the config, it turns the suite green, and it is completely reasonable as an afternoon decision:
export default defineConfig({
fullyParallel: true,
workers: 1, // the settings specs clobber each other otherwise
});
Note what that line actually does. It does not fix the conflict, it just guarantees that no two tests are ever in flight at once, so the conflict has no opportunity to occur. Every test in the project now pays for a collision between six of them. On my machine the suite goes from about fourteen seconds to twenty-seven, and the twenty tests that have nothing to do with the shared record account for most of the difference.
That trade gets worse over time, and it gets worse in a way that is hard to notice. The six contending tests stay roughly fixed, because there are only so many places a suite touches the seeded admin account. The independent tests are the ones that multiply, because that is what a growing suite is mostly made of. Every one of them added after that config change lands directly on the critical path. Eighteen months later somebody asks why the pipeline takes eleven minutes, and the answer is a comment nobody has read since the afternoon it was written.
A demo that only fails sometimes is worse than useless for a post like this, so the app under test is one I wrote specifically to lose the race every single run. It is a dependency-free Node server holding two records in process memory. One is called settings and stands in for the seeded admin account. The other is called queue and stands in for a report queue, a rate-limited sandbox API, or any second contended thing your suite has to line up behind. Having two of them matters later, when the interesting question stops being whether to lock and starts being what to lock.
Both records are written through the same handler, and that handler does the single most common thing a web application does with a form submission. It reads the current state, spends some time doing other work, and then writes the whole object back. The write is not a targeted update of one field, it is the entire record, reconstructed from a copy that was accurate when the request started:
const snapshot = { ...record }; // read
await sleep(120); // the window
Object.assign(record, snapshot, { // write it all back, stale copy and all
[body.field]: body.value,
owner: body.owner,
});
Those three steps are the whole bug. Between the read and the write there is a gap, and any write that lands inside that gap is silently discarded when the first request finally commits its stale snapshot. Last writer wins, and the loser is never told. I set the gap to a hundred and twenty milliseconds to make the demo deterministic, but there is nothing artificial about the shape of it. A slow ORM hydration, a cache round trip, a call out to a downstream service, or a SELECT followed by an UPDATE in two separate statements will all hand you the same window for free.
The important thing about this handler is that it is not the villain of the post. It is ordinary application code, and in production it is very likely fine, because production has one person updating their own settings at a time. It becomes a problem only when a test suite does something no real user does, which is to drive six concurrent sessions at the same record and then assert that each of them got back exactly what it asked for.
The rest of the app is deliberately boring. Twenty /widget/:id pages each render a title and three rows, and each one sleeps for six hundred milliseconds before responding. That delay is the only reason the numbers later in this post mean anything. If the independent tests were instant, serializing them would cost nothing, and every strategy in this post would look equally good.
Here is the entire spec file for one of the six contending tests. It is worth looking at closely, because the most important property of this file is how completely unremarkable it is. There is no comment warning you off, no fixture with a suspicious name, and nothing that hints the test cares what else is running:
test('update user settings', async ({ page }) => {
await exerciseSharedRecord(page, 'settings-spec', 'displayName');
});
Three of its siblings look the same with different arguments. profile.spec.ts renames a user, notifications.spec.ts flips a preference, billing.spec.ts changes an email address. Four different features, four different files, four different authors, and one row of data underneath all of them. You could review any one of these files and approve it without hesitation. The conflict does not exist inside any single file, which is exactly why nobody catches it in review.
The shared helper is where the actual test logic lives, and it does the most ordinary thing a UI test can do. It writes a value, spends a moment doing something else, reads the value back, and checks it got what it wrote. Each test signs its writes with its own name, so when the check fails it can say who trampled it:
await save(page, field, value);
await page.waitForTimeout(250);
const state = await readBack(page, field);
expect(state.owner, `round ${round}: another test wrote to ${resource}`).toBe(owner);
expect(state.value, `round ${round}: our value was clobbered`).toBe(value);
That waitForTimeout is standing in for whatever your real test does between writing and verifying. Asserting on a toast, waiting for a spinner, navigating to a confirmation page, hitting a second endpoint. It does not matter which, because all of them widen the same window. The longer a test holds a shared record between its write and its read, the more room it leaves for somebody else to walk through.
One detail in that snippet is deliberate and worth borrowing. The assertions run against a plain string that was read once, not against a locator. Playwright's web-first assertions retry for five seconds by default, which is the behaviour you want almost everywhere and precisely the behaviour you do not want here. A retrying assertion against a contended record will happily sit there waiting for the value to swing back around to yours, and then report a pass. That is not a flaky test being tolerated, it is a real race being actively concealed by the test framework. When you are writing a test whose entire job is to detect interference, take the snapshot and assert on the snapshot.
With all twenty-six tests running in parallel across six workers, the suite fails. The helper's custom assertion message does the work here, naming both the resource that moved and the test that moved it:
1) [naive] › tests\naive\profile.spec.ts:4:5 › rename userThe test that reports the failure is rename user. The test that caused it is toggle notification preferences, which is passing happily in another worker and will finish green. Without that owner field, this failure would read as a display name that mysteriously refused to save, and the investigation would start in the wrong file. Signing your writes costs one column and saves an afternoon.
Run it again and the count stays at four failures out of twenty-six, but the cast changes. Across three consecutive runs on my machine, change billing email, toggle notification preferences and export account report failed every time, while the fourth slot alternated between rename user and update user settings. A stable failure count with a rotating membership is the signature of this whole class of problem, and it is why the resulting bug reports are so unhelpful. Nobody is going to file "rename user is flaky" and be right about it.
There is a quieter lesson hiding in the passing column. The test that enqueues a report run touches a shared record too, and it passes on every single run. It is not safer than the others and it is not written better. It simply has fewer tests competing for its resource, so nothing happened to collide with it. A green result tells you that no collision occurred this time, which is a much weaker claim than the one people read into it.
One config line makes all of this visible, and it is the line most projects have set the other way:
// Deliberately zero. A retry would hide the race we are trying to show.
retries: 0,
A single retry turns this suite green. All four failures are races rather than defects, so on the second attempt the offending neighbour has usually finished and the record sits still long enough for the assertion to pass. The pipeline goes green, the retry count climbs quietly in the report, and the shared-state problem is now invisible until it grows large enough to survive a retry as well. This is the same trap as the retrying assertion from the previous section, one level up. Retries are a reasonable tool for genuine environmental flake, but they are also very good at hiding exactly the problem this post is about.
Before reaching for anything new, it is worth walking the tools that already exist, because one of them is genuinely the right answer and the others fail in instructive ways.
Start with the one that actually fixes the problem instead of managing it: give every test its own data. If each test can create its own account, run against it, and throw it away, there is no shared record, no contention, and nothing to serialize. If you can do this, do this, and you can stop reading. The reason this post exists is that plenty of suites cannot. The account may be provisioned by an identity provider you do not control, the sandbox may have a fixed number of seats, creating a user may take thirty seconds you are not willing to pay per test, or the resource may be singular by nature. There is only one tenant-wide feature flag, and no amount of fixture design will give you a second one.
Worker-scoped fixtures are the next instinct, and they solve a genuinely similar problem. Give each worker its own account, set it up once, and amortise the cost across every test that worker runs. This works beautifully when the resource can be duplicated. It does nothing here, because duplication is the exact thing that is unavailable. Six workers pointing at one row in one database is still six workers pointing at one row. A worker-scoped fixture would hand each of them a handle to the same record and change nothing about the collision.
Then there is serial mode, which looks like it was designed for precisely this and turns out to be scoped one level below where the problem lives:
// settings.spec.ts
test.describe.serial('account settings', () => {
// ...only orders the tests inside this one file
});
Our four contending tests are in four separate files, so ordering the tests within any one of them accomplishes nothing. Setting fullyParallel: false has the same shape of problem: it makes tests within a file run in sequence while the files themselves still run in parallel, which is the wrong axis entirely for a conflict that spans files. Serial mode also carries a second behaviour you probably do not want here, which is that a failure skips the remaining tests in the group. A race in the first test would take the other three with it and hide how widespread the problem is.
Which leaves the sledgehammer from the opening section. It works, it is one line, and it is why so many suites end up there. Nobody chooses workers: 1 because they think it is elegant. They choose it because it is the only one of these options that reliably stops the bleeding on a Friday afternoon.
Lining those failures up makes the missing shape fairly precise. It needs to serialize across files, across workers, and across projects, since a conflict does not respect any of those boundaries. It needs to be scoped to a named resource rather than to the whole run, so that tests touching unrelated things stay parallel. And it needs to be declared at the test that has the problem, not in a config file three directories away where the next person will not connect it to the four specs it exists to protect.
That is the shape Playwright 1.63 fills with test locks. A test declares a named lock in its options, and Playwright guarantees that no two tests holding the same name are ever in flight at once. Everything that does not share the name keeps running in parallel exactly as before. Here is the spec from earlier with the change applied, alongside one of its siblings from a different file:
// settings.spec.ts
test('update user settings', { lock: 'user-settings' }, async ({ page }) => {
await exerciseSharedRecord(page, 'settings-spec', 'displayName');
});
// profile.spec.ts
test('rename user', { lock: 'user-settings' }, async ({ page }) => {
await exerciseSharedRecord(page, 'profile-spec', 'displayName');
});
That is the entire change. One options object, added to each of the six contending tests. No fixtures were rewritten, no helper was touched, no config was edited, and the twenty independent tests do not know any of this happened. The suite goes green and stays green.
The important word in that snippet is 'user-settings', and it is important because it is a string you invent rather than something Playwright knows about. It has no relationship to a file, a describe block, a project, or a fixture. It is a label you have chosen for a resource in your system, and its only job is to match the label on every other test that touches the same resource. Two tests agreeing on that string is the whole mechanism.
That makes the naming a design decision rather than a formality, and it is worth spending a minute on. Name the lock after the thing being contended, not after the tests doing the contending. 'user-settings' is a good name because a developer adding a seventh test six months from now can look at what their test touches and know whether the label applies. 'settings-tests' is a bad name for the same reason it reads well today: it describes a group of files, so the next person has to go read those files to find out what the group is protecting. Names like 'critical' or 'group-a' are worse still, because they will eventually collect tests that have nothing in common and quietly become a second workers: 1 with extra steps.
The other thing this buys is that the constraint is now written down where the constraint applies. Somebody opening profile.spec.ts sees, on the same line as the test title, that this test coordinates with other tests over something called user settings. That is a considerably better experience than the previous arrangement, where the only trace of the conflict was a workers: 1 line in a config file that named none of the tests involved and explained none of the reasoning.
A claim like "locks made the suite faster" is easy to make and surprisingly easy to make badly. The obvious approach is to time the suite, add the lock options, and time it again, which produces two numbers measured minutes apart on a machine whose state has changed in between. Worse, once you have edited the specs, the original behaviour is gone. You cannot re-run the failure, you cannot show somebody else what it looked like, and if a reviewer asks whether the before number was fair, you have nothing to hand them.
So the demo keeps both versions permanently. The six contending specs exist twice, in a naive directory without locks and a locked directory with them, and the files are identical apart from the options object. Two projects then decide which copy runs, with the independent specs sitting outside both directories so they get pulled into either one:
projects: [
{ name: 'naive', testIgnore: '**/locked/**' },
{ name: 'locked', testIgnore: '**/naive/**' },
],
Now the before and after are a command-line flag apart. Both projects run the same twenty-six tests against the same application with the same worker count on the same machine, and the only variable in the entire comparison is whether six tests carry a lock option. Any difference in the result has exactly one place it could have come from.
This also makes the third mode easy to measure honestly. The sledgehammer is not a different set of tests, it is the naive project run with --workers=1, which means the comparison between locks and serialization is being made over identical test code rather than over two things that drifted apart while I was writing them.
The shape is worth borrowing whenever you are evaluating a change to how your suite runs, not just for a blog post. "It got faster" and "it got faster and still catches the bug" are different claims, and only the second one is interesting. Keeping the naive version runnable is what lets you check the second one, because a suite that no longer detects the race will happily report an excellent duration.
A total duration tells you the suite got faster. It does not tell you whether the lock did what you think it did, and those are not the same question. Two completely different schedules can produce the same number at the bottom of the run, and a lock that is accidentally serializing more than you intended will still look like an improvement next to the sledgehammer. To actually trust the mechanism, you want to see when each test ran.
Playwright hands that to you through the reporter API. Every reporter receives a callback as each test finishes, and the result object carries both the start time and the duration, which is everything you need to reconstruct the schedule after the fact:
onTestEnd(test: TestCase, result: TestResult) {
const start = result.startTime.getTime();
this.rows.push({ name: test.title, start, end: start + result.duration });
}
Collect those rows, work out the earliest start and the latest finish in onEnd, and each test becomes a bar on a shared scale. The whole reporter is about fifty lines and prints an ASCII chart to the terminal. Attaching it is where the second useful piece of 1.63 comes in, because passing a reporter on the command line has always replaced whatever the config specified, which meant choosing between your normal output and the new one. The --add-reporter flag appends instead:
npx playwright test --project=locked --add-reporter=./reporters/timeline.ts
Run the locked project with that attached and the schedule stops being something you infer from a total and becomes something you can look at:
Timeline (13.5s total, one cell ~376ms)Two things are immediately legible. The twenty widget tests are still stacked on top of each other at the left, finishing in overlapping clumps of six as workers free up, which is the proof that the lock did not leak into tests that never asked for it. Below them, the tests holding 'user-settings' form a staircase, each one starting only after the previous one has finished, exactly as promised and across four different files.
The staircase is also the honest cost of this approach, laid out in a way a duration total would let you ignore. Those tests are not fast, they are simply no longer wrong, and their combined length is now the tail of the run. Anything you can do to make an individual locked test shorter comes straight off the suite, which is a different optimisation target than the one you had before.
There is a third thing in that timeline, and it is the one that matters most. Look at where enqueue a report run sits. It does not wait its turn in the staircase. It starts at the same moment as change billing email and runs straight through alongside it, even though both tests are locked and both are writing to a shared record.
They overlap because they are not competing for the same thing, and they say so by holding different names. This is the entire argument for locks being labels you choose rather than a single global switch, and it is the reason the demo app has two resources instead of one:
// touches the queue only
test('enqueue a report run', { lock: 'report-queue' }, async ({ page }) => {
// touches the account, then the queue
test('export account report', { lock: ['user-settings', 'report-queue'] }, async ({ page }) => {
A test that needs more than one resource declares an array, and Playwright holds every name in it. That is what puts export account report where it lands in the timeline: it cannot start until the account is free, and it also cannot start until the queue is free. Declaring the whole set up front rather than acquiring one lock, running for a while, and then asking for a second is also what keeps this arrangement from tangling itself, since no test is ever sitting on half of what another test is waiting for.
The array form has a cost that is easy to miss and visible in the chart. export account report is the longest bar in the run, and it holds both locks for the whole of it. Its first half only touches the account, but the queue stays blocked throughout anyway, so every queue test in the suite waits on work that has nothing to do with the queue. Locks are held for the duration of the test, not for the portion of the test that actually needs them.
That gives you a rule worth applying deliberately. Declare the smallest set of names a test can honestly get away with, and when a test genuinely spans two resources, ask whether it wants to be two tests. Splitting export account report into an account half and a queue half would let each half hold one lock and release it sooner. Whether that is worth doing depends on whether the test is describing one user journey or two, which is a question about what you are testing rather than about parallelism. The thing to avoid is drifting toward a suite where most locked tests declare most of the names, because that is a global mutex again, reassembled one array at a time.
With the three modes running the same twenty-six tests over the same code, the comparison is finally worth making. Each of these is the median of three consecutive runs on a twelve-core machine that Playwright chose to drive with six workers:
| Mode | Duration | Result |
|---|---|---|
| fullyParallel, no locks | 6.5s | 4 failed, 22 passed |
| workers: 1 | 27.0s | 26 passed |
| fullyParallel, with locks | 14.3s | 26 passed |
The first row is there to be discounted rather than admired. It is the fastest number in the table because four tests gave up early, and a suite that finishes quickly by failing is not competing with the other two. It is in the table because leaving it out would let the remaining comparison look like a straight choice between slow and fast, when the actual choice is between slow, wrong, and neither.
Between the two green rows, locks cut the run from twenty-seven seconds to fourteen and a half. That is roughly half the time back for an options object on six tests, and the variance was small enough to be uninteresting: three runs of the serialized suite landed on twenty-seven seconds exactly, and three runs of the locked suite landed between fourteen point two and fourteen point five.
Twelve and a half seconds is not a number worth restructuring a test strategy over, and it is worth being honest that this suite is small. The reason to care is which direction it moves as the suite grows. The six contending tests are roughly a fixed population, because there are only so many places a suite touches the seeded admin account. The independent tests are the ones that multiply. Under workers: 1 every one of them is added to the critical path; under locks they are absorbed by whichever worker is free. The gap in that table is the narrowest it will ever be.
The demo runs all three modes back to back and prints the summary, so none of this has to be taken on trust:
npm run bench
The first lesson is the one the feature makes easiest to forget. A lock does not fix shared state, it makes shared state survivable, and those are different achievements. The suite in this post is green because six tests agreed to take turns, not because the read-modify-write window in the application went away. If the day ever comes when each test can seed its own account, the locks should come out. The risk with a tool this convenient is that it removes the pain that would otherwise have funded the real fix, and a lock declared as a stopgap looks identical in the source to a lock declared as an architecture.
The second lesson is that locks change what is worth optimising. Before, every test was on the critical path, so any test you made faster made the suite faster. Now the locked tests are the tail of the run and everything else disappears into whichever worker is free. Shaving two seconds off a widget test buys you nothing. Shaving two seconds off a locked test comes straight off the total. When you are looking at a slow run, the timeline from earlier is a faster way to find that out than any list of test durations sorted by time.
The third lesson cost me a few minutes and is worth passing on. A lock name is an ordinary string with no validation behind it, which means a typo is not an error. I misspelled 'user-settings' as 'user-settngs' in one of the six specs to see what would happen, and Playwright did exactly what it should: it created a lock named 'user-settngs' with one member, ran that test in parallel with everything else, and reported nothing unusual. The suite went from twenty-six passing to two failing, and it still finished in eleven point eight seconds, so the duration looked healthy the whole time. Lock names are effectively an interface between files that never import each other. Put them in a shared constant and let the compiler check them, rather than retyping the string in every spec and trusting your fingers.
The fourth lesson is the one from the failure output. The test that enqueued a report run passed every single time under full parallelism, not because it was written more carefully but because fewer tests were competing for its resource. If I had audited that suite by looking at which tests fail, I would have concluded it was safe and left it unlocked, and it would have started failing the week somebody added a second queue test. Work out which tests touch a shared resource by reading what they touch, not by watching which ones go red.
The last one is about restraint. Every lock you add makes the tail of your run longer, and a lock added defensively to a test that did not need one is indistinguishable from a lock that is carrying real weight. The array form makes this easier to do by accident, since adding a second name to be safe costs nothing at the moment you write it and blocks another group for the entire duration of that test forever after. Fewer names, held by fewer tests, for shorter tests, is the direction that keeps the mechanism worth having.
Most suites that reach for workers: 1 are not slow because they have too many tests. They are slow because a handful of tests share something, and the only instrument available was one that applied to everything. Test locks are a smaller instrument for the same job: name the resource, put the name on the tests that touch it, and let the rest of the suite carry on as it was. The change to the specs in this post was an options object on six of twenty-six tests, and it bought back half the run while keeping the race detectable.
What makes the feature worth adopting deliberately rather than casually is that it moves a constraint from a config file into the tests themselves, where the next person will actually read it. That only pays off if the names describe real resources and stay few. Get that part wrong and you will have rebuilt workers: 1 out of strings, with the added disadvantage that it is now spread across twenty files instead of sitting on one line.
The full demo suite, including the deliberately racy app, both versions of the contending specs, and the timeline reporter, is available on our GitHub repository. Until next time!