Browser Instances as Workers

How vitest-dev/vitest#11525 changes the browser pool, what the review added on top of it, and how much each change matters. The pull request resolves #9696: with 20 browser projects, CI ran out of memory because every project opened its own browser and its own set of pages at the same time.

branch feat/browser-pool-workers base origin/main 3e3624c5e 16 files, +1061 / -227 review fixes: 30e2694cc, 0e666c197

Before and after

On main the browser pool is the only pool that works per project: each browser project gets its own BrowserPool, and all of them start at once. The pull request replaces them with one scheduler. Every browser instance is a worker, at most maxWorkers of them are open, and the same number is a budget of pages shared by the open instances.

main · a pool per browser project pool.ts executeTests starts all 16 pools at once project p1 1 browser 6 pages project p2 1 browser 6 pages project p3 1 browser 6 pages p4 … p16 13 more browsers 6 pages each pages per project = min(maxWorkers, files) peak: 16 browsers · 96 renderer processes 18.1 GB · 15.8 s PR · one scheduler, every instance is a worker pool.ts executeTests runTests(specs, { maxWorkers }) queue · 16 tasks one per project exclusive ones last schedule() active instances ≤ maxWorkers 9 open at the same time share a budget of 9 pages idle + slot needed: evict (LRU), keep warm adopt warm browsers prewarm ≤ maxWorkers · same launch options peak: 9 browsers · 16 renderer processes 5.4 GB · 11.3 s
Same fixture on both sides: 16 browser projects, 6 files of 4 DOM tests, headless Chromium, Apple M4 with 10 cores, median of 5 interleaved runs. Memory is the summed RSS of the Vitest process and every browser process.

Life of one instance in the new pool

The top lane is the scheduler and the worker in packages/vitest/src/node/pools/browser.ts. The bottom lane is the Playwright provider. Highlighted parts were added by the review on top of the pull request: 4 defects reproduced with probes, 4 more confirmed by subagents.

pool · packages/vitest/src/node/pools/browser.ts next queued task · more pages for the instances still running schedule() idle worker first evict idle (LRU) worker.runTests reopen the provider parallel decided once openPages() forget closed pages page budget share runNextTest createTesters one file per page finishSession settle when no page is busy, keep 1st error .finally schedule() grow() active workers openPage(sessionId, { parallel }) provider · packages/browser-playwright/src/playwright.ts warm pool prewarm ≤ maxWorkers closed with vitest.onClose adopt openBrowser() takeWarmBrowser takeAnyWarmBrowser → launch ctx openPage() newContext, newPage, goto closing: dispose only own page close({ keepWarm }) eviction: stop() keeps it warm shutdown: browser.close ≤ 10 s an evicted instance's browser is adopted by the next instance with the same browser and launch options
pull request review fixes
A worker is evicted only when it is idle and another task needs its slot. On shutdown the pool closes every provider without the keep-warm option, so the browser process is closed under the existing 10 s bound.

Architectural changes and how much they matter

Importance reflects what breaks or what a user pays without the change. Critical changes fix hangs or the problem the pull request exists for. High changes affect memory, correctness of results, or public behaviour. Medium and low changes improve performance, robustness, or hygiene.

