Vite · advanced
Vite dev server serves stale code: identify the invalidation edge
Vite's dev server can serve stale modules when a change happens outside its tracked invalidation boundary: alias rewrites, dependency pre-bundling cache, config-driven transforms, or filesystem events the watcher misses. This guide frames the failure as a module-graph invalidation edge problem and walks through evidence-driven triage to confirm that a file change actually reaches the client.
The symptoms
- •The browser keeps receiving an old version of a file after a clear on-disk edit, even though the editor saved successfully.
- •A renamed or moved file is still imported under its previous path and is not re-resolved against the new alias or root.
- •A newly added dependency (or a version bump in package.json) is absent from served modules, while older transitive code still loads.
- •HMR triggers for some files but not others, leaving a mix of fresh and stale chunks in the same session.
- •Changes to vite.config.* (alias, resolve.extensions, optimizeDeps) appear ignored until the dev server is restarted manually.
Likely causes
- •The dep optimizer cache (node_modules/.vite) was built before the change and was not invalidated when a new bare import appeared or a dependency version changed.
- •An alias or resolve.alias entry points at a path that the watcher does not include, so edits inside that directory do not produce HMR or full-reload events.
- •The file lives outside the project root or server.watch.ignored list, so chokidar polls never observe the write.
- •A symlinked workspace or monorepo package is resolved through a realpath that escapes server.fs.strict, so the change is not trusted and the module graph is not invalidated.
- •A non-HMR-aware module or a manually cached import (for example a singleton, a memoized fetch, or a global registry) is keeping the old reference alive past an update.
- •The browser itself is caching aggressively (service worker, 304 with stale body, HTTP cache disk hit) and bypassing the dev server entirely.
First ten minutes
- 01Reproduce with a single, minimal edit: change a string literal in the suspect file, save, and observe whether the browser content and the served network response both update.
- 02Open the dev server's stdout/stderr in the terminal where Vite was started; record whether HMR update, page reload, or "[vite] connected" messages appear for the touched file.
- 03In the browser DevTools Network panel, hard-disable cache for the request and inspect the response body and the ?t= or ?import query timestamp that Vite appends.
- 04Check the dependency optimizer cache directory (default node_modules/.vite/deps) for a timestamp older than the most recent package.json or lockfile change.
- 05Compare vite.config.* against the running process: any alias, resolve, optimizeDeps, or server.watch change requires a server restart, so identify whether a restart actually occurred after the edit.
- 06List the resolved import graph for the suspect module using the DevTools "Import Map" or "Module Dependencies" view, and confirm whether the file shown matches the on-disk path.
Evidence to collect
- •The HTTP response body and its query string for the suspect module, captured with cache disabled, to confirm what Vite actually served.
- •The HMR event log line emitted by Vite for the touched file (update, full-reload, or no event), with timestamp.
- •File mtime and size for the on-disk source, plus the timestamp of the optimizer cache directory if present.
- •The active alias map and server.fs settings from the loaded vite.config.* as Vite resolved it, not as written on disk.
- •The list of paths reported by the file watcher (chokidar) versus the actual path of the edited file, to detect out-of-root writes.
- •Browser-side evidence: a forced reload with DevTools "Disable cache" enabled, plus the precise module URL requested by the page.
Where to look
- •The boundary between on-disk file state and the in-memory module graph held by the Vite dev server.
- •The boundary between bare imports (handled by the dep optimizer) and relative or aliased imports (handled by the resolver and plugin pipeline).
- •The boundary between the filesystem watcher (chokidar) and server.fs.strict / server.watch, which together define which paths Vite trusts to invalidate.
- •The boundary between the dev server response and the browser HTTP cache, including any service worker or framework-level cache layer in front of it.
- •The boundary between vite.config.* read at startup and any runtime mutation, since most config changes require a restart to take effect.
Diagnostic steps
- 01Force a known-good edit and a known-bad edit on the same file; if both look stale, the problem is upstream of HMR (cache or watcher); if only one updates, isolate which transform or plugin is responsible.
- 02Compare the served module URL to the on-disk path: a mismatch indicates an alias, resolve.extension, or symlink realpath that the watcher is not following.
- 03Delete the optimizer cache directory and restart the dev server, then retry the edit; if the stale behavior disappears for a dependency import, the cache was the invalidation edge.
- 04Add the suspect file's directory to server.watch explicitly (or relax server.fs.strict) and confirm that an edit now produces an HMR or full-reload event in the terminal log.
- 05Reproduce with the browser DevTools "Disable cache" checkbox on and with all extensions/service workers disabled; if the file now updates, the browser cache layer was serving a previous response.
- 06Toggle Vite's clearScreen and log HMR boundaries in the terminal; an absent "[vite]" line for the touched file means the watcher did not observe the event at all.
- 07Test in a fresh incognito window with a clean profile to rule out a service worker registered by the app under test.
Common mistakes
- •Assuming HMR is broken when the real cause is that the file is outside server.fs.strict and Vite is silently ignoring the change for safety.
- •Treating "restart Vite" as a generic step rather than a targeted one, missing that a stale optimizer cache needs to be invalidated, not just the process restarted.
- •Editing vite.config.* (alias, resolve, optimizeDeps, server.watch) and expecting HMR to pick it up; these are startup-time settings, not hot-reloadable.
- •Conflating "the file changed" with "the page updated" when a service worker, a CDN dev mirror, or a stale import map is intercepting requests before they reach Vite.
- •Trusting the editor's "saved" indicator without checking the file mtime and size, which is the only signal Vite's watcher actually consumes.
- •Mutating shared state (singletons, module-level caches) outside an HMR-accept boundary, so the new module loads but the old reference is still in use.
Safe fixes
- •If the optimizer cache is the edge: stop the dev server, delete the cache directory (node_modules/.vite), and restart; verify by editing a bare import and confirming a new pre-bundled chunk appears in the optimizer output.
- •If the file is outside the trusted root: add the directory under server.fs.allow in vite.config.*, restart the server, and confirm the watcher reports the path and an HMR event appears in the log.
- •If an alias or resolve change is ignored: restart the dev server after saving vite.config.*, then re-request the module URL and confirm the resolved path in the response now matches the new alias.
- •If the browser cache is the edge: open the page with DevTools "Disable cache" enabled, or unregister the service worker for the dev origin, and confirm the response body matches the on-disk source.
- •If a non-HMR module holds stale state: add an import.meta.hot.accept boundary, or refactor the singleton behind a factory so the new module replaces the old reference on update.
- •If a symlinked workspace package is stale: confirm server.fs.strict is set appropriately for the realpath, and that the watcher is bound to the real path, not the symlink.
Prove the fix
- 01An edit to the suspect file produces a visible HMR or full-reload event line in the dev server terminal, and the browser content reflects the change without a manual refresh.
- 02The HTTP response body for the module URL (captured with cache disabled) contains the new string literal, and its query timestamp advances on each save.
- 03After deleting the optimizer cache, the next start rebuilds pre-bundled dependencies and a subsequent version bump in package.json is reflected in served modules without further manual intervention.
- 04An out-of-root or symlinked edit now triggers a watcher event in the terminal and an HMR update in the browser, confirming the invalidation boundary has been widened correctly.
- 05A reload of the page in a clean browser profile (no service worker, no cache) shows the new code on the first request, ruling out browser-side staleness.
Prevention and next steps
- •Keep all editable sources inside the project root and under server.fs.allow, so the watcher is guaranteed to observe every save.
- •Document which vite.config.* sections are hot-reloadable (for example plugins.transform) versus startup-only (alias, resolve, optimizeDeps, server.watch, server.fs), and treat restarts as part of those edits.
- •Use import.meta.hot.accept or import.meta.hot.dispose on modules that hold module-level state, so HMR can replace rather than accumulate references.
- •Disable service workers and HTTP cache in the dev environment, or scope them to production builds only, to keep the dev server as the single source of truth.
- •Wire a pre-commit or pre-start script that clears node_modules/.vite when package.json or the lockfile changes, so dependency edits are never masked by a stale optimizer cache.
Safe commands and checks
ls -la node_modules/.vite/deps 2>/dev/null | head -n 20 # inspect optimizer cache directory contents and timestamps stat -c '%Y %n' <path-to-edited-file> # print mtime epoch of the suspect file to compare against watcher activity grep -nE 'optimizeDeps|resolve|alias|server\.fs|server\.watch' vite.config.* # locate config sections that require a dev server restart ps -o pid,etime,cmd -C node 2>/dev/null # identify the running Vite process and confirm it started after the latest config change find . -path ./node_modules -prune -o -newer node_modules/.vite/deps -print 2>/dev/null | head # list source files newer than the optimizer cache