Vite Module Graph and Dependency Resolution

Without explicit control over how Vite builds and traverses its module graph, development servers bloat to hundreds of parallel HTTP requests, production builds produce duplicate vendor code across routes, and monorepo cold-start times creep past 30 seconds. The root cause is always the same: the on-demand ESM graph that makes Vite’s dev server fast operates under entirely different rules to the Rollup-driven static DAG that generates your production bundles.

This page is part of the JavaScript Build Pipeline & Module Resolution Fundamentals guide. It explains precisely how both graphs are constructed, how esbuild pre-bundling bridges them, and what configuration is required to keep both graphs correct and fast.


How Vite’s Two-Phase Graph Architecture Works

Vite operates two distinct module graphs that never run simultaneously.

Development graph (native ESM, on-demand): The browser drives traversal. When a route is visited, the browser requests the entry module; Vite intercepts via a connect middleware, runs resolveIdloadtransform hooks, and returns the result. Each subsequent import statement causes another browser request. The graph is constructed incrementally — only modules reachable from visited routes are ever processed.

Production graph (Rollup DAG, full static analysis): Running vite build serialises the entire dependency tree reachable from every configured entry point, applies tree-shaking, evaluates manualChunks rules, and emits hashed chunks. This phase performs exhaustive analysis that the dev server deliberately skips.

The diagram below shows both phases and the esbuild pre-bundling step that normalises node_modules for the dev graph.

Vite Two-Phase Module Graph Architecture Left side shows the development graph driven by browser ESM requests through Vite middleware with esbuild pre-bundling for node_modules. Right side shows the production build where Rollup performs full static analysis and emits hashed chunks. Development (native ESM) Browser Vite Middleware resolveId → load → transform esbuild pre-bundle .vite/deps cache node_modules src/ modules HMR propagates up import chain to nearest accept() boundary Production (vite build) Entry Points Rollup Static Analysis tree-shake + manualChunks vendor [hash].js route-a [hash].js shared [hash].js 15–25% smaller payload vs unoptimised ESM delivery

Native ESM Graph vs Webpack 5’s Upfront Static Analysis

Understanding this architectural difference explains why Vite’s cold-start performance and Webpack 5’s production optimisation footprint sit at opposite ends of the spectrum.

With Webpack 5, webpack --watch performs full static analysis of every reachable module before serving anything. The entire dependency tree is compiled into a chunk graph up front, consuming 40–60% more RAM during development than Vite for the same codebase. Any import change requires a partial or full rebuild before the browser sees updated output.

Vite’s dev server skips that upfront work entirely. When the browser requests main.tsx, Vite processes only that file, then processes each transitive import as the browser issues separate HTTP requests. Engineers switching from Webpack 5 should read Understanding ES Modules vs CommonJS in Bundlers to understand why native ESM resolves this way and what constraints it imposes on CJS interop.

The trade-off is network overhead: a large application with 800+ modules generates 800+ HTTP requests on first paint. HTTP/2 multiplexing manages this in practice, but it remains a hard limit for very large graphs. Vite’s server.warmup.clientFiles partially mitigates cold-paint latency by pre-transforming known entry modules before the browser asks.

Side-by-side dev configuration

// Webpack 5 — webpack.config.js
// Full upfront static analysis; slower cold start, faster incremental rebuild for large graphs
const path = require('path');

module.exports = {
  mode: 'development',
  entry: './src/main.tsx',
  resolve: {
    alias: { '@': path.resolve(__dirname, './src') },
    extensions: ['.tsx', '.ts', '.js'],
  },
  devServer: {
    hot: true,
    port: 3000,
  },
  cache: {
    type: 'filesystem', // Persistent cache cuts cold start by ~60% on subsequent runs
  },
};
// Vite 5+ — vite.config.ts
// On-demand ESM graph; sub-500 ms cold start regardless of graph size
import { defineConfig } from 'vite';
import path from 'path';
import { fileURLToPath } from 'url';

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

export default defineConfig({
  resolve: {
    alias: { '@': path.resolve(__dirname, './src') },
  },
  server: {
    fs: { strict: true },  // Prevent directory traversal outside project root
    warmup: {
      clientFiles: ['./src/main.tsx', './src/routes/index.tsx'],
    },
  },
  plugins: [
    {
      name: 'virtual-module-resolver',
      resolveId(id) {
        if (id === 'virtual:config') return '\0virtual:config';
      },
      load(id) {
        if (id === '\0virtual:config') return 'export const ENV = "production";';
      },
    },
  ],
});

Dependency Pre-Bundling and the esbuild Optimisation Phase

node_modules packages arrive in a mix of CommonJS, UMD, and ESM formats. Serving each one as individual HTTP requests through the native ESM graph would generate thousands of requests for a package like lodash-es (which ships as ~600 individual ES modules). Vite’s pre-bundling phase solves both problems: it converts CJS/UMD to ESM and flattens each package’s module tree into a single file using esbuild.

