Describe the bug
Summary
In vitest 3, maxConcurrency bounded the number of tests that were in flight at any moment - that is, the number of tests whose beforeEach/body/afterEach cycle had started but not yet completed. After #9653, the limiter was moved from runTest-level to leaf-level (individual hook/body function calls). This was done to fix #8367, where sibling beforeAll hooks weren't being throttled. But it removed a guarantee that resource-owning test suites were relying on: that resources allocated in beforeEach and freed in afterEach would only ever exist maxConcurrency at a time.
In v4, with describe.concurrent and an expensive beforeEach, the runtime now fires every test's beforeEach to completion before any test body runs, because the FIFO limiter releases between leaf calls and the queued bodies are appended after every queued beforeEach. The result is that all N concurrent tests own their per-test resources simultaneously, regardless of maxConcurrency.
Impact
This affects test suites that:
- Use
describe.concurrent (or concurrent: true via config)
- Allocate per-test resources in
beforeEach (DB pools, app instances, file handles, network sockets, cloned databases)
- Free those resources in
afterEach
Just to give an example scenario, inside each of our tests, we create and connect to a database. In v3, we would have max 5 of those connections active at any one time. In v4 we have 400 (we have 400 tests), even though only 5 tests happening at once. The 395 sit there connected, waiting for a test slot. Every concurrent test holds its a DB conn for the entire duration of all sibling tests' beforeEach calls.
The new behaviour is internally consistent and the PR description (#9653) is reasonable: maxConcurrency now means "max user functions executing at any instant", which is a sensible interpretation of the option name. But it leaves no built-in way to express "max tests that own resources at any instant". These were the same number in v3 (as a side effect of where the limiter wrapped); they're decoupled in v4.
The available knobs in v4 don't fill the gap:
maxConcurrency: now leaf-level, doesn't bound resource ownership
maxWorkers / fileParallelism: worker-level, doesn't help inside a single concurrent file
pool/isolate: orthogonal
singleFork/singleThread: collapses parallelism entirely, defeats the purpose of describe.concurrent
The only fix I can see is to add a paired semaphore in user-space (acquire in beforeEach, release in afterEach) or implement a custom test runner that overrides onBeforeRunTask/onAfterRunTask. Both work, but they require users to know that the v4 behaviour exists, and effectively re-implement the v3 guarantee outside of vitest. Workaround I've got going atm (per-worker semaphore in the test setup file):
import { limitConcurrency } from '@vitest/runner/utils'
const limiter = limitConcurrency(5)
const releases = new WeakMap<object, () => void>()
beforeEach(async (context) => {
const release = await limiter.acquire()
releases.set(context.task, release)
// ... real setup that allocates resources
})
afterEach(async (context) => {
try {
// ... real teardown that frees resources
} finally {
releases.get(context.task)?.()
releases.delete(context.task)
}
})
What I'd like to see:
- A new option along the lines of
maxInFlightTests (or sequence.maxInFlightTests) that re-exposes the v3 guarantee - wraps runTest end-to-end in a separate semaphore, sized independently of maxConcurrency. Defaults to Infinity so it's opt-in and doesn't change behaviour for anyone.
- Or documentation on
maxConcurrency that explicitly calls out the v3 to v4 behaviour change and points users at the workaround pattern (acquire/release in setup/teardown), so the next team that hits this knows what's happening before they spend a day debugging connection limits.
- Or a first-class hook in the runner API for "around test" with paired acquire/release semantics, so user-side semaphores have somewhere natural to live.
I'd be happy to put up a PR for option 1 if there's interest - it's a small change (a second limitConcurrency instance, used at the same call site #9653 removed) and it's strictly additive.
Reproduction
https://stackblitz.com/edit/vitest-dev-vitest-munmai6p?file=test%2Fleak.test.ts
Here's a basic example showing that hooks can exceed maxConcurrency.
System Info
System:
OS: macOS 26.3
CPU: (18) arm64 Apple M5 Pro
Memory: 475.66 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 - /opt/homebrew/bin/yarn
npm: 11.3.0 - /Users/dbousamra/.nvm/versions/node/v24.0.2/bin/npm
pnpm: 10.11.0 - /Users/dbousamra/Library/pnpm/pnpm
bun: 1.3.3 - /Users/dbousamra/.bun/bin/bun
Deno: 2.5.6 - /Users/dbousamra/.deno/bin/deno
Browsers:
Chrome: 146.0.7680.178
Firefox: 148.0
Safari: 26.3
Used Package Manager
yarn
Validations
Describe the bug
Summary
In vitest 3,
maxConcurrencybounded the number of tests that were in flight at any moment - that is, the number of tests whosebeforeEach/body/afterEachcycle had started but not yet completed. After #9653, the limiter was moved from runTest-level to leaf-level (individual hook/body function calls). This was done to fix #8367, where siblingbeforeAllhooks weren't being throttled. But it removed a guarantee that resource-owning test suites were relying on: that resources allocated inbeforeEachand freed inafterEachwould only ever existmaxConcurrencyat a time.In v4, with
describe.concurrentand an expensivebeforeEach, the runtime now fires every test'sbeforeEachto completion before any test body runs, because the FIFO limiter releases between leaf calls and the queued bodies are appended after every queuedbeforeEach. The result is that all N concurrent tests own their per-test resources simultaneously, regardless ofmaxConcurrency.Impact
This affects test suites that:
describe.concurrent(orconcurrent: truevia config)beforeEach(DB pools, app instances, file handles, network sockets, cloned databases)afterEachJust to give an example scenario, inside each of our tests, we create and connect to a database. In v3, we would have max 5 of those connections active at any one time. In v4 we have 400 (we have 400 tests), even though only 5 tests happening at once. The 395 sit there connected, waiting for a test slot. Every concurrent test holds its a DB conn for the entire duration of all sibling tests' beforeEach calls.
The new behaviour is internally consistent and the PR description (#9653) is reasonable:
maxConcurrencynow means "max user functions executing at any instant", which is a sensible interpretation of the option name. But it leaves no built-in way to express "max tests that own resources at any instant". These were the same number in v3 (as a side effect of where the limiter wrapped); they're decoupled in v4.The available knobs in v4 don't fill the gap:
maxConcurrency: now leaf-level, doesn't bound resource ownershipmaxWorkers/fileParallelism: worker-level, doesn't help inside a single concurrent filepool/isolate: orthogonalsingleFork/singleThread: collapses parallelism entirely, defeats the purpose ofdescribe.concurrentThe only fix I can see is to add a paired semaphore in user-space (acquire in
beforeEach, release inafterEach) or implement a custom test runner that overridesonBeforeRunTask/onAfterRunTask. Both work, but they require users to know that the v4 behaviour exists, and effectively re-implement the v3 guarantee outside of vitest. Workaround I've got going atm (per-worker semaphore in the test setup file):What I'd like to see:
maxInFlightTests(orsequence.maxInFlightTests) that re-exposes the v3 guarantee - wrapsrunTestend-to-end in a separate semaphore, sized independently ofmaxConcurrency. Defaults to Infinity so it's opt-in and doesn't change behaviour for anyone.maxConcurrencythat explicitly calls out the v3 to v4 behaviour change and points users at the workaround pattern (acquire/release in setup/teardown), so the next team that hits this knows what's happening before they spend a day debugging connection limits.I'd be happy to put up a PR for option 1 if there's interest - it's a small change (a second
limitConcurrencyinstance, used at the same call site #9653 removed) and it's strictly additive.Reproduction
https://stackblitz.com/edit/vitest-dev-vitest-munmai6p?file=test%2Fleak.test.ts
Here's a basic example showing that hooks can exceed maxConcurrency.
System Info
System: OS: macOS 26.3 CPU: (18) arm64 Apple M5 Pro Memory: 475.66 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 - /opt/homebrew/bin/yarn npm: 11.3.0 - /Users/dbousamra/.nvm/versions/node/v24.0.2/bin/npm pnpm: 10.11.0 - /Users/dbousamra/Library/pnpm/pnpm bun: 1.3.3 - /Users/dbousamra/.bun/bin/bun Deno: 2.5.6 - /Users/dbousamra/.deno/bin/deno Browsers: Chrome: 146.0.7680.178 Firefox: 148.0 Safari: 26.3Used Package Manager
yarn
Validations