A Playwright test restarts the server. Lock just that test.

Published: · 8 min read

Playwright 1.65 adds a lock that runs one test alone. See a demo where a server restart breaks three tests, and how the lock fixes it.

One Playwright test restarts the server. Lock just that test. A timeline shows six tests running in parallel, then the restart running alone.

One Playwright test restarts the server. Lock just that test.

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.

Anton Gulin is the AI QA Architect, the first person to claim this title on LinkedIn. He builds AI-powered test automation systems where AI agents and human engineers collaborate on quality. Former Apple SDET (Apple.com / Apple Card pre-release testing). Find him at anton.qa or on LinkedIn.

playwright · testing · parallel-testing

Subscribe

Get notified when I publish something new, and unsubscribe at any time.

Related articles

Read all my blog posts