Speeding Up Vite Cold Starts with Dependency Pre-Bundling

A Vite dev server’s first start is slow because it pre-bundles every dependency with esbuild before serving a request; get optimizeDeps wrong and it re-does that work mid-session, forcing full reloads. This guide makes the cold start fast and keeps it fast by controlling exactly what gets pre-bundled. It narrows the pre-bundling half of Vite build performance and caching; for the parent overview see Vite Configuration Ecosystem.

The reason pre-bundling exists at all is that Vite serves your application source as native ES modules over HTTP, one module per request, and lets the browser do the graph traversal. That model is fast for your own files but catastrophic for dependencies: a single import of lodash-es can fan out into six hundred internal modules, and a package like @mui/material pulls in thousands. Left unbundled, the browser would fire thousands of waterfalled requests on first paint, and any CommonJS package would not load at all because browsers cannot execute require. So before the first request is served, Vite runs esbuild over node_modules, flattens each dependency into a single ESM file, converts CJS to ESM in the process, and writes the result to node_modules/.vite/deps. That one-time conversion is what the cold start pays for.

The failure mode this guide targets is not the cold start itself — a few seconds once per lockfile change is acceptable — but the mid-session re-optimize. When Vite pre-bundles up front it only knows about the dependencies it could find by statically scanning your entry HTML and its import graph. Anything reachable only through a lazy route, a dynamic import(), or a conditional branch is invisible to that scan. The moment the browser requests such a module, Vite discovers a dependency it has not pre-bundled, stops the world, re-runs the optimizer, invalidates the module graph, and issues a full-page reload. In a large app this can happen several times in the first minute of use, and each reload throws away component state and scroll position. Getting optimizeDeps right converts that unpredictable, repeated stall into a single deterministic pass.

Cold start pre-bundles, warm start restores the cache On a cold start Vite scans imports and pre-bundles dependencies with esbuild into .vite/deps, taking seconds; on a warm start it finds the cache valid and serves immediately, and a dependency discovered late forces a mid-session re-optimize and reload. Pre-bundle once, then restore the cache cold startesbuild pre-bundle · seconds .vite/deps cachekeyed on lockfile warm startrestore · sub-second late-discovered dep → re-optimize + reload
Figure: the cache makes warm starts cheap; a dependency Vite did not see up front is what breaks the streak.

How pre-bundling works under the hood

The optimizer runs in three phases, and knowing which phase is misbehaving is most of the debugging. First, scan: esbuild is run in scan mode over the entry points (by default the index.html and everything statically imported from it) purely to enumerate which bare imports resolve into node_modules. This pass throws away its output; it exists only to build the list of optimize targets. Second, optimize: each discovered dependency is treated as a separate esbuild entry point and bundled into a single ESM file, with CommonJS and UMD packages run through esbuild’s interop so that both named and default imports work from your ESM source. Third, serve: Vite rewrites every bare import in your source (import _ from 'lodash') to point at the flattened file under /node_modules/.vite/deps/, and serves those with a strong Cache-Control: max-age=31536000, immutable header so the browser never re-requests them within a session.

The cache is keyed by a hash, not a timestamp. Vite computes a fingerprint over the things that could change the optimizer’s output — the resolved versions in your lockfile (package-lock.json, pnpm-lock.yaml, or yarn.lock), the relevant fields of vite.config.ts (optimizeDeps, resolve.alias, resolve.dedupe, plugins that declare themselves), and the patch state of any patched packages. That hash is written to node_modules/.vite/deps/_metadata.json alongside the bundled files. On the next start Vite recomputes the fingerprint and compares: if it matches, the entire deps directory is reused verbatim and the optimize phase is skipped, which is why a warm start is sub-second. If any input to the hash changed, the whole directory is discarded and rebuilt — pre-bundling is all-or-nothing, there is no incremental optimize of a single package.

This design has a direct consequence for how you tune it. Because the scan only sees static imports, the cure for late discovery is to add to the list the scan produces, which is exactly what optimizeDeps.include does — it appends entries to the optimize phase’s input regardless of whether the scan found them. And because the cache is content-addressed on the lockfile and config, anything that perturbs those inputs on every run (a lockfile regenerated in CI, a config value derived from Date.now(), a plugin whose identity changes) forces a cold start every time and no amount of include tuning will help.

Prerequisites & reproducible setup

# Vite 5.4.x, Node 20+
npm create vite@latest cold-start-demo -- --template react-ts
cd cold-start-demo && npm install
npm install lodash @mui/material          # CJS-heavy deps that pre-bundle slowly

The demo deliberately installs lodash (a CommonJS package that must be converted to ESM) and @mui/material (a large tree with many deep entry points) because those are the two shapes that dominate real cold-start cost: interop-heavy CJS and wide dependency graphs. A pure-ESM dependency like lodash-es still needs flattening to avoid the request waterfall, but it skips the CJS-to-ESM conversion, so it pre-bundles faster; if your slow start is dominated by one package, checking whether it ships CJS or ESM tells you which cost you are paying.

