Adaptive browser sessions

Vitest treats maxWorkers as a hard ceiling, opens browser sessions incrementally, and dynamically caps the batch when existing sessions appear sufficient.

vitest-dev/vitest#10726 browser.ts at 55d210ef standalone diff against main
session opening test execution queued files

What changes

Before: launch up to the hard ceiling

min(maxWorkers, files.length) pages open concurrently. Even trivial files can each pay for a context, page, and browser module graph.

After: open until a dynamic cap

Pages open sequentially until the measured queue appears cheap enough for the current session count. Expensive queues can still grow toward maxWorkers.

Before: startup contention first

Hypothetical four-file run. All four pages ask the shared Vite server to bring up their module graphs at once.

Vite
4 startup graphs contend
S1
openf1
S2
openf2
S3
openf3
S4
openf4
0 ms900 ms

The old pool calls every openPage before awaiting Promise.all. In this illustration, contention stretches the shared startup phase to 800 ms.

After: startup overlaps useful work

Same four files. S1 executes files sequentially while S2 opens; the queue drains before S3 is useful.

Vite
S1 startupS2 startup
S1
openf1f2f3
S2
openf4
S3
not opened
S4
not opened
0 ms900 ms

The bars use illustrative fixed timings: 300 ms per staged open and 100 ms per file. They show the intended contention model, not benchmark measurements.

Scheduling principle

remaining files × observed time per file÷current worker countobserved worker startup time
If current workers can drain the queue within that startup window, keep the current worker count.
PR implementation queueLen × fileCostEMA ÷ openSessions ≤ 2 × max(openCost, 100 ms) Stop opening sessions; otherwise continue toward hard maxWorkers.
Observed worker startup time

openCost is replaced after every completed open, so the latest browser worker startup controls the next decision.

Observed time per file

fileCostEMA = EMA * 0.7 + duration * 0.3. Each session's first file is excluded because it also pays tester bootstrap cost.

Without contention, the natural cap begins when estimated drain is no longer than observed startup. The implementation's broadens that cap region: equivalently, it discounts projected drain by half or treats opening as twice as expensive. The 100 ms floor avoids treating an unusually tiny startup sample as free.

Worked cap decisions

Cap this batch at 2

S1 and S2 are running files. The queue contains eight more unassigned files.

queued files8
open sessions2
file EMA200 ms
open cost500 ms
S1
open 500f1 200f2 200f5f7f9f11
S2
open 500f6f8f10f12
S3
not opened
-1000 msdecision now+1000 ms
8 × 200 ÷ 2 = 800 ms2 × 500 = 1000 ms
Cap at 2 sessionsThe two existing sessions are projected to drain the queue before opening S3 pays back its cost.

Do not cap yet: open S3

S1 and S2 are running files. The queue contains twelve more unassigned files.

queued files12
open sessions2
file EMA200 ms
open cost500 ms
S1
open 500f1 200f2 200f5f7f9f12f15
S2
open 500f6f8f10f13f16
S3
open 500f11f14
-1000 msdecision now+1200 ms
12 × 200 ÷ 2 = 1200 ms > 2 × 500 = 1000 ms
Continue toward maxWorkersThe projected queue drain exceeds the cap threshold, so S3 is expected to pay back its startup cost.

Purple history left of “decision now” shows the already-paid sequential opens for S1 and S2. S1 completes two 200 ms files while S2 opens; the first is excluded as bootstrap and the second seeds the estimate. At the decision, current files f3 and f4 are omitted because the formula counts only the unassigned queue, shown from f5 onward. Actual assignment depends on which session asks for work next.

Operational behavior

Fresh multi-file runs open at least two sessions

After S1 shifts the first file, the queue remains non-empty and the EMA is still undefined, so S2 starts opening immediately when maxWorkers > 1.

No signal means no dynamic cap

Until one session completes its second file, shouldOpenAnotherSession returns true and the pool can reach maxWorkers.

Session startup is sequential

When many tabs are useful, each new open waits behind the previous one. The benefit depends on reduced server contention outweighing this serialization.

Large queues approach maxWorkers

As remaining work grows, projected drain exceeds any finite startup threshold and the dynamic cap rises toward the configured ceiling.

Queue mechanics

scaleSessions

  1. Check queue, worker limit, and cost rule.
  2. Await one openPage().
  3. Measure its startup cost.
  4. Start that session's run loop.
_queue
f1f2f3...
open decisions read size
run loops shift files

