Describe the bug
In browser mode, the pages of a project that has finished running its tests stay open until the whole workspace run ends. They are only closed by the provider's close(), which runs after every project is done.
Since projects run concurrently and each one opens up to maxWorkers pages, a run holds projects * maxWorkers pages open at its peak, and every project that finishes early keeps its browser page (and everything the tests loaded into it) alive until the slowest project is done.
With empty tests the pages are cheap, so the reproduction only shows the count. In a real suite each page holds the modules, DOM and component trees of every file that ran in it.
In mui/mui-x the browser suite has 21 projects and maxWorkers: 2, so the run keeps 42 Chromium tabs open and peaks at ~12.8GB (measured as the delta in system memory over a full run). That is more than the 16GB CI container can hold, so a renderer is OOM-killed and the run fails with Browser page crashed followed by [vitest] Browser connection was closed while running tests, always on whichever project happens to finish last, while every test passes. Splitting the suite into two sequential vitest invocations dropped the peak to 7.2GB and the wall time from 144s to 112s, purely because half as many pages are alive at once.
Reproduction
https://github.com/JCQuintas/vitest-browser-pages-repro
npm install
npx playwright install chromium
npm test
Six browser projects, each with a single test that sleeps for a different duration, so they finish 500ms apart. A reporter prints how many pages each provider still holds whenever a project finishes:
✓ |project-1 (chromium)| project-1.test.ts (1 test) 504ms
[open-pages] 2.4s project "project-1 (chromium)" finished: 6 page(s) open | project-1=1 project-2=1 project-3=1 project-4=1 project-5=1 project-6=1
✓ |project-2 (chromium)| project-2.test.ts (1 test) 1003ms
[open-pages] 2.9s project "project-2 (chromium)" finished: 6 page(s) open | project-1=1 project-2=1 project-3=1 project-4=1 project-5=1 project-6=1
...
✓ |project-6 (chromium)| project-6.test.ts (1 test) 6003ms
[open-pages] 7.9s project "project-6 (chromium)" finished: 6 page(s) open | project-1=1 project-2=1 project-3=1 project-4=1 project-5=1 project-6=1
project-1 is done at 2.4s, but its page is still open at 7.9s, and the total never drops below 6.
Expected behaviour
Once a project's queue is empty and its testers have been cleaned up, its pages are closed and the memory released, so the peak follows the projects that are actually running rather than the total project count.
Where it comes from
packages/vitest/src/node/pools/browser.ts, in runNextTest: when the queue is empty the session is cleaned up and marked ready, but the page is left open.
if (!file) {
const isolate = this.project.config.isolate
if (isolate) {
this.finishSession(sessionId)
return
}
const orchestrator = this.getOrchestrator(sessionId)
orchestrator.cleanupTesters()
.catch(error => this.reject(error))
.finally(() => this.finishSession(sessionId))
return
}
Workarounds that do not work
- Closing the pages from a custom reporter.
onTestModuleEnd fires before the pool calls cleanupTesters() on that page, so closing it there fails the run with [birpc] rpc is closed, cannot call "createTesters". There is no reporter hook that runs after the cleanup.
isolate: true. Recreates the iframe per file but keeps the same page, so the page count is unchanged. It was also 2.5x slower on the suite above.
- Lowering
maxWorkers. It is per project, so it scales the peak down but the pages of finished projects still accumulate.
Splitting the run into several vitest invocations does work, because the browsers go away with each process, but the projects have to be split by hand and the reports merged afterwards.
System Info
System:
OS: macOS 26.5.2
CPU: (11) arm64 Apple M3 Pro
Memory: 4.61 GB / 36.00 GB
Shell: 5.9 - /bin/zsh
Binaries:
Node: 24.15.0
npm: 11.12.1
pnpm: 10.34.3
npmPackages:
vitest: 4.1.10
@vitest/browser: 4.1.10
@vitest/browser-playwright: 4.1.10
playwright: 1.61.1
The same code is on main at the time of writing.
Used Package Manager
npm
Validations
Describe the bug
In browser mode, the pages of a project that has finished running its tests stay open until the whole workspace run ends. They are only closed by the provider's
close(), which runs after every project is done.Since projects run concurrently and each one opens up to
maxWorkerspages, a run holdsprojects * maxWorkerspages open at its peak, and every project that finishes early keeps its browser page (and everything the tests loaded into it) alive until the slowest project is done.With empty tests the pages are cheap, so the reproduction only shows the count. In a real suite each page holds the modules, DOM and component trees of every file that ran in it.
In mui/mui-x the browser suite has 21 projects and
maxWorkers: 2, so the run keeps 42 Chromium tabs open and peaks at ~12.8GB (measured as the delta in system memory over a full run). That is more than the 16GB CI container can hold, so a renderer is OOM-killed and the run fails withBrowser page crashedfollowed by[vitest] Browser connection was closed while running tests, always on whichever project happens to finish last, while every test passes. Splitting the suite into two sequentialvitestinvocations dropped the peak to 7.2GB and the wall time from 144s to 112s, purely because half as many pages are alive at once.Reproduction
https://github.com/JCQuintas/vitest-browser-pages-repro
npm install npx playwright install chromium npm testSix browser projects, each with a single test that sleeps for a different duration, so they finish 500ms apart. A reporter prints how many pages each provider still holds whenever a project finishes:
project-1is done at 2.4s, but its page is still open at 7.9s, and the total never drops below 6.Expected behaviour
Once a project's queue is empty and its testers have been cleaned up, its pages are closed and the memory released, so the peak follows the projects that are actually running rather than the total project count.
Where it comes from
packages/vitest/src/node/pools/browser.ts, inrunNextTest: when the queue is empty the session is cleaned up and marked ready, but the page is left open.Workarounds that do not work
onTestModuleEndfires before the pool callscleanupTesters()on that page, so closing it there fails the run with[birpc] rpc is closed, cannot call "createTesters". There is no reporter hook that runs after the cleanup.isolate: true. Recreates the iframe per file but keeps the same page, so the page count is unchanged. It was also 2.5x slower on the suite above.maxWorkers. It is per project, so it scales the peak down but the pages of finished projects still accumulate.Splitting the run into several
vitestinvocations does work, because the browsers go away with each process, but the projects have to be split by hand and the reports merged afterwards.System Info
The same code is on
mainat the time of writing.Used Package Manager
npm
Validations