Change Why it matters Origin Importance
One scheduler: a queue of per-project tasks, at most maxWorkers instances open, LRU eviction of idle instances Resolves #9696. Browser count no longer grows with the project count: 16 browsers became 9, peak memory 18.1 GB became 5.4 GB, wall time 15.8 s became 11.3 s. bbff01106 critical
Forget pages that were closed or crashed (pruneClosedSessions, opening sessions tracked as a set) Without it a watch rerun after a page disconnect opened no page and never settled, and vitest.close() hung on the running promise. Reproduced with a probe. 30e2694cc critical
Page budget shared by the open instances (getPageAllowance, grow() when an instance finishes) Pages stay close to the core count instead of projects × maxWorkers: 96 renderer processes became 16. maxWorkers now means concurrent files, like in the node pools. bbff01106 high
One maxWorkers knob passed from the task group in pool.ts Per-project maxWorkers: 1 or fileParallelism: false now serialises instances too. On a 2-core CI runner the default is 1 worker, so 3 instances run one after another. This is a behaviour change worth a release note. bbff01106 high
Provider lifecycle: closeBrowserProvider() detaches the provider, the worker re-inits it on the next run, the RPC upgrade terminates sockets of a closed instance Eviction is only possible when an instance can close and reopen cleanly. Fixes the CI hang where a reopened instance read an unset provider. 1ebbca8f7 high
The run settles only after every instance settled (allSettled, per-page reject, settle()) Before, one failing instance ended the run while the others still ran: close() killed their files, results leaked into the next watch run, and a second error was lost. 0e666c197 high
Keep the browser warm only on eviction (close({ keepWarm })); shutdown closes it under the 10 s bound The pull request routed the final close through the warm pool, so the real browser.close() ran in an onClose listener without a timeout. A frozen browser would hang shutdown again, the problem #10791 fixed. 30e2694cc high
Decide the parallel flag once per instance (maxPages > 1) With the budget an instance can start with 1 page and grow later. The provider launched a persistent context for the first page and then shared it with the added pages, without a warning. Reproduced: 3 pages in one persistent context. 30e2694cc high
Browser recycling: an evicted instance registers its browser as warm, the next task adopts it (keepWarmBrowser, takeAnyWarmBrowser) Eviction reuses the browser process instead of launching one, and prewarmed browsers are adopted by whichever project is scheduled first. bbff01106 medium
Prewarm capped at maxWorkers launches (_prewarmedBrowsers, reset on restart) Without the cap 16 browsers launched at startup and 7 of them sat idle until their project got a turn. bbff01106 medium
Idle-worker-first scheduling (nextTaskIndex) With 3 instances and 2 slots every rerun closed and reopened all 3 instances. Now 1 reopens: rerun 312 ms became 177 ms. 0e666c197 medium
Exclusive instances (no per-session mocker or no parallelism) run alone and last Keeps the webdriverio and preview providers correct: they share one mock registry and drive one page. bbff01106 medium
ProjectBrowser.provider is typed BrowserProvider | undefined, 8 call sites guarded The provider is unset after an eviction until the next run. A reporter reading provider.name in onTestRunEnd failed the run with a TypeError. Public type change, needs a release note. 0e666c197 medium
_throwIfClosing disposes only its own page instead of clearing the shared maps A page still opening during an eviction could clear the context map before close() read it, and the recycled browser kept the contexts. Confirmed in the provider: 15 of 25 runs leaked with 4 open pages. 0e666c197 medium
Session ids and exit promises of closed instances are released Both grew without bound in watch mode: 3, 6, 9 session ids over 3 runs. 30e2694cc low
Not applied yet: instances with headless: false and preview instances are evicted like headless ones, so their visible window or tab is closed and reopened on every rerun when there are more instances than workers, and standalone() opens all pages up front. A proposal to never evict headed instances exists and waits for a decision.

Benchmark

Measured after all applied fixes against origin/main. Every run passed all 384 tests.

Scenario Wall time main / branch Peak memory main / branch Browsers Renderers CPU time
16 projects, default maxWorkers 15.8 s / 11.3 s 18.1 GB / 5.4 GB 16 / 9 96 / 16 60.9 s / 36.2 s
16 projects, --maxWorkers=2 11.7 s / 19.6 s 9.5 GB / 1.4 GB 16 / 2 32 / 2 41.2 s / 28.5 s
1 project, 96 files 6.7 s / 6.8 s 2.7 GB / 2.7 GB 1 / 1 9 / 9 24.8 s / 25.0 s

The --maxWorkers=2 run is slower because 2 pages run instead of 32. The single-project numbers are within noise.