How to detect parallel test interference
Run the same suite at one worker and at four with npx vitest run --maxWorkers=N, and compare. Here four files that bind port 9731 pass serially and fail with EADDRINUSE at four workers. A failure that appears only above a worker count is interference, not a broken test.
Why check this
Run this when a suite is red on CI and green on a laptop, and before you raise the worker count to make the suite faster.
The failure it prevents is a test file that cannot run next to its neighbours. Four files each start a server on a fixed port. Serially every one works. Started at the same moment, three die before their first assertion, and the error names a port rather than the product.
Interference is only possible through something the workers share: the filesystem, TCP ports and any database file. Module state and globals are not, while isolation is on. Which side your resource falls on decides whether the failure you suspect can happen at all.
Prerequisites
- Node 22 and npm. Every figure came from Node 22.23.2, Vitest 5.0.0 and 8 logical cores on Windows 11, on 2026-09-12. Worker counts are not portable between machines.
- A scratch project in an empty directory outside your repository. See the Vitest CLI reference.
mkdir pardemo && cd pardemo
npm init -y > /dev/null
npm pkg set type=module
npm install -D vitest@5
mkdir -p test/api test/shared test/keyed tmp
- Four API files sharing one port. Copy
test/api/orders.test.jstoinvoices,webhooksandsearch, changing only the name insideit(...).
// test/api/orders.test.js
import { it, expect, beforeAll, afterAll } from 'vitest';
import { createServer } from 'node:http';
const PORT = 9731;
let server;
beforeAll(async () => {
server = createServer((req, res) => res.end('{"ok":true}'));
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(PORT, '127.0.0.1', resolve);
});
});
afterAll(() => new Promise((resolve) => server.close(resolve)));
it('orders api answers on /health', async () => {
const res = await fetch(`http://127.0.0.1:${PORT}/health`);
expect(res.status).toBe(200);
});
- A probe for what crosses a worker boundary. Two files for globals:
// test/shared/tenant-a.test.js
import { it, expect } from 'vitest';
it('sets the tenant on globalThis', () => {
globalThis.__tenant = 'acme';
expect(globalThis.__tenant).toBe('acme');
});
// test/shared/tenant-b.test.js
import { it, expect } from 'vitest';
it('sees no tenant left by another file', () => {
expect(globalThis.__tenant).toBeUndefined();
});
- Three files for the filesystem. Copy
counter-one.test.jstocounter-twoandcounter-three, changing only the name insideit(...).
// test/shared/counter-one.test.js
import { it, expect } from 'vitest';
import { readFileSync, writeFileSync } from 'node:fs';
const FILE = new URL('../../tmp/counter.json', import.meta.url);
it('counter-one increments the shared file', async () => {
const before = JSON.parse(readFileSync(FILE, 'utf8')).n;
await new Promise((r) => setTimeout(r, 80));
writeFileSync(FILE, JSON.stringify({ n: before + 1 }));
expect(JSON.parse(readFileSync(FILE, 'utf8')).n).toBe(before + 1);
});
- Every run of the shared probe starts from
echo '{"n":0}' > tmp/counter.json. Steps 4 and 6 assume that. - A fourth copy of the API files under
test/keyed/, with one line changed, for step 9.
const PORT = 9730 + Number(process.env.VITEST_WORKER_ID);
Steps
- Step 1.
Run the API files one at a time and get a baseline.
npx vitest run test/api --no-file-parallelismTest Files 4 passed (4) Tests 4 passed (4) Duration 849ms (import 43%, tests 39%, transform 11%, worker 6%) Isolate 4 workers spawned · ~122ms startup each (spawn + environment, per file)Four green. Serial still spawns one worker per file:
--no-file-parallelismorders them, it does not merge them. - Step 2.
Run the identical files at the default worker count.
npx vitest run test/api❯ test/api/orders.test.js (1 test | 1 skipped) 20ms ❯ test/api/webhooks.test.js (1 test | 1 skipped) 17ms ❯ test/api/invoices.test.js (1 test | 1 skipped) 17ms … FAIL test/api/invoices.test.js [ test/api/invoices.test.js ] FAIL test/api/orders.test.js [ test/api/orders.test.js ] FAIL test/api/webhooks.test.js [ test/api/webhooks.test.js ] Error: listen EADDRINUSE: address already in use 127.0.0.1:9731 … Test Files 3 failed | 1 passed (4) Tests 1 passed | 3 skipped (4)Three of four die in
beforeAll, so their tests are skipped, not failed. The survivor is whichever file bound first, and it changes between runs. - Step 3.
Halve the workers and find where the failure starts.
npx vitest run test/api --maxWorkers=2FAIL test/api/orders.test.js [ test/api/orders.test.js ] FAIL test/api/search.test.js [ test/api/search.test.js ] Error: listen EADDRINUSE: address already in use 127.0.0.1:9731 … Test Files 2 failed | 2 passed (4) Tests 2 passed | 2 skipped (4)Failed files over five runs each: 0 at one worker, then 2, 2, 2, 2, 1 at two workers, 2 every time at three, 3 every time at four. One worker is the only count that is clean.
- Step 4.
Switch to the sharing probe and run it serially first.
npx vitest run test/shared --maxWorkers=1Test Files 5 passed (5) Tests 5 passed (5) Duration 1.08s (tests 68%, import 19%, transform 8%, worker 5%)Green, including the file asserting no global crossed from its neighbour.
- Step 5.
Read the shared file the three counter tests wrote.
cat tmp/counter.json{"n":3}Three increments, three recorded. That is the correct answer.
- Step 6.
Reset the counter to zero and run the same five files at four workers.
npx vitest run test/shared --maxWorkers=4Test Files 5 passed (5) Tests 5 passed (5) Duration 387ms (tests 62%, import 19%, transform 13%, worker 5%)Green again, and faster. Nothing in this output is different from step 4.
- Step 7.
Read the counter after the parallel run.
cat tmp/counter.json{"n":1}One, not three. All three tests read 0, waited, and wrote 1. Every assertion held inside its own worker, and two writes were lost.
- Step 8.
Turn isolation off and rerun the probe serially.
npx vitest run test/shared --maxWorkers=1 --no-isolate❯ test/shared/tenant-b.test.js (1 test | 1 failed) 4ms × sees no tenant left by another file 4ms … FAIL test/shared/tenant-b.test.js > sees no tenant left by another file AssertionError: expected 'acme' to be undefined - Expected: undefined + Received: "acme" ❯ test/shared/tenant-b.test.js:4:31 … Test Files 1 failed | 4 passed (5) Tests 1 failed | 4 passed (5)--no-isolatereuses one worker across files, soglobalThisnow crosses. Whether a global is shared is a setting, not a property of the runner. - Step 9.
Give each worker its own port and run at four workers again.
npx vitest run test/keyed --maxWorkers=4 --reporter=verboseorders on port 9731 … webhooks on port 9732 … invoices on port 9733 … search on port 9734 Test Files 4 passed (4) Tests 4 passed (4)VITEST_WORKER_IDruns 1 tomaxWorkersand is reused as workers recycle, so it keys a port, a database file or a schema name.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| Green at 1 worker, red above | Two files contend for one resource | Key the resource by VITEST_WORKER_ID |
| EADDRINUSE in beforeAll | A fixed port in more than one file | Bind port 0 and read the assigned port, or key it |
| Tests skipped rather than failed | The hook died, so the file never ran | Read the failed suite, not the test count |
| Green suite, wrong value in a shared file | Lost updates the assertions cannot see | Assert the shared resource after the run, not inside it |
| Red only on the build machine | The machine has more cores | Set --maxWorkers in CI and reproduce with the same number |
| Red at every worker count including 1 | Order, not concurrency | Follow How to check test isolation |
Common mistakes
What to check next
- How to check test isolation: the same symptom, caused by order.
- How to reset mocks between tests: state inside one worker.
- How to check why jest did not exit after tests: the server a worker never closed.
- How to test API concurrency: the same question asked of the service.
- How to detect flaky tests: telling a rate from a worker count.
FAQ
How many workers does vitest use by default?
It scales to the machine. This run had 8 logical cores and failed 3 of 4 files at the default, the same as --maxWorkers=4. Pin the number in CI.
Is --no-file-parallelism the same as one worker?
No. It orders the files but still spawns one worker per file, as the Isolate 4 workers spawned line in step 1 shows. Both were green here.
What can two workers actually share?
Anything outside the process: files, TCP ports, a SQLite file, a database schema, an external service. Module state and globalThis stay private while --isolate is on.
How do I give each worker its own port?
Read process.env.VITEST_WORKER_ID, a number from 1 to the worker count, and add it to a base port. Step 9 bound 9731 to 9734 that way. Binding port 0 and reading server.address().port needs no id at all.
Should tests run serially in CI instead?
Serial hides the problem rather than removing it, and production reaches the resource concurrently anyway. Use one worker to confirm the diagnosis, then key the resource and put the workers back.
Verified
Verified by Maks Vernynode 22.23.2vitest 5.0.0
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate14 minpublished updated Maks Verny