Describe the bug
In vitest 4, maxConcurrency does not bound the number of concurrent tests that are mid-lifecycle (between beforeEach and afterEach) when the tests are split across sibling describe blocks. Setups run far ahead of teardowns, so resources created per-test in beforeEach (in our real suite: one Postgres database + app instance per test) accumulate far beyond maxConcurrency.
This is a follow-up to #10097, which was fixed by #10179 (v5) and backported in #10384 (v4.1.7) (thank you @hi-ogawa!). That fix restores the bound within a single suite: a flat describe.concurrent now behaves correctly (it did not in 4.1.6). However, the fix gives each task branch an independent limiter, so sibling concurrent suites each receive their own full maxConcurrency budget and the totals multiply. With 5 sibling groups and maxConcurrency: 5, up to 25 tests are mid-lifecycle simultaneously.
I've created a repro. Basically we have 20 tests in 5 sibling describe groups under describe.concurrent, with maxConcurrency: 5. Each test creates a fake db in beforeEach and drops it in afterEach. Every db operation costs more the more databases are live (mimicking how a real database degrades under load). An afterAll asserts the live-database high-water mark stayed at or below 6. This is a contrived example, so if anyone has a better approach lmk. The point though is that there's definitely a regression in V4 causing real world consequences.
Results:
| Version |
Flat (1 suite) |
Grouped (5 sibling suites) |
| 3.2.4 |
max 5 live |
max 5 live |
| 4.1.6 |
20 live |
20 live |
| 4.1.7 (with #10384) |
max 5 live |
20 live |
| 5.0.0-beta.2 (with #10179) |
max 5 live |
20 live |
On the failing versions the suite fails with:
AssertionError: live databases should be bounded by maxConcurrency: expected 20 to be less than or equal to 6 and wall time roughly doubles (~3.1s to ~6.5s) because the fake database's load-scaled operations make the pile-up cost real time. Flattening the groups (all 20 tests directly under one describe.concurrent) makes 4.1.7 and 5.0.0-beta.2 pass, so the sibling suites are required to reproduce.
Why this happens
These per-branch limiters do not compose into a global bound. With N concurrent sibling suites, the effective limit on mid-lifecycle tests is roughly maxConcurrency * N, not maxConcurrency, which is why the flat repro passes on 4.1.7 while the grouped one allocates all 20 dbs at once.
For certain test suites this is a large performance regression. For example, our server suite went from 38s on vitest 3 to 89s on vitest 4 with identical configuration and identical per-test timings, purely from the resource contention caused by the pile-up. A fix likely needs the per-branch limiters to share a global budget, or a test to hold one slot for its full setup and teardown lifecycle as in v3.
Reproduction
https://stackblitz.com/edit/vitest-dev-vitest-ruovhudo?file=package.json
Change v3 to v3 and vice versa in the package.json to see failures
System Info
System:
OS: macOS 26.3
CPU: (18) arm64 Apple M5 Pro
Memory: 176.59 MB / 48.00 GB
Shell: 5.9 - /bin/zsh
Binaries:
Node: 24.0.2 - /Users/dbousamra/.nvm/versions/node/v24.0.2/bin/node
Yarn: 3.5.1 - /Users/dbousamra/.nvm/versions/node/v24.0.2/bin/yarn
npm: 11.3.0 - /Users/dbousamra/.nvm/versions/node/v24.0.2/bin/npm
bun: 1.3.3 - /Users/dbousamra/.bun/bin/bun
Deno: 2.5.6 - /Users/dbousamra/.deno/bin/deno
Browsers:
Chrome: 148.0.7778.217
Firefox: 148.0
Safari: 26.3
Used Package Manager
pnpm
Validations
Describe the bug
In vitest 4,
maxConcurrencydoes not bound the number of concurrent tests that are mid-lifecycle (betweenbeforeEachandafterEach) when the tests are split across siblingdescribeblocks. Setups run far ahead of teardowns, so resources created per-test inbeforeEach(in our real suite: one Postgres database + app instance per test) accumulate far beyondmaxConcurrency.This is a follow-up to #10097, which was fixed by #10179 (v5) and backported in #10384 (v4.1.7) (thank you @hi-ogawa!). That fix restores the bound within a single suite: a flat
describe.concurrentnow behaves correctly (it did not in 4.1.6). However, the fix gives each task branch an independent limiter, so sibling concurrent suites each receive their own fullmaxConcurrencybudget and the totals multiply. With 5 sibling groups andmaxConcurrency: 5, up to 25 tests are mid-lifecycle simultaneously.I've created a repro. Basically we have 20 tests in 5 sibling
describegroups underdescribe.concurrent, withmaxConcurrency: 5. Each test creates a fake db inbeforeEachand drops it inafterEach. Every db operation costs more the more databases are live (mimicking how a real database degrades under load). AnafterAllasserts the live-database high-water mark stayed at or below 6. This is a contrived example, so if anyone has a better approach lmk. The point though is that there's definitely a regression in V4 causing real world consequences.Results:
On the failing versions the suite fails with:
AssertionError: live databases should be bounded by maxConcurrency: expected 20 to be less than or equal to 6and wall time roughly doubles (~3.1s to ~6.5s) because the fake database's load-scaled operations make the pile-up cost real time. Flattening the groups (all 20 tests directly under onedescribe.concurrent) makes 4.1.7 and 5.0.0-beta.2 pass, so the sibling suites are required to reproduce.Why this happens
limitMaxConcurrency(() => runTest(c, runner)). A test acquired one slot before itsbeforeEachand released it after itsafterEach, so at mostmaxConcurrencytests could be mid-lifecycle at once, regardless of suite structure.maxConcurrency#9653 changed what the shared limiter wraps: it now wraps each individual hook and body invocation separately. A test no longer holds a slot between its phases. After itsbeforeEachfinishes, it sits "open" (resources allocated) without counting against any limit while other tests' hooks run.runSuitenow creates a freshlimitConcurrency(runner.config.maxConcurrency)for each suite's children. Within one suite this restores the bound, but every sibling suite gets its own independent budget.These per-branch limiters do not compose into a global bound. With N concurrent sibling suites, the effective limit on mid-lifecycle tests is roughly
maxConcurrency * N, notmaxConcurrency, which is why the flat repro passes on 4.1.7 while the grouped one allocates all 20 dbs at once.For certain test suites this is a large performance regression. For example, our server suite went from 38s on vitest 3 to 89s on vitest 4 with identical configuration and identical per-test timings, purely from the resource contention caused by the pile-up. A fix likely needs the per-branch limiters to share a global budget, or a test to hold one slot for its full setup and teardown lifecycle as in v3.
Reproduction
https://stackblitz.com/edit/vitest-dev-vitest-ruovhudo?file=package.json
Change v3 to v3 and vice versa in the package.json to see failures
System Info
System: OS: macOS 26.3 CPU: (18) arm64 Apple M5 Pro Memory: 176.59 MB / 48.00 GB Shell: 5.9 - /bin/zsh Binaries: Node: 24.0.2 - /Users/dbousamra/.nvm/versions/node/v24.0.2/bin/node Yarn: 3.5.1 - /Users/dbousamra/.nvm/versions/node/v24.0.2/bin/yarn npm: 11.3.0 - /Users/dbousamra/.nvm/versions/node/v24.0.2/bin/npm bun: 1.3.3 - /Users/dbousamra/.bun/bin/bun Deno: 2.5.6 - /Users/dbousamra/.deno/bin/deno Browsers: Chrome: 148.0.7778.217 Firefox: 148.0 Safari: 26.3Used Package Manager
pnpm
Validations