The output lands in node_modules/.vite/deps/. A _metadata.json file records the dependency hash; when package-lock.json or yarn.lock changes, Vite invalidates the cache and re-runs esbuild automatically on next dev-server start.

Pre-bundling is also the correct intervention point when a CJS package causes silent require is not defined errors — a root-cause pattern covered in detail in the Converting CJS Libraries to ESM for Better Bundling guide.

Pre-bundling configuration (Vite 5+)

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

export default defineConfig({
  optimizeDeps: {
    // Force pre-bundling even when Vite cannot auto-detect the import
    include: [
      'lodash-es',
      'date-fns',
      'some-pkg > transitive-cjs-dep',  // Nested CJS inside an ESM wrapper
    ],
    // Skip pre-bundling for packages that are already valid ESM
    exclude: ['my-native-esm-lib'],
    esbuildOptions: {
      target: 'es2020',
    },
  },
});

When to add include entries manually:

  • A package deep inside a dynamic import() is not reachable from a static entry and Vite misses it during the initial scan
  • A CJS package generates browser errors on first load (require is not defined)
  • A package with 50+ internal modules causes an HTTP waterfall visible in DevTools

CI cache integrity validation

#!/bin/bash
# Vite 5+ — scripts/validate-vite-deps.sh
set -e

DEPS_DIR="node_modules/.vite/deps"
METADATA="$DEPS_DIR/_metadata.json"

# Vite auto-invalidates based on lockfile hash — this script adds a belt-and-suspenders
# check for CI environments where lockfile changes may not have triggered a fresh install
if [ ! -f "$METADATA" ]; then
  echo "Pre-bundle cache missing. Running initial optimisation..."
  npx vite optimize
  exit 0
fi

CACHE_HASH=$(node -e "const m=require('./$METADATA'); console.log(m.hash || '')")

if [ -z "$CACHE_HASH" ]; then
  echo "Metadata hash missing — forcing re-optimisation"
  rm -rf "$DEPS_DIR"
  npx vite optimize
fi

Production Chunk Graph: Rollup Static Analysis and manualChunks

Running vite build exits the on-demand dev model entirely. Vite hands every entry point to Rollup, which performs exhaustive static analysis: it traces every import, constructs a full DAG, applies tree-shaking (removing exported symbols with no live bindings), then evaluates your manualChunks function to assign modules to named output files.

This process differs fundamentally from Webpack 5’s chunk generation lifecycle, which resolves chunk boundaries through a separate SplitChunksPlugin algorithm rather than user-supplied functions. Both approaches produce shared common chunks to prevent the same module being emitted into multiple route chunks, but the control surface is different.

The manualChunks callback receives the absolute module ID and should return a chunk name string. Returning undefined lets Rollup decide. Returning a fixed name for all node_modules creates a single vendor chunk — appropriate for applications where third-party code changes rarely.

Production build configuration (Vite 5+)

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

export default defineConfig({
  build: {
    target: 'es2020',
    minify: 'esbuild',
    sourcemap: true,   // Required for source map debugging workflows
    rollupOptions: {
      output: {
        manualChunks(id) {
          // Isolate third-party code into a long-lived vendor chunk
          if (id.includes('node_modules')) {
            // Split large frameworks separately to avoid one monolithic vendor chunk
            if (id.includes('react') || id.includes('react-dom')) {
              return 'vendor-react';
            }
            return 'vendor';
          }
          // Keep framework-core utilities in a stable shared chunk
          if (id.includes('src/framework/')) {
            return 'framework-core';
          }
          // Route modules fall through — Rollup assigns them to their entry chunk
        },
      },
    },
  },
});

Equivalent Webpack 5 splitChunks configuration

// Webpack 5 — webpack.config.js
module.exports = {
  mode: 'production',
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        // Mirrors the Vite manualChunks vendor split above
        vendorReact: {
          test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
          name: 'vendor-react',
          priority: 20,
        },
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendor',
          priority: 10,
        },
        frameworkCore: {
          test: /[\\/]src[\\/]framework[\\/]/,
          name: 'framework-core',
          priority: 5,
        },
      },
    },
  },
};

Framework Integration: React and Vue 3 Patterns

Vite’s transform hook is where framework-specific compilers run. The @vitejs/plugin-react plugin injects React Fast Refresh boundaries and compiles JSX. Vue’s @vitejs/plugin-vue compiles single-file components into plain JS with defineComponent calls. Both plugins register as nodes in the dev module graph — meaning HMR updates flow through the same resolveId → load → transform chain as application code.

React: lazy() with Suspense and Vite dynamic imports

