Optimizing dev server startup times for large monorepos
Diagnosing the Monorepo Startup Stall
When a workspace exceeds 500 packages, the dev server initialization frequently hangs during the dependency pre-bundling phase. This manifests as a prolonged vite:deps log trace where the optimizer attempts to resolve transitive dependencies across package boundaries. Understanding how Vite Module Graph and Dependency Resolution handles workspace symlinks is critical to isolating the bottleneck.
Error signature
- Symptom: Dev server cold start exceeds 45 s in pnpm/yarn workspaces with more than 500 packages
- CLI output pattern:
VITE v5.x.x ready in Xsnever appears; the optimizer hangs - Log trace (with DEBUG=vite:deps):
optimizing dependencies...stalls before completing
Engineering steps
- Capture resolution latency:
DEBUG=vite:deps npx vite dev 2>&1 | grep 'optimizing' - Identify packages triggering synchronous CJS-to-ESM conversion by filtering for
transformingorbundlingentries in the debug stream. - Verify symlink integrity across workspace boundaries:
ls -la node_modules/@internal
Root Cause: Recursive Graph Traversal & Cache Invalidation
The core issue stems from the bundler treating internal workspace packages as external dependencies requiring full pre-bundling on each cold start. When optimizeDeps lacks explicit workspace scoping, the resolver falls back to scanning each package individually, and packages that contain CJS entry points cause esbuild to process entire transitive dependency trees. This conflicts with the principles described in JavaScript Build Pipeline & Module Resolution Fundamentals, where static analysis should be bounded and incremental.
Architectural breakdown
- Mechanism: Recursive workspace dependency crawling triggers full ESM pre-bundling for every internal package on each cold start.
- Root cause: Default optimizer treats monorepo internal packages as external, forcing synchronous resolution of transitive CJS/ESM hybrids.
- Graph impact: Module graph initialization blocks the HTTP server until the dependency optimizer finishes scanning
node_modules/.vite.
Engineering steps
- Audit
package.jsonexportsvsmainfield mismatches in internal packages to identify legacy resolution paths. - Map
node_modules/.vite/depscache invalidation triggers by comparing file modification timestamps post-restart. - Temporarily set
server.fs.strict: falseto bypass symlink guardrails and confirm path resolution bottlenecks.
Exact Configuration & CLI Remediation
Apply a targeted vite.config.ts patch that scopes optimizeDeps.include to internal namespaces while excluding packages that should be resolved at runtime. Configure server.fs.allow to explicitly whitelist parent workspace directories.
Configuration patch (vite.config.ts)
import { defineConfig } from 'vite';
export default defineConfig({
optimizeDeps: {
include: ['@internal/ui-lib', '@internal/utils'], // Pre-bundle these specifically
exclude: ['@internal/shared-utils'], // Exclude if it's already native ESM
esbuildOptions: {
resolveExtensions: ['.ts', '.tsx', '.mjs', '.js']
}
},
server: {
fs: {
strict: false,
allow: ['../packages'] // Allow Vite to serve files from the monorepo root
},
watch: {
ignored: ['**/node_modules/**', '**/dist/**']
}
}
});CLI override Run the dev server with warm logging disabled to reduce startup I/O:
npx vite dev --logLevel warnIf you previously ran with --force and the cache seems stale, clear it and rebuild once:
rm -rf node_modules/.vite && npx vite devWorkspace package.json manifest
Ensure internal packages use exports-first manifests with a correct type: "module" declaration so Vite does not need to convert them:
{
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
}
}Fallback logic
If optimizeDeps.include causes duplicate chunking during HMR, move the problematic packages to optimizeDeps.exclude and rely on Viteโs native ESM pre-bundling fallback. Ensure server.fs.allow points to the absolute monorepo root if relative paths fail in CI environments.
Verification & Regression Protocol
Validate the fix by measuring cold start latency and confirming the pre-bundle cache hit rate exceeds 95%. Monitor HMR propagation across package boundaries to ensure no regression in update latency.
Performance targets
- Primary KPI: Cold start time under 8 s (measured via
time npx vite dev) - Secondary KPI: HMR update latency under 50 ms for cross-package imports
- Success threshold: Pre-bundle cache hit rate above 95% across consecutive restarts
Engineering steps
- Benchmark against the original baseline using
hyperfine:hyperfine --warmup 2 'npx vite dev' - Automate cache validation in CI pre-merge hooks by asserting
node_modules/.vite/deps/_metadata.jsonexists and contains valid dependency hashes:test -f node_modules/.vite/deps/_metadata.json && \ jq -e '.optimized | length > 0' node_modules/.vite/deps/_metadata.json - Use
rollup-plugin-visualizer(configured invite.config.ts) to verify that no duplicate chunks are generated from misconfiguredoptimizeDepsscopes after running a production build.