Vite dependency pre-bundling: why dev goes stale and how optimizeDeps fixes it
Vite pre-bundles node_modules with esbuild in dev, so a stale optimized bundle is usually a cache and discovery problem — not a broken install.
19 Nov 2025, 06:31 UTC

The symptom: a fresh install that behaves like an old one
You add a package, and the dev server either throws a bare-import resolution error or keeps serving behavior from a version you already replaced. Restarting the dev server fixes it, which makes the problem feel haunted rather than configurable.
The reason is that Vite's dev server does not hand your browser raw node_modules. It pre-bundles dependencies with esbuild into ESM, writes them to a generated cache directory, and serves those files instead. That cache has discovery rules and invalidation rules. It is not a live watcher over every package you might import.
What the optimizer actually does
Two jobs. First, it converts CommonJS and UMD packages to ESM, because browsers cannot import CommonJS directly. Second, it coalesces many deep imports into a single file so a page with hundreds of imports does not fire hundreds of network requests.
Vite scans your entry HTML and source imports to find which bare specifiers are actually used, then pre-bundles those. The output lands in a generated directory, commonly node_modules/.vite — treat the exact path as version-dependent and check your installed Vite major plus any framework plugin, since plugins and SSR modes can change it.
Lockfile changes, config changes, and package.json changes typically trigger re-optimization. What gets missed: newly linked workspace packages, optional dependencies pulled in conditionally, and packages only reachable through a path the scanner did not traverse.
Why "restart fixes it" is a clue, not a fix
A restart forces a fresh scan. If the same dependency goes stale again next week, the discovery rules are not seeing it, and that belongs in config rather than in someone's memory.
Worked example: pinning a heavy dependency and widening the scan
Scenario: a route-heavy app imports one heavy utility package from many files. The first dev request triggers a discover-then-bundle pass, which is slow and can land mid-navigation. In a monorepo, workspace packages outside the default entry globs may never be scanned at all.
// vite.config.js — config shape shown for Vite 4/5-era projects.
// Verify option names against the Vite major you have installed.
import { defineConfig } from 'vite'
export default defineConfig({
optimizeDeps: {
// Pre-bundle on cold start instead of on first import.
include: ['heavy-utils', 'some-cjs-only-package'],
// Widen the scan so workspace packages are discovered.
entries: [
'index.html',
'src/**/*.{js,ts,jsx,tsx}',
'packages/*/src/**/*.{js,ts,jsx,tsx}',
],
},
})
Where to run: this is a project-root config file, no elevated permissions needed. Restart the dev server after editing — optimizeDeps changes are not applied by hot module replacement. Some Vite versions expose a force-re-optimize flag on the dev command; check vite --help for your installed version rather than assuming a flag name.
include is a hint that forces pre-bundling. It does not change which version is installed and does not repair a broken package export. If the package is wrong, you get a faster wrong.
| Symptom | Likely cause | Check |
|---|---|---|
| Bare import fails in dev, works after restart | Dependency not found by the scan | Add it to optimizeDeps.include, restart |
| Dev works, production build fails on a named import | Dev CJS interop shim masking a missing ESM export | Run the build and preview, inspect the package's exports |
| Workspace package edits not reflected | Entry globs do not cover it | Widen optimizeDeps.entries, restart |
| Cache regenerates constantly | Lockfile or config churn | Look for tooling rewriting the lockfile |
Trade-offs and limitations
Over-including makes cold start heavier and cache invalidation more expensive, because every entry must be re-bundled whenever the cache is dropped. It can also hide real dependency-graph problems: if a package is only reachable through a path your scanner misses, forcing it into include papers over the miss instead of fixing it.
Second limitation: dev output is not the production build. Production uses Rollup and does not apply the same CommonJS interop shim, so a named import that works in dev can fail after build. Verify with a real production build before trusting the dev result.
Third: never edit or commit the generated deps cache. It is derived output. If it is not already ignored, add it to .gitignore.
How to verify
- Delete the deps cache directory (commonly
node_modules/.vite) and restart the dev server. Confirm the package appears in the regenerated cache. - Change the lockfile by reinstalling a dependency, and confirm the cache is rebuilt rather than reused.
- Run the production build and preview it. Confirm the same import works without the dev pre-bundle layer.
- In a monorepo, import a workspace package that falls outside your entry globs. If it is missed, widen
entriesand restart.
Treat the optimizer cache as disposable. Put "delete the deps cache and restart" in your onboarding notes, and when the same dependency goes stale twice, move the fix into optimizeDeps instead of leaving it as tribal knowledge.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.