// Vite 5+ / React 18 — src/routes/index.tsx
import { lazy, Suspense } from 'react';

// Vite statically analyses this pattern and emits a separate chunk per route.
// The import() hint comment controls the chunk file name in dist/assets/.
const DashboardPage = lazy(() => import(/* @vite-chunk-name: "route-dashboard" */ './Dashboard'));
const SettingsPage  = lazy(() => import(/* @vite-chunk-name: "route-settings" */  './Settings'));

export function AppRouter() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      {/* Routing logic here */}
      <DashboardPage />
    </Suspense>
  );
}

For a deeper treatment of the React.lazy + Suspense pattern including route transition edge cases, see Implementing Route-Level Code Splitting in SPAs.

Vue 3: defineAsyncComponent with Vite

// Vite 5+ / Vue 3 — src/router/index.ts
import { defineAsyncComponent } from 'vue';

// defineAsyncComponent wraps a dynamic import() — Vite emits one chunk per component.
const DashboardView = defineAsyncComponent(
  () => import('./views/DashboardView.vue'),
);

const SettingsView = defineAsyncComponent({
  loader: () => import('./views/SettingsView.vue'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorDisplay,
  delay: 200,
  timeout: 5000,
});

In a pnpm or Yarn workspace, packages reference each other via symlinks under node_modules/@scope/pkg. Vite’s default resolve.preserveSymlinks: false resolves symlinks to their real disk path before matching against the module graph — this can cause the same module to appear twice in the graph under different IDs, breaking HMR and producing duplicate code in production bundles.

Setting resolve.preserveSymlinks: true forces Vite to treat symlinked paths as canonical, matching pnpm’s own resolution behaviour. The trade-off: two genuinely different packages that happen to share the same real path are now treated as one module, which is almost always correct in a managed workspace.

For advanced monorepo startup optimisation including lazy workspace loading and per-package dep caching, see Optimizing Dev Server Startup Times for Large Monorepos.

Monorepo configuration (Vite 5+)

// Vite 5+ — vite.config.ts (monorepo root or per-app config)
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  resolve: {
    // Align with pnpm/yarn workspace layouts — treat symlinks as canonical
    preserveSymlinks: true,
  },
  server: {
    warmup: {
      // Pre-transform these files before the browser requests them
      clientFiles: ['./src/main.tsx', './src/routes/index.tsx'],
    },
  },
  plugins: [react()],
});

Workspace build validation

#!/bin/bash
# Vite 5+ — scripts/validate-monorepo-graph.sh
set -e

pnpm install --frozen-lockfile
pnpm exec vite build --mode staging

# Workspace protocol specifiers must not survive into the dist output.
# If they do, the bundler failed to resolve a cross-package import.
if grep -r '"workspace:' dist/; then
  echo "FAIL: Workspace package specifiers not resolved in build output"
  exit 1
fi

echo "PASS: Monorepo graph resolution validated"

Quantified Impact Metrics

  • Dev cold-start time: Sub-500 ms for apps with 500–1 000 source modules because only the entry file is processed at start; compare with 8–15 s for the equivalent Webpack 5 full upfront compilation.
  • RAM during development: 40–60% lower memory footprint than Webpack 5 for the same codebase, because the dev graph is populated lazily per request rather than compiled upfront.
  • Pre-bundling startup cost: esbuild processes node_modules in 1–3 s on a warm machine; this is a one-time cost per node_modules change, not per module request.
  • Production bundle size: Rollup’s aggressive tree-shaking produces 15–25% smaller final payloads compared to unoptimised ESM delivery, given a well-configured manualChunks strategy.
  • HMR latency: Module-level boundaries mean HMR updates propagate in 20–80 ms for most changes; Webpack 5 chunk-level HMR typically takes 150–400 ms for the same edit in a comparable application.

Common Pitfalls

Undetected dynamic imports cause a full-page reload mid-session

Root cause: Vite’s initial scan runs at dev-server start. Dynamic imports buried inside conditional logic or inside node_modules packages are not visible to the scanner. When the browser requests one at runtime, Vite must re-run esbuild, invalidating the existing module graph and forcing a full reload.

Diagnostic signal: Browser console shows [vite] new dependencies found: some-pkg; reloading page.

Fix: Add the missed dependency to optimizeDeps.include. If the package is a transitive dependency inside another package, use the pkg > transitive syntax: 'outer-pkg > inner-cjs-dep'.

Circular imports produce silent undefined values in dev but crash in production

Root cause: Vite’s dev graph processes circular dependencies at request time: module A requests module B, which hasn’t finished executing yet, so B’s exports are undefined at the point A reads them. In production, Rollup detects the cycle and logs a warning but resolves it via hoisting — often producing different behaviour to the dev graph.

Diagnostic signal: A value that works during development throws TypeError: Cannot read properties of undefined in the production bundle, with the stack trace pointing to a re-exported value.

