Skip to content

Watch mode watches every file under root before the first test, unlike vitest run #11327

Description

@asapach

Describe the bug

In watch mode Vitest's top-level Vite server hands chokidar the config root to watch recursively.
chokidar opens an fs.watch handle per entry — every file as well as every directory — before the
first test runs
, so startup scales with repository size rather than with the test suite.

vitest run is unaffected: Vitest swaps in Vite's NoopWatcher when watch mode is off. Same config,
same root, same single trivial spec — only the mode differs:

tree under root watched entries vitest run vitest --watch
2,000 module dirs 8,060 1.23s 5.10s
8,000 module dirs 32,060 1.11s 15.97s
20,000 module dirs 80,060 1.00s 36.10s
a real monorepo 181,710 1.25s 102.2s

Medians of 4 watch runs per size, all measured in one directory. run does not move as the tree
grows 10x. Watch is linear in watched entries at a near-constant ~0.46 ms each (0.48 / 0.46 /
0.44), and extrapolating that to the monorepo's 181,710 entries predicts ~82s against the 102.2s
actually measured — so the synthetic fixture models the real cost to within about 25%.

One caveat for anyone re-measuring: the per-entry constant depends heavily on where the tree lives,
so compare only within a single directory. The identical 32,060-entry fixture measured 3.6s under
%TEMP% and 15.97s under C:\Development on this machine, and run-to-run variance was far worse
under %TEMP% (one run of four took 21.0s, against a ±10% spread in the numbers above).

The handle count is the clearest evidence that the whole tree is being visited. Counting
FSWatcher handles via process._getActiveHandles() once the watcher settles:

  • the 80,069-entry synthetic fixture → 80,069 handles, an exact match
  • the 181,710-entry monorepo → 181,692 handles
  • a 1,000-file directory → 1,001 handles

One handle per file and per directory, whether or not anything is reachable from a test.

Expected: watch-mode startup scales with the test suite and its module graph, as run does. A
directory containing no test files and no test dependency should not be visited at startup.

Actual: watch-mode startup scales with the size of the tree under root.

Root cause

