Originally published on anton.qa.
Last week, two tests shared one account and got in each other's way. The fix was to give each test its own data.
Some shared things can't be split up like that. One test restarts the test server. Another resets a setting for the whole app. Every test running at that moment feels it.
The usual fix is to run tests one at a time. That works, but the whole suite gets slower. Playwright 1.65 adds a smaller tool, the built-in -exclusive- lock:
test('restart the server', { lock: '-exclusive-' }, async () => {
// No other test runs while this one is running.
});
That snippet comes from the 1.65 release notes. The locked test starts only when no other test is running. No other test starts until it finishes. The rest still run in parallel with each other. The test-locks docs describe the details.
Below is a small demo you can copy. The results come from real runs on Playwright 1.65.0. The server and notes are made up, and everything stays on your machine.
A server that forgets on restart
The demo server keeps notes in memory, so a restart wipes them. For 1.5 seconds after a restart, it answers 503. That status means "busy, try again later."
// A tiny notes server. Data lives in memory, so a restart wipes it.
import http from 'node:http';
let notes = new Map();
let restartingUntil = 0;
http.createServer((req, res) => {
const url = new URL(req.url, 'http://localhost');
if (Date.now() < restartingUntil) {
res.writeHead(503).end('restarting');
return;
}
if (req.method === 'POST' && url.pathname === '/restart') {
notes = new Map();
restartingUntil = Date.now() + 1500;
res.writeHead(202).end('restarting');
return;
}
const id = url.pathname.slice('/notes/'.length);
if (req.method === 'PUT' && url.pathname.startsWith('/notes/')) {
notes.set(id, 'saved');
res.writeHead(201).end('saved');
return;
}
if (req.method === 'GET' && url.pathname.startsWith('/notes/')) {
res.writeHead(notes.has(id) ? 200 : 404).end(notes.get(id) ?? 'missing');
return;
}
res.writeHead(url.pathname === '/health' ? 200 : 404).end();
}).listen(3123);
Save it as server.mjs. Next come the tests. Six tests each save a note, wait one second, then read it back. The wait stands in for real page work. The seventh test restarts the server and waits until it answers again.
import { test, expect } from '@playwright/test';
function noteTest(name) {
test(`save note ${name}`, async ({ request }) => {
expect((await request.put(`/notes/${name}`)).status()).toBe(201);
await new Promise((resolve) => setTimeout(resolve, 1000));
expect((await request.get(`/notes/${name}`)).status()).toBe(200);
});
}
noteTest('a');
noteTest('b');
noteTest('c');
test('restart the server', { lock: '-exclusive-' }, async ({ request }) => {
expect((await request.post('/restart')).status()).toBe(202);
await expect.poll(async () => (await request.get('/health')).status(), {
timeout: 5000,
}).toBe(200);
});
noteTest('d');
noteTest('e');
noteTest('f');
Save it as tests/notes.spec.mjs. expect.poll keeps asking for the status until it gets 200, meaning OK. The tests use Playwright's request fixture, a built-in HTTP client. So this demo needs no browser.
The config runs three workers, meaning three test processes at once:
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
workers: 3,
retries: 0,
reporter: [['list'], ['json', { outputFile: process.env.REPORT || 'report.json' }]],
use: { baseURL: 'http://localhost:3123' },
webServer: {
command: 'node server.mjs',
url: 'http://localhost:3123/health',
reuseExistingServer: false,
},
});
Save it as playwright.config.mjs. fullyParallel: true lets each test run on its own, even inside one file. That setting matters for the lock, as you'll see below. webServer starts the notes server before the tests begin.
The JSON reporter saves each test's start time and duration. The timelines below come from those reports. Times are seconds from the first test's start.
Without the lock, three tests fail
First, the same file without { lock: '-exclusive-' }. Three runs ended the same way. Notes d, e and f failed, and 4 passed. In the first run, two failures looked like this:
Expected: 200
Received: 503
The third expected 201, the "saved" status, and also got 503. Depending on timing, a note's save or its read-back gets the 503. So your error lines may differ. Here's the first run:
| Test | Start | End | Result |
|---|---|---|---|
| save note a | 0.00 | 1.05 | Passed |
| save note b | 0.00 | 1.05 | Passed |
| save note c | 0.00 | 1.05 | Passed |
| restart the server | 1.08 | 2.96 | Passed |
| save note d | 1.08 | 2.09 | Failed |
| save note e | 1.08 | 2.09 | Failed |
| save note f | 2.36 | 2.39 | Failed |
The restart started next to notes d and e. In this run, it wiped their notes, then answered 503 when they read back. Note f started during the busy window, so its save got 503 right away.
Look at which test passed, though. The restart did its job. The failures showed up in three tests that did nothing wrong. In a real suite, that looks like random failures in the wrong place.
With the lock, the restart waits its turn
Put { lock: '-exclusive-' } back on the restart test. All three runs passed: 7 passed (4.4s).
| Test | Start | End | Result |
|---|---|---|---|
| save note a | 0.00 | 1.05 | Passed |
| save note b | 0.00 | 1.04 | Passed |
| save note c | 0.00 | 1.04 | Passed |
| save note d | 1.07 | 2.09 | Passed |
| save note e | 1.07 | 2.09 | Passed |
| save note f | 1.07 | 2.09 | Passed |
| restart the server | 2.10 | 3.99 | Passed |
The restart ran alone, from 2.10 to 3.99 seconds. No other test overlapped it.
It's the fourth test in the file, but it ran last. Each time a worker freed up, Playwright started another note test instead. The restart waited until nothing else was running.
One more run moved the restart to the top of the file. Then it ran first, alone, from 0.00 to 1.91 seconds. The six note tests followed in parallel.
So the lock decides "alone," not "when." The docs promise only that it starts when nothing else runs. If other tests need a fresh server first, the lock won't arrange that.
The lock needs tests that run on their own
The test-locks docs add one important note. In default and serial modes, a file's tests run in one worker, one by one. So a lock on any test is held for the whole file.
Without fullyParallel, Playwright's default gives each file to one worker. Two files show what that means. One file holds notes a and b plus the locked restart. A second file holds notes x and y.
| Setting | What happened | Run time |
|---|---|---|
| Default mode, no lock | The two files ran side by side | 4.4s |
| Default mode, with the lock | All five tests ran one after another, in one worker | 6.4s |
| Default mode, lock, file set to parallel | The notes ran first, then the restart alone | 4.4s |
In default mode, the lock made its whole file exclusive. Notes a and b also ran with nothing beside them. To lock only the one test, set fullyParallel: true in the config. Or add this line to that one file:
test.describe.configure({ mode: 'parallel' });
Serial mode is a different tool
test.describe.configure({ mode: 'serial' }) runs a file's tests in order, in one worker. If one fails, the tests after it are skipped. It's meant for tests that depend on each other. The Playwright docs don't recommend it.
Serial mode also doesn't pause other files. In one run, the restart sat in a serial file with no lock. Notes x and y, in another file, ran during the restart. Both received 503, so 2 failed and 2 passed.
Retries and shards
A retry is a second try after a failure. In a retry run with nine notes, the restart failed its first try on purpose. One retry was allowed. That first try ran alone, after all nine notes. The retry also ran alone, on a fresh worker. Playwright marked the test flaky, meaning it failed, then passed. A passing retry still deserves a look, as this post on retries explains.
Sharding splits a suite across machines, with flags like --shard=1/2. Each shard is its own Playwright run. The docs say locks work across files, workers and projects. Shards are separate runs, and in this test they didn't wait for each other.
Two shards started together on one machine, sharing one server. Shard 1 ran the locked restart first, alone within its own run. Shard 2 had three note tests. They ran at the same moment. All three received 503 and failed.
If your shards share a test server, the lock can't protect it. Give each shard its own server. Or run the restart in its own CI job, a separate build step.
Named locks, for a few tests
Playwright 1.63 added named locks. Tests that share a lock name never run at the same time. Everything else keeps running in parallel.
test('update user settings', { lock: 'user-settings' }, async ({ page }) => {
// never runs at the same time as other tests holding 'user-settings'
});
A test can hold several locks, like lock: ['database', 'external-api']. It runs only when all of them are free. Since 1.64, test.describe.configure({ lock: 'user-settings' }) adds a lock to a whole file or group. Locks work across files, workers and projects. A project is a named group of settings in your config.
I'd pick a named lock when only a few tests touch the shared thing. The docs suggest -exclusive- for tests that break if anything else runs. A server restart or a speed measurement fits that.
When I wouldn't use it
- The data can be separated. Last week's fix keeps each test independent, and nothing has to wait.
- Many tests need it. Nothing else runs during a locked test. In the demo, that meant 1.9 seconds with one test running.
- Shards or separate jobs share the resource. The lock only sees its own run.
Here's how I'd choose. For one or two tests that restart or reset things, I'd use the lock. For a bigger group with its own setup, I'd move them to a separate project. Project dependencies can make that project run after the main tests.
Try it
The demo ran on Node.js 24. Playwright 1.65 supports Node.js 22, 24 and 26. Make a folder with the three files from this article:
server.mjs
playwright.config.mjs
tests/notes.spec.mjs
Then install Playwright and run the tests:
npm init -y
npm install --save-exact @playwright/test@1.65.0
npx playwright test
With the lock, you should see 7 passed. Delete { lock: '-exclusive-' }, from the restart test and run again. You should see the three failures. Each run also writes report.json, with every test's start time and duration.
Exact seconds will differ on your machine. The demo's waits are set by hand so the overlap happens every time. It shows how a restart hits nearby tests. It doesn't measure how often that happens.
Lock the one test that changes things for everyone. Let the rest keep their speed.
I'm Anton. I build AI tools and measure how fast they really run. Former Apple engineer. I make ollamatps.com and write about building and measuring AI at anton.qa.
Top comments (1)
The shard boundary is easy to miss. Another option for CI is to give each shard a distinct database namespace and server port, then make teardown target only that namespace. That also prevents a failed job from wiping another shard’s fixtures.