Time a cold start with the cache cleared, then a warm one, to see the gap the pre-bundle occupies:

rm -rf node_modules/.vite && time npx vite --port 5199 &   # cold

Read the timing from the ready in line Vite prints, not from wall-clock feel — on this small demo the cold start lands around 800ms to 1.5s and the warm start under 300ms, but the absolute numbers matter less than the ratio. What you are establishing is a baseline for the cold-minus-warm gap so that when you later add include entries you can confirm the change moved the number in the right direction rather than trusting that it “feels faster”. Delete node_modules/.vite between cold measurements; deleting all of node_modules also works but conflates dependency install time with pre-bundle time, which is not what you are measuring here.

The cold-versus-warm gap is the pre-bundle cost Timing a start with the .vite cache removed captures the cold pre-bundle cost, and timing a second start captures the warm restore; the difference between the two is exactly the pre-bundling work the cache saves. cold install + esbuild pre-bundle warm restore ← the gap is what the cache saves measure the gap before tuning it
Figure: the cold-minus-warm gap is the exact quantity optimizeDeps tuning targets.

Diagnosis workflow

  1. Watch for the re-optimize log. [vite] optimized dependencies changed. reloading mid-session means a dependency was discovered after startup — the signal that it needs to be pre-declared. The tell is when it appears. On a clean cold start Vite legitimately logs optimizing dependencies... once, before the server is ready; that is expected and healthy. The same message appearing after you interact with the page — clicking into a lazy route, opening a modal, hitting a code path guarded by a feature flag — is the pathological case, because it means the optimize you already paid for was incomplete and Vite is now redoing it and reloading the tab.

  2. Identify the late dep. It is usually one imported inside a lazily-loaded route or behind a conditional import(). Grep the reload log or run with --debug to see which module triggered re-optimization. The most direct method is DEBUG='vite:deps' npx vite, which prints each dependency as it is added to the optimize set with a reason; the entries that appear after the initial “dependencies bundled” line are your late-discovered packages, and their names go straight into include. If you cannot reproduce the trigger interactively, walk your React.lazy, loadable, and dynamic import() call sites — the offending dependency is almost always a transitive import of a route-level chunk, not something you import at the top level, which is precisely why the static scan missed it.

  3. Confirm the cache is being reused. A warm start should not print optimizing dependencies; if it always does, the cache is being invalidated (a changing lockfile or config). To distinguish a genuinely-changed input from a spurious bust, inspect node_modules/.vite/deps/_metadata.json between two starts: the top-level hash field is the fingerprint described above. If it differs across two runs where you changed nothing, something feeding the hash is non-deterministic — most often a vite.config.ts that computes an alias or define value from the environment, a timestamp, or a freshly-resolved path on each load. Pin those to stable values and the hash stabilizes.

The re-optimize log is the diagnostic signal If the optimizing dependencies line appears only on the first cold start the cache is healthy, but if it reappears mid-session a dependency was discovered late and belongs in optimizeDeps.include, and if it appears on every start the cache is being invalidated. cold start onlycache healthy ✓ mid-session→ optimizeDeps.include every start→ cache invalidated when the log appears tells you the fix
Figure: the timing of the "optimizing dependencies" line maps directly to which fix applies.

Annotated solution config

The fix has two independent levers, and it is worth being clear about which problem each one solves because they are frequently confused. optimizeDeps.include addresses completeness — it forces named packages into the optimize phase whether or not the scan found them, so late-discovered dependencies are already bundled by the time the browser asks for them. holdUntilCrawlEnd addresses timing — it tells Vite to wait until the full import crawl has finished before committing to the first optimize, so it does not fire an early partial optimize and then immediately redo it when the crawl turns up more. You reach for include when a specific dependency reloads mid-session; you reach for holdUntilCrawlEnd when the cold start itself optimizes twice in quick succession.

// vite.config.ts — Vite 5.4.x
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  optimizeDeps: {
    // Pre-bundle these up front so they never trigger a mid-session reload.
    // List anything imported lazily or dynamically that Vite cannot see
    // during its initial static scan.
    include: [
      'lodash',
      '@mui/material',
      '@mui/material/Button',   // deep import: declare the exact subpath
    ],
    // Let esbuild pre-bundle even linked workspace deps (monorepos).
    // holdUntilCrawlEnd defers the first optimize until the scan finishes,
    // avoiding an early partial optimize that gets redone.
    holdUntilCrawlEnd: true,
  },
});
include pre-declares, holdUntilCrawlEnd defers the first optimize optimizeDeps.include pre-declares dependencies Vite cannot see in its static scan so they are bundled up front, while holdUntilCrawlEnd waits for the crawl to finish before the first optimize so an early partial pass is not redone. include: [...]pre-declare lazy/deep importsbundled up front holdUntilCrawlEndwait for the scanone optimize, not two declare what the scan misses, and defer the first pass
Figure: the two options attack the same reload from opposite ends — completeness and timing.

