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

  1. 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.
  2. 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.
  3. 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.
  4. 04Check the dependency optimizer cache directory (default node_modules/.vite/deps) for a timestamp older than the most recent package.json or lockfile change.
  5. 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.
  6. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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