Fix: Break the cycle by extracting shared state or types into a dedicated module that neither party imports. Run npx vite --debug vite:resolve to surface circular dependency chains during development.

manualChunks creates a chunk with no entry point, causing a ChunkLoadError

Root cause: If manualChunks assigns a module to a chunk name that is never referenced by an entry point’s import chain at runtime, the chunk is emitted but never loaded. Async routes that lazily depend on it receive a ChunkLoadError in the browser because the chunk was not preloaded.

Diagnostic signal: ChunkLoadError: Loading chunk framework-core failed in the browser console, typically on route navigation.

Fix: Ensure every named manual chunk is reachable from at least one entry point via static or dynamic imports. Use <link rel="modulepreload"> or Vite’s build.rollupOptions.output.experimentalMinChunkSize to merge small orphaned chunks rather than isolating them.

Source maps missing after production build

Root cause: build.sourcemap defaults to false in Vite 5+. Without source maps, production errors reference hashed chunk filenames rather than original source paths, making debugging impractical. The source map generation and debugging workflows guide explains the full configuration and upload pipeline for error monitoring tools.

Fix: Set build.sourcemap: true (inline) or build.sourcemap: 'hidden' (external file, not referenced in the bundle — appropriate for production where you upload maps to a monitoring service but don’t serve them publicly).


Verification Workflow

1. Inspect the dev graph with --debug

# Vite 5+ — trace resolveId and transform hook execution in real time
npx vite --debug vite:resolve,vite:transform 2>&1 | tee vite-debug.log

# Look for lines starting with [vite:resolve] that show unexpected file paths
# or [vite:transform] lines with unusually high hook durations (>50 ms per module)
grep 'duration' vite-debug.log | sort -t: -k2 -rn | head -20

2. Profile the production build

# Vite 5+ — write a Chrome DevTools CPU profile to the project root
npx vite build --profile

# Inspect the pre-bundle metadata to see which packages were optimised
node -e "const m=require('./node_modules/.vite/deps/_metadata.json'); console.log(JSON.stringify(Object.keys(m.optimized), null, 2))"

3. CI bundle budget gate

# Vite 5+ — .github/workflows/bundle-budget.yml
- name: Enforce production bundle budgets
  run: |
    npx vite build
    CHUNK_COUNT=$(find dist/assets -name "*.js" | wc -l)
    TOTAL_KB=$(du -sk dist/assets | awk '{print $1}')

    echo "Chunk count: $CHUNK_COUNT (max: 15)"
    echo "Total size: ${TOTAL_KB} KB (max: 250 KB)"

    [ "$CHUNK_COUNT" -le 15 ] || { echo "FAIL: Too many chunks ($CHUNK_COUNT)"; exit 1; }
    [ "$TOTAL_KB" -le 256 ]   || { echo "FAIL: Bundle exceeds 250 KB budget (${TOTAL_KB} KB)"; exit 1; }

4. DevTools verification checklist

  1. Open the Network tab filtered to JS. On first load, confirm vendor-react.[hash].js and vendor.[hash].js are loaded once and then served from disk cache on subsequent navigations.
  2. Navigate to a lazy route. Confirm a new chunk request appears — the lazy chunk — without the vendor chunks re-downloading.
  3. Check the Console for [vite] new dependencies found warnings (dev) or ChunkLoadError messages (production). Either indicates a graph configuration issue.
  4. In the Performance tab, verify that the initial JS parse budget stays below 150 ms on a throttled 4x CPU profile for the first-paint route.

FAQ

Why does Vite’s dev server feel instant but the production build still takes time?

Development uses native ESM: modules are transformed on request, so the browser drives graph traversal rather than the bundler. Production hands the full DAG to Rollup for exhaustive static analysis, tree-shaking, and chunk splitting — a fundamentally different pipeline that cannot be skipped.

When should I add a package to optimizeDeps.include?

Add it when Vite fails to auto-detect the package during cold start (common with packages deep inside dynamic imports or conditional requires), when a CJS package generates many small HTTP requests, or when you want to guarantee a specific esbuild target for that dependency.

What causes the “dependency has been changed” full-page reload during development?

Vite invalidates the pre-bundle cache when it detects a new unoptimised import at runtime. It re-runs esbuild for that dependency and forces a full reload because the module graph topology has changed. Pin frequently-changing internal packages to optimizeDeps.include to pre-empt the detection.

How does Vite’s HMR graph boundary differ from Webpack 5’s?

Vite propagates HMR updates up the import chain until it reaches a module that declares an explicit accept() boundary or the HMR root. Webpack 5 similarly walks the dependency graph upward but rebuilds the affected chunk. With Vite, fine-grained module-level boundaries mean HMR updates are typically 5–20x faster in large applications.