A word of caution on the temptation to over-declare: include is not free. Every entry you add is a package esbuild must bundle up front, so listing your entire dependency tree “to be safe” inflates the cold start you are trying to shrink and can pull in packages a given session never actually uses. The discipline is to add only the dependencies you have observed triggering a mid-session reload, confirmed via the vite:deps debug output, and to remove entries when the code that imported them lazily goes away. Treat include as a list of known scan blind spots, not a manifest of everything you depend on.

The complementary option, optimizeDeps.exclude, does the opposite and is almost never what you want for cold-start work. It removes a package from pre-bundling so Vite serves it as raw source over native ESM. That is occasionally correct for a small, already-ESM local library you are actively editing and want HMR on without a re-optimize, but for anything CJS or anything with a wide internal graph it reintroduces exactly the interop failures and request waterfalls pre-bundling exists to prevent.

For a monorepo where the same heavy dependencies recur across several apps, factor the shared list into a helper rather than copy-pasting it, so a package that needs pre-declaring is declared everywhere at once:

// vite.shared.ts — Vite 5.4.x
// Shared optimizeDeps used across every app in the monorepo.
export const sharedPrebundle = [
  'lodash',
  '@mui/material',
  '@mui/material/Button',
  '@emotion/react',    // MUI's styling engine, imported deep in its tree
  '@emotion/styled',
];

// vite.config.ts in each app — Vite 5.4.x
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { sharedPrebundle } from '../../vite.shared';

export default defineConfig({
  plugins: [react()],
  optimizeDeps: {
    include: [...sharedPrebundle, 'app-only-lazy-dep'],
    holdUntilCrawlEnd: true,
  },
});

Verification

# Vite 5.4.x — prove the warm start skips pre-bundling.
rm -rf node_modules/.vite
npx vite --port 5199   # cold: prints "optimizing dependencies"; ctrl-C
npx vite --port 5199   # warm: NO optimize line, ready in a fraction of the time

A correct setup optimizes exactly once (cold), then restores silently on every warm start with no mid-session reload as you navigate lazy routes. If you still see a reload, the triggering dependency is missing from include.

The stricter version of this check exercises the code paths that produced the late imports in the first place. A warm start that stays silent while you sit on the home page proves nothing about the lazy dashboard route that was the actual problem. Navigate every lazy route, open every dynamically-loaded modal, and toggle any feature flag that gates an import(), all while watching the terminal. Only a run that touches every dynamic entry point and never re-optimizes has actually validated the fix. If you want this to be repeatable rather than a manual click-through, drive it in CI with a headless smoke test that visits each route and asserts the dev server logged the optimize line exactly once; the CI integration section below sketches that.

You can also prime the cache without a browser at all using the server-side warm-up API, which is useful in a predev script so the first human to open the app never waits:

// warmup.ts — Vite 5.4.x
// Boot the dev server, let it pre-bundle, then exit — populating .vite/deps.
import { createServer } from 'vite';

const server = await createServer({ server: { port: 5199 } });
await server.listen();
// listen() resolves only after the initial optimize completes.
await server.close();
console.log('pre-bundle cache warmed at node_modules/.vite/deps');
Optimize exactly once, then silence The verification is that the optimizing dependencies line appears on the cold start and never again on warm starts or while navigating lazy routes; a reappearance points to a dependency still missing from include. cold: optimize onceexpected warm + navigation: silentno reload one optimize line total is the success condition
Figure: success is a single optimize on the cold run and silence thereafter.

Gotchas & edge cases