Line numbers from the installed dist of vitest 5.0.0 / vite 8.3.0.

  1. vitest/dist/chunks/index.B89dZ0-N.js:8896 — run mode disables the watcher:

    if (!(viteConfig.test?.watch ?? configDefaults.watch)) server.watch = null;
    else server.watch ??= {};          // :8898 — watch mode

    server.watch === null gets Vite's NoopWatcher, which is why run never pays.

  2. index.B89dZ0-N.js:9158 — only one watcher exists per process:

    // project servers never watch; the top-level server owns the watcher
    config.server.watch = null;

    The cost therefore attaches to the top-level config's root. Per-project root values are
    free, which makes this easy to misattribute.

  3. vite/dist/node/chunks/node.js:24288 — the top-level server hands chokidar the root:

    const watcher = serverConfig.watch !== null
        ? chokidar.watch([...(config.experimental.bundledDev ? [] : [root]), ...config.configFileDependencies, ...], resolvedWatchOptions)
        : createNoopWatcher(resolvedWatchOptions);
  4. node.js:16353 — the default exclusions are narrow and none is gitignore-aware:

    const ignored = ['**/.git/**', '**/node_modules/**', '**/test-results/**', escapePath(cacheDir) + '/**', ...arraify(ignoredList || [])];

    Generated trees are crawled exactly like source. In the monorepo above, gitignored output
    directories account for 59.2% of all watched entries.

    outDir is not the issue: its exclusion is gated on emptyOutDir, and Vitest sets
    emptyOutDir: false deliberately (index.B89dZ0-N.js:8880-8885, citing fix: don't add outDirs to watch.ignored if emptyOutDir is false vitejs/vite#16453), so
    build output stays watched on purpose.

A related smaller bug: server.watch = null in config is silently ignored

Because of the ??= on line 8898, the one setting a user would reach for to opt out is overwritten
by the slow path — ??= fires on null. Measured on the 80,069-entry fixture: 34.92s with
server: { watch: null } against a 35.02s baseline, i.e. no effect at all. Worth fixing
independently of everything else.

Ruled out by measurement — listed to save triage time
  • Spec discovery. test.dir, a narrowed test.exclude, and forceRerunTriggers: [] change
    nothing about what is watched. On the 80,069-entry fixture all three, and the baseline, settle at
    an identical 80,069 FSWatcher handles. tinyglobby prunes to a static prefix, so globbing
    is cheap.
  • The dependency scanner. Vitest already sets optimizeDeps to
    { noDiscovery: true, entries: [], include: [] } on the parent's environments
    (index.B89dZ0-N.js:7193-7231, applied at :7352). It would also affect run, which is flat.
  • Worker pool startup. Watch mode uses fewer workers than run mode — index.B89dZ0-N.js:10049
    halves the count: vitest.config.watch ? Math.max(Math.floor(maxThreadsCount / 2), 1) : ....
  • tsconfig discovery. Vite 8 has no tsconfck dependency at all.
  • The vendored chokidar is what runs. Vite inlines chokidar 3.6.0 (patched) at
    node.js:11464 and lists no chokidar dependency, so npm ls chokidar is misleading.
Workaround (works today, and suggests the fix needs no new API)

Narrow what chokidar traverses with a server.watch.ignored predicate that keeps the root, the
files directly in it, and a scope discovered from the host, pruning everything else at depth one.
resolveChokidarOptions honours a predicate (normalizeIgnored passes non-strings through,
node.js:11523).

On the 80,069-entry tree this takes watch startup from 35.02s to 1.00s, against a run-mode floor
of 0.97s — the watch penalty disappears entirely.

Two hooks supply a complete scope, and neither alone is enough:

  • configureVitest gives each project's include globs, trimmed to the part before the first
    wildcard — so a spec that has never been loaded, in a directory that did not exist, is still seen.
  • defineCacheKeyGenerator gives every module a run loads. It fires per module while the cache
    path is computed, which precedes the cache lookup, so unlike transform it is not silenced by
    test.fsModuleCache. Returning nothing leaves the key untouched.

Three approaches look like they would work and do not:

  • Listing the subtrees by hand costs what crawling them costs, so it stops scaling exactly when
    it matters.
  • Driving the scope from transform passes cold and covers nothing warm. With
    test.fsModuleCache: true, a spec importing 14 modules across 14 directories gives
    transform 14/14 directories cold and 0/14 warm — the scope is simply empty on a warm run.
    resolveId is not an alternative: it is called twice in that run, both times for index.html,
    and never for a fixture module.
  • Reading the module graph on a timer. The graph is complete on a warm run — Vitest replays
    cached imports into it (index.B89dZ0-N.js:9780-9791, "we populate the module graph to make the
    watch mode work because it relies on importers"
    ) — but read environments.client, not ssr,
    which shows only entry files. The real objection is that it needs a trigger, and a poll leaves a
    window in which an edit to a just-loaded dependency is missed. The watcher's ready cannot be
    that trigger: a plugin attaching a listener at the earliest available moment (configureServer,
    137ms) never observes it across the whole 200s crawl.
Possible fixes, roughly in order of how much they help
  1. Watch lazily. Vitest already knows its spec files and their dependencies; watching the module
    graph rather than the whole root would make startup proportional to the suite. This is what Karma
    does, and it is the only option indifferent to repository size. A third-party plugin can already
    assemble a complete scope from configureVitest and defineCacheKeyGenerator alone, which
    suggests no new API is needed.

  2. A native recursive watch (fs.watch(root, { recursive: true })) is O(1) to establish — it
    arms immediately with a single handle — but it loses events and should not be adopted as it
    stands. One handle means one ReadDirectoryChangesW buffer for the whole tree. Measured on
    Windows with a 1,000-file burst driven from a separate process, three trials each:

    watcher handles distinct files seen lost
    fs.watch recursive 1 213 / 205 / 179 787 / 795 / 821
    chokidar 3.6.0 1001 1000 0

    chokidar's per-entry handles each have their own buffer, which is precisely why it does not drop
    events — the cost and the correctness are the same design choice. A native watch is also a kernel
    watch only on Windows and macOS: on Linux, Node runs a userspace shim that opens a handle per
    file and directory (fs: add recursive watch for linux nodejs/node#45098, shipped in v19.1.0 as "fs.watch recursive support on
    Linux", commit 34bfef91a9) — which is also why it cannot be feature-detected, since the
    ERR_FEATURE_UNAVAILABLE_ON_PLATFORM throw no longer fires there.

  3. Make the exclusions gitignore-aware. Not a solution on its own, but it removes the largest
    single component at essentially no cost and, unlike a hand-written denylist, does not rot. This
    part belongs to Vite, not Vitest. It must not be done by un-gating the outDir exclusion.

  4. At minimum, document it. The interaction between the top-level root, test.projects[].root
    and watch cost is not discoverable, and the natural reading — that a per-project root bounds the
    work — is wrong.

Scope note

A plain Vite dev server rooted at the same repository pays the identical crawl:
createServer({ root }) with no Vitest returns in 137ms and then settles after 200.5s across
181,692 handles, with an 87-second worst-case event-loop stall. So the crawl itself is Vite's,
and Vite's dev server legitimately wants to watch its root for HMR.

What is specific to Vitest is combining a root-sized watch with a workload that only needs the module
graph, having no supported way to opt out (the ??= above), and already holding the information
needed to scope it. Happy to move this to vitejs/vite if you disagree.

Reproduction

https://github.com/asapach/vitest-watch-root-repro

git clone https://github.com/asapach/vitest-watch-root-repro && cd vitest-watch-root-repro
npm install
node generate.mjs . 20000     # 20,053 directories, 60,000 files
npx vitest run                # time to first result
npx vitest --watch            # time to first result

--watch is required rather than cosmetic: the default is
!isCI && process.stdin.isTTY && !isAgent (vitest/dist/chunks/defaults.D2ip7f-X.js:53), so a
redirected or piped stdin silently gives run mode and no asymmetry at all. Confirm the banner reads
DEV, not RUN.

The fixture is additive and has no cleanup step, so generate in ascending size order to reproduce the table.

System Info

System:
    OS: Windows 11 10.0.26100
    CPU: (18) x64 Intel(R) Core(TM) Ultra 5 135H
    Memory: 9.36 GB / 31.46 GB
  Binaries:
    Node: 24.16.0 - C:\Program Files\nodejs\node.EXE
    npm: 11.13.0 - C:\Program Files\nodejs\npm.CMD
  Browsers:
    Chrome: 153.0.8010.53
    Edge: Chromium (152.0.4191.53)
    Internet Explorer: 11.0.26100.8115
  npmPackages:
    vite: ^8.3.0 => 8.3.0
    vitest: ^5.0.0 => 5.0.0

Used Package Manager

npm

Validations

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions