Vite dev server startup stalls in large monorepos — diagnosis and fix

Symptom: Running npx vite dev in a pnpm or Yarn workspace with 500 or more packages results in the terminal printing optimizing dependencies... and then stalling — sometimes for 45 seconds or more — before the VITE v5.x.x ready in Xs line ever appears. HMR is completely unavailable during this window.

This is a pre-bundling pathology. It is not a hardware limitation. The fix requires scoping optimizeDeps precisely, configuring file-system permissions correctly, and optionally enabling route warmup. None of these require upgrading Vite.


Vite monorepo startup: slow path vs fast path Two horizontal swim lanes comparing the slow default startup path (recursive workspace scan, 45+ seconds) against the optimized path (scoped optimizeDeps, 6 seconds). SLOW PATH — default config (45 s+) FAST PATH — optimized config (6 s) vite dev starts cold cache scan all 500+ workspace packages recursively CJS→ESM conversion blocks HTTP server optimizer stalls 45 s+ hang server ready (eventually) vite dev starts cold cache scan only explicitly listed packages native-ESM packages served without transform warmup pre-transforms entry routes server ready in ~6 s ▲ 45 s+ hang before HTTP server starts ▲ 6 s cold start — HTTP server starts immediately

Root cause: recursive workspace scanning blocks the HTTP server

The bottleneck lives in Vite’s optimizeDeps phase, which runs synchronously before the HTTP server starts accepting connections. When no explicit optimizeDeps.include/exclude list is provided, Vite’s dependency scanner crawls every discovered entry point — including all workspace packages reachable through symlinks — and converts any CJS or UMD package to ESM using esbuild.

This behavior is by design for a single-package project where the optimizer reliably completes in under a second. In a workspace with 500 packages it becomes catastrophic: the scanner discovers hundreds of internal packages through symlink traversal, many of which have mixed main/exports fields or CJS entry points. Each one triggers a full esbuild invocation, and because esbuild processes transitive dependencies too, the total graph explodes.

The underlying mechanics are covered in depth in Vite Module Graph and Dependency Resolution, which is the parent concept for this page. In short: the optimizer stores its output in node_modules/.vite/deps and tracks a hash in _metadata.json. On a cold start (empty cache, CI environment, or any config change) this entire pass runs from scratch.

Three contributing factors compound the stall:

  1. Implicit discovery. Without explicit scoping, Vite scans all import statements in entry points and recursively follows them into node_modules. In a monorepo, internal packages are in node_modules via symlinks, so they look like external dependencies.
  2. CJS/ESM hybrid packages. Internal packages that ship a main field pointing at CJS alongside an exports.import pointing at ESM cause esbuild to enter dual-resolution mode, doubling processing time per package.
  3. Symlink path normalization. pnpm’s flat-but-symlinked node_modules layout produces duplicate resolved paths when resolve.preserveSymlinks is false (the default), causing the same package to be scanned multiple times under different real paths.

To isolate which packages are responsible, capture the debug stream before applying any fix:

# Vite 5+ — stream the dependency optimizer trace to a file
DEBUG=vite:deps npx vite dev 2>&1 | tee /tmp/vite-deps-trace.txt

# Count how many packages triggered a 'bundling' entry
grep -c 'bundling' /tmp/vite-deps-trace.txt

Any count above roughly 20 in a monorepo signals that the scanner is operating without boundaries.


Exact Vite 5+ configuration fix

The remediation has three parts: scope the optimizer, fix file-system permissions, and enable warmup. Apply all three together; partial application reduces but does not eliminate the stall.

Part 1: scope optimizeDeps

// vite.config.ts — Vite 5+
import { defineConfig } from 'vite';
import path from 'path';
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

export default defineConfig({
  optimizeDeps: {
    // List ONLY the packages that need CJS→ESM conversion.
    // Typically these are third-party packages with a 'main' field but no 'exports'.
    include: [
      '@internal/ui-components',   // CJS build — must be pre-bundled
      '@internal/legacy-utils',    // No 'exports' field, uses 'main'
      'some-third-party-cjs-lib',  // External dep without ESM export map
    ],
    // Exclude packages that already ship pure ESM with a correct 'exports' field.
    // Vite will serve these directly without conversion.
    exclude: [
      '@internal/design-tokens',   // Native ESM, 'type: module', 'exports' map present
      '@internal/api-client',      // Native ESM
    ],
    esbuildOptions: {
      // Resolve TypeScript source files in internal packages during pre-bundling.
      resolveExtensions: ['.ts', '.tsx', '.mjs', '.js'],
    },
  },
  resolve: {
    // Required for pnpm workspaces: prevents double-scanning of symlinked packages.
    preserveSymlinks: true,
  },
  server: {
    fs: {
      // Allow Vite to serve files from anywhere in the monorepo root.
      // Use an absolute path here — relative paths break in CI.
      allow: [path.resolve(__dirname, '../..')],
    },
    watch: {
      // Prevent the file watcher from traversing built artifacts.
      ignored: ['**/node_modules/**', '**/dist/**', '**/.turbo/**'],
    },
  },
});

Part 2: fix internal package manifests

Any internal package that ships CJS but should ideally ship native ESM should be migrated. For those that cannot be migrated immediately, ensure the manifest does not have conflicting resolution fields that confuse the pre-bundler:

{
  "name": "@internal/design-tokens",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    },
    "./tokens": {
      "import": "./dist/tokens.js",
      "types": "./dist/tokens.d.ts"
    }
  }
}

Packages with "type": "module" and a correct exports map are excluded from pre-bundling automatically, even without listing them in optimizeDeps.exclude. The importance of this exports field convention is explained in Understanding ES Modules vs CommonJS in Bundlers, which covers why dual-mode packages require explicit resolution hints.

Part 3: enable route warmup

The server.warmup option (available in Vite 5.1+) pre-transforms the listed files immediately after the HTTP server starts, hiding most of the remaining initialization cost from the first browser request:

// vite.config.ts — Vite 5.1+
server: {
  warmup: {
    clientFiles: [
      './src/main.tsx',
      './src/routes/home.tsx',
      './src/routes/dashboard.tsx',
    ],
  },
},

Step-by-step verification

  1. Measure the cold-start baseline before patching using hyperfine to get a statistically reliable number:

    # Vite 5+ — remove the cache before each run to force a cold start
    hyperfine --prepare 'rm -rf node_modules/.vite' \
      --warmup 1 --runs 5 \
      'npx vite dev --logLevel warn'

    Record the mean time. The target after the fix is under 8 seconds for a 500-package workspace.

  2. Apply the config patch from Part 1 above and re-run the same hyperfine command. Confirm the mean drops to under 8 seconds. If it does not, check the debug trace again:

    DEBUG=vite:deps npx vite dev 2>&1 | grep 'bundling' | head -20

    Any package still appearing in bundling lines that should be native ESM is either missing the correct exports field or is caught by an implicit include added by a Vite plugin. Audit your plugins for hidden optimizeDeps.include injections by adding console.log in config hooks.

  3. Verify pre-bundle cache integrity in CI by asserting the metadata file exists and is non-empty:

    # Assert the cache is valid after a CI build
    test -f node_modules/.vite/deps/_metadata.json && \
      jq -e '.optimized | length > 0' node_modules/.vite/deps/_metadata.json || \
      { echo "FAIL: Vite pre-bundle cache is empty or missing"; exit 1; }
  4. Confirm HMR latency across package boundaries using the Vite client’s built-in timing log. After editing a file in an internal package, the browser console should print:

    [vite] hot updated: /packages/ui-components/src/Button.tsx in 48ms
    

    Latency above 200 ms on a cross-package HMR update indicates the file-watcher boundary is still too wide — tighten server.watch.ignored to exclude built artifacts.

  5. Check for duplicate chunk generation in production by running rollup-plugin-visualizer after a production build and confirming no internal package appears in more than one chunk tree:

    npx vite build && open dist/stats.html

Edge cases and gotchas

1. CI cache invalidation causes fresh stalls on every run

By default Vite invalidates the node_modules/.vite cache when the lockfile hash changes. In CI, every clean install regenerates the lockfile hash, forcing a cold pre-bundle pass regardless of your local cache. The solution is to cache the node_modules/.vite/deps directory in CI keyed on the lockfile hash:

# .github/workflows/ci.yml (GitHub Actions excerpt)
- name: Restore Vite dependency cache
  uses: actions/cache@v4
  with:
    path: node_modules/.vite
    key: vite-deps-${{ hashFiles('**/pnpm-lock.yaml') }}
    restore-keys: vite-deps-

If the lockfile is unchanged between runs, the pre-bundling phase is skipped entirely, reducing startup in CI to under 2 seconds.

2. optimizeDeps.include causes duplicate chunks in production

If a package appears in both optimizeDeps.include (for dev pre-bundling) and Rollup’s module graph (via a direct import in source), the production build may emit it twice — once as a pre-bundled chunk and once as part of the Rollup output. The fix is to also add the package to build.rollupOptions.output.manualChunks:

// vite.config.ts — Vite 5+
build: {
  rollupOptions: {
    output: {
      manualChunks(id) {
        if (id.includes('@internal/ui-components')) return 'internal-ui';
        if (id.includes('@internal/legacy-utils')) return 'internal-utils';
      },
    },
  },
},

This forces Rollup to emit each package into a named chunk, preventing the pre-bundled version and the statically analyzed version from being emitted separately.

3. Absolute path in server.fs.allow breaks on Windows CI runners

Paths constructed with path.resolve(__dirname, '../..') produce backslash-separated paths on Windows, which Vite’s file-system guard does not always normalize correctly. If your CI runs on Windows agents, use fileURLToPath(new URL('../..', import.meta.url)) instead, which produces forward-slash paths on all platforms:

// vite.config.ts — Vite 5+ cross-platform path
import { fileURLToPath } from 'url';

const workspaceRoot = fileURLToPath(new URL('../..', import.meta.url));

export default defineConfig({
  server: {
    fs: {
      allow: [workspaceRoot],
    },
  },
});

FAQ

Why does Vite hang at “optimizing dependencies” in a monorepo?

Vite’s optimizer treats internal workspace packages as external dependencies and triggers a full CJS-to-ESM pre-bundling pass for each one. With 500+ packages, this recursive traversal blocks the HTTP server from starting until every transitive dependency has been scanned. Scoping optimizeDeps.include to only the packages that genuinely need conversion eliminates the stall.

Should I use optimizeDeps.include or optimizeDeps.exclude for workspace packages?

Use include for packages that ship CJS or UMD and need conversion. Use exclude for packages that already ship native ESM with a valid exports map — telling Vite to skip them explicitly prevents it from even attempting to classify them during scanning. Misclassifying a CJS package as exclude forces Vite to re-scan it on every request, producing slower HMR than if you had left the default alone.

Why does the startup stall only appear in CI, not locally?

CI runs typically have a cold node_modules/.vite cache and slower disk I/O. Relative paths in server.fs.allow can also fail in CI if the working directory differs from local. Use absolute, forward-slash paths derived from import.meta.url to guarantee correct behaviour across all environments.