Four pre-bundling edge cases Deep subpath imports must be listed explicitly in include; excluding a CJS dep removes its interop wrapper and breaks default imports; a changing lockfile invalidates the cache every start; and linked workspace packages need explicit include to be pre-bundled. deep subpath import→ list the exact subpath exclude a CJS dep→ breaks default import changing lockfile→ cache busts every start linked workspace dep→ add to include
Figure: deep subpaths and linked workspace packages are the two Vite's static scan most often misses.
  • Deep imports need explicit entries. import Button from '@mui/material/Button' is a distinct optimize target from @mui/material; list the subpath. The symptom is a reload the first time you render a component that uses a deep import even though the barrel package is already in include. The root cause is that Vite treats each subpath as its own optimize entry — bundling @mui/material does not implicitly bundle @mui/material/Button — so the deep path is discovered fresh when first requested. The fix is to add every deep subpath you import to include explicitly; confirm it by requesting that component and seeing no optimize line. Where a library exposes dozens of subpaths, prefer importing from its top-level barrel so there is a single optimize target, unless tree-shaking cost in the production build makes that a bad trade.

  • Do not exclude CJS deps. Excluding a CommonJS package removes the pre-bundle interop wrapper and breaks default imports. The symptom is a runtime error like X is not a function or default is not exported, appearing only after you added the package to exclude. The root cause is that a raw CJS module served over native ESM has no synthesized default binding — that binding is manufactured by esbuild during pre-bundling, which excluding skips. The fix is to remove the package from exclude and, if you were excluding it to get HMR on a local build, publish an ESM entry point instead. Confirm by reverting the exclude and watching the failing import resolve.

  • Volatile lockfile. If CI regenerates the lockfile each run, the cache key changes and pre-bundling never warms; commit a stable lockfile. The symptom is a cold start on every CI job despite a restored .vite cache, and a _metadata.json hash that differs each run. The root cause is that the lockfile feeds the fingerprint, so a lockfile written fresh by npm install (rather than restored by npm ci from a committed lock) invalidates the cache before Vite even reads it. The fix is to commit the lockfile and install with the frozen-lockfile flag (npm ci, pnpm install --frozen-lockfile); confirm by diffing the hash across two jobs. Persisting the cache across runners is covered in the linked CI guide below.

  • Linked workspace packages. A file:/workspace dependency may not be pre-bundled unless listed in include. The symptom is either a request waterfall through the linked package’s internal files or a re-optimize when one of them is first imported. The root cause is that Vite by default leaves workspace-linked source out of pre-bundling so that edits to it get HMR, which is the right default while you are editing it but wrong for a stable shared library you only consume. The fix is to add the linked package to include so it is flattened like any third-party dependency; confirm by importing from it and seeing a single bundled request under /node_modules/.vite/deps/ rather than many requests into the linked path.

Performance considerations

Two variables dominate cold-start duration: the number of optimize entries and the CJS-to-ESM conversion cost per entry. Adding entries to include trades a larger, more predictable cold start for the elimination of unpredictable mid-session reloads — a good trade for a dev loop, since a reload that discards component state costs a developer far more than a few hundred milliseconds at boot. But the trade is only worth making for dependencies actually reached in a typical session; pre-bundling a package that a given run never imports is pure waste. Because the optimize phase is all-or-nothing on a cache miss, the marginal cost of one more include entry is only paid on cold starts, not warm ones, which is another reason to keep the cache warm rather than to trim the list aggressively.

The single highest-leverage move for a slow team is not tuning include at all but ensuring warm starts actually happen. A developer whose node_modules/.vite is wiped by a postinstall script, an over-broad clean task, or a branch switch that changes the lockfile pays the full cold cost every morning. Audit for anything that deletes .vite and remove it; the cache is safe to keep because its own fingerprint guarantees it is rebuilt whenever an input changes.

CI integration

Pre-bundling is a dev-server concern, so a production vite build does not use optimizeDeps at all — Rollup handles dependency bundling in the build path. The reason to think about CI here is different: integration and end-to-end tests that run against vite in dev mode (Playwright, Cypress, or a Vitest browser run) pay the cold-start cost on every job unless the .vite cache is restored. The pattern is to warm the cache once, cache the node_modules/.vite directory keyed on the lockfile hash, and restore it on subsequent runs.

# .github/workflows/e2e.yml — GitHub Actions
- uses: actions/setup-node@v4
  with: { node-version: 20, cache: npm }
- run: npm ci                              # frozen lockfile: stable cache key
- uses: actions/cache@v4
  with:
    path: node_modules/.vite
    # Keying on the lockfile means the cache busts only when deps change,
    # matching Vite's own fingerprint so a hit is always a valid hit.
    key: vite-deps-${{ hashFiles('package-lock.json') }}
- run: node warmup.ts                      # populate .vite/deps before tests
- run: npx playwright test

Note the cache key mirrors the input to Vite’s own fingerprint: keying on the lockfile hash means a cache hit corresponds to a state where Vite would also consider its cache valid, so you never restore a .vite directory Vite will immediately discard. Deeper strategies for sharing this cache across many jobs and runners live in the linked CI guide.

When not to use this

Reach for manual include tuning only once you have an observed mid-session reload. On a small app with no lazy routes and no dynamic imports, the static scan already finds everything, the cold start is a second or two, and hand-maintaining an include list is overhead with no payoff — worse, a stale list can slow the cold start by pre-bundling packages you have since stopped importing. Similarly, if your slow start turns out to be dependency install rather than pre-bundle time (measure the two separately, as noted above), no optimizeDeps change will help; that is a package-manager and lockfile problem. And if the same package re-optimizes no matter how you configure it, suspect a non-deterministic config feeding the fingerprint before you suspect the include list — the diagnosis workflow’s _metadata.json hash check will tell you which it is.