runNextTest

  1. Shift one file from the queue.
  2. Run it in the session.
  3. Update the per-file EMA after warmup.
  4. Repeat or mark the session ready.

Pool state lifecycle

Field and role Fresh poolfields + constructor runTestsenqueue + reuse Session openingscaleSessions File runningrunNextTest First file donecompletion callback Later file doneEMA update Run completecheckCompletion Next batchrunTests again
_queuework not yet assigned [] [f1 ... fN]files appended N filesnothing shifts until a page opens shrinkingeach session shifts one shrinkingnext file shifts immediately shrinking [] [new files]
_promisewhole-run completion undefined pending DeferPromise same promise same promise same promise same promise resolve; undefined new DeferPromise
_scalingone opener loop + completion latch undefined Promise<void>loop starts pendingprevents early completion pendingmay open another page pending or settled undefinedonce the dynamic cap is chosen undefined new promise if capacity exists
_fileCostEmasteady per-file estimate undefined undefined undefinedno signal: keep opening undefined undefinedfirst file excluded seed or 70/30 update retained reusedaffects initial scaling
_sessionOpenCostlatest page startup cost 250 ms 250 ms replace after await openPage latest measurement latest measurement latest measurement retained reused
_warmedUpSessionssessions past bootstrap file {} {} {} {} add(sessionId) already presentduration enters EMA retained reused sessions stay warm
orchestratorsconnected session map in project state size 0 existing sessions retainedsets available capacity size + 1 after connectionopenPage waits for ready all connected sessionsbounds scaling at maxWorkers unchangedunless a WebSocket closes unchangeda disconnect removes its entry all live sessions retained same sessions reusednew pages fill remaining capacity
readySessionsopen sessions with no assigned work all 0 live sessionsempty set is complete full set -> remove allocated IDsunallocated idle sessions remain new session absentit is not idle yet running IDs absent stay absent if another file shifts add when queue is emptyfinishSession full set againsize === orchestrators.size full set -> remove allocated IDs

This shows a fresh pool's first batch. Exact queue counts and overlap depend on timing. The final column is important: the EMA, open cost, warmed-session set, and ready-session set survive completion and influence the next batch.

Completion

!_scaling && readySessions.size === orchestrators.size

The run cannot resolve while a page is still opening. Both the final session and the scaling promise's finally path recheck this invariant, which covers either completion order. Source

Call chain

Pool entry
Pool.runTests->browserPool.runTests->runWorkspaceTests
Project pool
Open session
shouldOpenAnotherSession->openPage->TestProject._openBrowserPage
Handshake
BrowserSessions.createSession+provider.openPage->orchestrator ready
Dispatch
runNextTest->queue.shift->orchestrator.createTesters RPC
Test runner
startTests / collectTests->BrowserRunner.importFile
Return path
createTesters resolves->update EMA->next file or finishSession->checkCompletion

False-cap caveat

Fast samples, slow remaining queue

S1's early files establish a 200 ms EMA, but the eight files still queued actually take 500 ms each.

queued files8
open sessions2
observed EMA200 ms
open cost500 ms

Continuous execution timeline

S1
open 500f1 200f2 200f3 500f5 500f7 500f9 500f11 500
S2
open 500f4 500f6 500f8 500f10 500f12 500
S3
not opened after false cap
00.5sdecision 1s1.5s2s2.5s3s3.5s

This is a linear time scale: the 500 ms opening and queued-file blocks have equal width, each 2.5 times wider than a 200 ms sample. Scroll horizontally to inspect the full 3.5-second run.

estimated at decision: 8 × 200 ÷ 2 = 800 ms1000 ms thresholdcap at 2
The true queued-work estimate is 8 × 500 ÷ 2 = 2000 ms, so paying 500 ms for S3 would be worthwhile. The low EMA comes from files that finished early enough to be observed while sessions were opening. Once that estimate caps the pool, this batch never invokes scaleSessions again, even when later completions raise the EMA.

Potential improvements

Estimate the remaining queue

Use historical per-file durations when available and sum estimates for the actual queued files instead of multiplying by one global EMA.

Reconsider the cap

Allow new cost samples to trigger another scaling decision while queued work remains. This helps, although a slow sample may arrive late.

Separate and age signals

Do not carry cheap collection or stale watch-run timings directly into a different execution phase or substantially changed batch.

Require confidence or expose control

Apply a low cap only with enough representative evidence, and consider a minimum-session floor or opt-out when explicit concurrency matters.