Vite's Dependency Pre-Bundling: How the Cache Works and When to Override It
Vite's dev server feels instant because esbuild pre-bundles node_modules into single-file ESM before the browser ever asks. Here's how the cache invalidates — and the monorepo case it misses.
31 Dec 2025, 11:57 UTC

The hundreds-of-requests problem pre-bundling solves
Vite's dev server serves your application source as native ES modules straight to the browser, with no bundling step. That's the trick behind its fast startup — and it would fall apart the moment you import a typical npm package. A package like lodash still ships CommonJS, which uses require() and module.exports — syntax the browser cannot execute at all. Even pure-ESM dependency trees can be deep: one import can cascade into hundreds of nested module requests, each a separate network round trip and parse.
Vite's answer is dependency pre-bundling. On dev-server start, it runs esbuild over your node_modules dependencies, converts CommonJS/UMD packages to ES modules, and concatenates each package into a single file under node_modules/.vite/deps. The browser then loads roughly one file per package instead of hundreds. esbuild's speed is what makes this practical: the pass is a pause you notice once, not a build you wait on.
Version note: this assumes Vite 5 or newer, where the optimizer runs in a background worker. Log messages and file layout can shift between versions, so treat the checks below as expectations to verify, not gospel.
What invalidates the pre-bundle cache — and what doesn't
The cache lives in node_modules/.vite/deps. Vite keys it on your package.json dependencies, a hash of your lockfile, and the relevant parts of your Vite config. Change any of those — run an install, bump a version, edit optimizeDeps — and the next dev start re-bundles.
Two consequences worth internalizing:
- Hot module replacement never touches the pre-bundle. Editing your components invalidates only app code; optimized deps stay cached until a version or config change. That's why HMR stays quick even in large apps.
- The cache key does not include plugin code. If you're editing a plugin that transforms dependencies and the dev server keeps serving stale output, force a rebuild: run
npx vite --forcefrom the project root (no special permissions needed), or deletenode_modules/.viteand restart.
You can inspect the cache inputs in node_modules/.vite/deps/_metadata.json, which records per-dependency hashes and a config hash — useful for confirming that an edit actually rotated the key.
Worked example: the monorepo waterfall
The most common place pre-bundling silently fails is a monorepo. Say your app imports @my/ui, a workspace package symlinked into node_modules by pnpm or yarn. Vite treats linked workspace packages as source code, not dependencies, so @my/ui is not pre-bundled by default. If that package or its own dependencies have a deep import graph or ship CommonJS, you're back to waterfall requests and 'new dependencies optimized' reload loops.
The fix is to force it into the optimizer:
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
optimizeDeps: {
include: ['@my/ui'],
},
})
Restart the dev server and verify in the browser's Network tab: @my/ui should now load as a single request served from /node_modules/.vite/deps/. For a closer look, run DEBUG=vite:deps npm run dev from the project root — Vite logs which packages the optimizer is bundling.
The trade-off: an included package is served from the optimized cache, so edits to its source won't behave like normal HMR — expect a dev-server restart to pick them up. If you're actively developing @my/ui, a better split is to leave it excluded and add only its heavy third-party dependencies to optimizeDeps.include.
When pre-bundling breaks a package
| optimizeDeps setting | Effect | Use it for |
|---|---|---|
| (default) | Pre-bundle everything found in node_modules | Ordinary third-party dependencies |
| include | Force pre-bundling | Linked workspace packages; deps discovered mid-session |
| exclude | Leave as individual files, no optimizer | Browser-ready ESM packages; debugging original sources |
Concatenation assumes dependencies tolerate being merged into one file. CommonJS packages with circular require() calls or dynamic require paths can misbehave once squashed together. The escape hatch is optimizeDeps.exclude, which leaves a package as individual files — but it only works for packages that already ship browser-runnable ESM. Excluding a CommonJS package just hands the browser require() and it fails.
Two more limitations to keep in mind:
- esbuild doesn't polyfill Node built-ins. Vite injects shims only for globals it detects, such as
processorBuffer; packages expecting a fuller Node environment may need explicitdefineentries or a polyfill plugin. - CI pays the pre-bundle cost on every cold start. Caching
node_modules/.vite/depsbetween runs helps, but key the cache on your lockfile — a stale cache paired with mismatched dependency versions produces exactly the confusing failures this article is about.
A five-minute diagnostic routine
- Open the Network tab during dev. Hundreds of requests from
node_modulesmean something escaped pre-bundling — usually a linked workspace package. - Check
node_modules/.vite/deps/_metadata.jsonto confirm what's cached and whether your last change rotated the hash. - Run
DEBUG=vite:deps npm run devto see the optimizer's decisions and timing. - When output looks stale after plugin or config edits, run
npx vite --forcebefore debugging anything else.
Pre-bundling is why Vite's dev server feels instant, and it asks very little of you — until a workspace package or an unusual CommonJS dependency slips past it. Knowing the cache rules and the two optimizeDeps knobs turns most 'why is my dev server slow again' sessions into a one-line config change.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.