Fixing optimizeDeps Reload Loops in the Vite Dev Server
The dev server starts, the page loads, and then it does not stop:
VITE v5.4.2 ready in 412 ms
[vite] ✨ new dependencies optimized: lodash-es/debounce
[vite] ✨ optimized dependencies changed. reloading
[vite] ✨ new dependencies optimized: @acme/charts
[vite] ✨ optimized dependencies changed. reloading
[vite] ✨ new dependencies optimized: date-fns/locale/en-GB
[vite] ✨ optimized dependencies changed. reloading
Each reload discards component state, resets the route, and interrupts whatever was being worked on. In the worst version the loop never converges, because each reload’s navigation discovers a dependency the previous one did not.
Root cause: discovery is incremental, and reloading is how it converges
Vite pre-bundles dependencies before serving them, for two reasons described in Vite’s module graph and dependency resolution: CommonJS packages must be converted to ES modules the browser can load, and packages composed of many small files must be collapsed so the browser does not issue hundreds of requests.
At startup, a scanner crawls the entry’s static import graph and pre-bundles everything it finds. But it cannot see everything. A dependency imported only inside a lazily-loaded route is invisible until that route loads; a dependency reached through an interpolated path cannot be resolved statically at all. When one shows up at runtime, Vite optimizes it — and because the already-loaded modules hold references to the previous optimized bundle, it must reload the page to hand out the new one.
One reload is normal. A loop means each reload discovers something new.
Fix 1: declare what the scanner cannot find
The log names the dependency on every pass. Collect those names and declare them.
// vite.config.js — Vite 5+
export default {
optimizeDeps: {
// Dependencies the entry scan cannot reach: imported only from lazily
// loaded routes, or through a path the scanner cannot resolve statically.
include: [
'lodash-es/debounce',
'@acme/charts',
'date-fns/locale/en-GB',
// Deep imports need naming individually — the package root is not enough.
'chart.js/auto',
],
exclude: [
// Workspace packages: served from source so their edits hot-reload.
'@acme/design-system',
// Already valid ESM with few files — pre-bundling adds nothing.
'nanoid',
],
},
};The include/exclude distinction is where most misconfiguration lives. include means “pre-bundle this even though the scanner did not find it.” exclude means “never pre-bundle this, serve it as-is.” Putting a workspace package in include freezes it into an optimized bundle so your edits to it stop hot-reloading — a confusing failure that looks unrelated.
Fix 2: remove the interpolated import paths
A dependency reached through a runtime-constructed path is undiscoverable by design, so it will be found at runtime every time.
// Undiscoverable: the scanner cannot know which locales exist.
// const locale = await import(`date-fns/locale/${code}`);
// Discoverable: an explicit map the scanner can crawl statically.
const LOCALES = {
'en-GB': () => import('date-fns/locale/en-GB'),
'fr': () => import('date-fns/locale/fr'),
'de': () => import('date-fns/locale/de'),
};
export const loadLocale = (code) => (LOCALES[code] || LOCALES['en-GB'])();That change fixes the loop and also narrows the production bundle, for the reason described in shrinking bundled locale and timezone data: an interpolated path forces the bundler to include every possible match.
Fix 3: give the scanner more entry points
In a multi-page application, the scanner only crawls from the configured entry. Additional entries make the first pass see more of the graph:
// vite.config.js — Vite 5+
export default {
optimizeDeps: {
entries: [
'index.html',
'admin/index.html',
// Route modules whose dependencies would otherwise be discovered late.
'src/routes/**/*.tsx',
],
},
};This is usually a better first move than a long include list, because it stays correct as dependencies change — the scanner keeps finding them rather than relying on a hand-maintained list.
Fix 4: reset a stale cache
When the loop persists after the configuration is correct, the cache itself may be inconsistent — commonly after a lockfile change or a branch switch.
# Clear the optimization cache and start clean.
rm -rf node_modules/.vite
npx vite --force # one clean optimization pass--force re-optimizes once on startup. It is a reset rather than a fix: if a dependency remains undiscoverable, it will be discovered again on the next navigation and the loop resumes.
Step-by-step verification
-
Read every dependency name from the log. Each re-optimization prints what triggered it; the complete list is what needs declaring.
-
Restart with a cleared cache. After changing the configuration, remove the cache and start fresh so you observe a true first pass.
-
Count the optimization passes. Exactly one on startup is correct. Any pass after a navigation means a dependency is still undiscoverable.
-
Navigate every major route. Visit each lazily-loaded route in one session and confirm none triggers a reload.
-
Confirm workspace packages still hot-reload. Edit a file in an excluded workspace package and confirm the change appears without a full reload.
-
Check startup time. A very long
includelist slows the first pass; compare against the sub-second startup target discussed in optimizing dev server startup times for large monorepos.
Edge cases and gotchas
Deep imports need naming individually. Including a package root does not pre-bundle a submodule import; each deep path is a separate optimization target.
Lockfile changes invalidate the cache. After a dependency update the cache is rebuilt on the next start, so one reload is expected and is not a loop.
Monorepo symlinks. A linked workspace package resolves outside the project root, and Vite treats it as source rather than a dependency. Its own dependencies still need declaring, which is easy to miss.
Only in production builds does this stop mattering. Pre-bundling is a dev-server mechanism; the production build resolves the whole graph statically, so a loop never appears there — and a dependency set that only works because of include may hide a resolution problem the build will surface differently.
FAQ
Why does Vite need to pre-bundle dependencies at all?
For two reasons. Many packages still ship CommonJS, which the browser cannot load natively, so they must be converted to ES modules before the dev server can serve them. And a package split into hundreds of small modules would otherwise produce hundreds of separate requests on every page load; pre-bundling collapses each dependency into one file. Both are startup-time optimisations that only apply to dependencies, not to your own source.
Why does the loop happen only for some developers?
Because discovery depends on which routes are visited. The scanner crawls from the entry, so a dependency reached only through a lazily-loaded route is invisible until someone navigates there — and that person triggers the re-optimization. A developer who works exclusively on the landing page never sees it; one who opens the reports route sees it every session until the cache is warm.
Is deleting node_modules/.vite a real fix?
It is a reset, not a fix. Clearing the cache forces a clean optimization pass, which resolves a corrupted or stale cache, and it does nothing about a dependency the scanner cannot discover up front — that one will be discovered again on the next navigation and trigger another reload. Use it to get to a clean state, then fix the discovery problem so the loop does not return.
Related
- Vite Module Graph and Dependency Resolution — the parent guide on how the dev server resolves modules
- Optimizing Dev Server Startup Times for Large Monorepos — the startup cost this configuration affects
- How to Configure Module Resolution Aliases in Vite — controlling which file a specifier resolves to
- Shrinking Bundled Locale and Timezone Data — why interpolated import paths cause trouble in the build too