12.3 · difficulty 3/4 · 12 min read
HMR Performance Diagnosis
HMR feeling sluggish? Use `--debug hmr` to find the bottleneck — is it a slow transform, a large module graph traversal, or WebSocket latency? Diagnose and fix the right problem.
HMR time breakdown
File changed → HMR update complete
│
├── 1. Filesystem detection delay (chokidar, usually <10ms)
│
├── 2. Module graph traversal (finding affected modules)
│ Can be >100ms for complex graphs
│
├── 3. Transform affected modules
│ Can be slow if plugins are involved
│
├── 4. WebSocket message transmission (<1ms)
│
└── 5. Browser executes the hot update (depends on the framework)--debug hmr diagnosis
DEBUG=vite:hmr pnpm devSample output:
vite:hmr hmr update /src/components/Button.tsx
vite:hmr hotModules: [
{ url: '/src/components/Button.tsx', type: 'js-update' }
] +2ms
vite:hmr propagate: Button.tsx → App.tsx → main.tsx +15msWatch for:
propagatetime: module graph traversal cost (optimize if >100ms)- transform time: recompilation time for each module
Problem 1: Module graph too large, traversal is slow
Symptom: long propagate time (hundreds of milliseconds).
Cause: a foundational module (like utils.ts) is imported by many other modules, creating a massive propagation chain.
Diagnosis:
// Check the number of importers for a module inside configureServer
configureServer(server) {
server.middlewares.use("/__graph-info", (req, res) => {
const info = [...server.moduleGraph.idToModuleMap.values()]
.map(m => ({ id: m.id, importers: m.importers.size }))
.sort((a, b) => b.importers - a.importers)
.slice(0, 20) // Top 20 most-imported modules
res.end(JSON.stringify(info, null, 2))
})
}Fix:
- Split commonly used utility functions into more granular files
- Avoid "barrel files" (a single
index.tsthat re-exports everything), which connect all modules together - Be mindful of boundaries when using
import.meta.glob
Problem 2: Transform takes too long
Symptom: after changing a file, the page takes 500ms+ to update.
Diagnosis: add timing instrumentation inside the transform hook:
transform(code, id) {
const start = performance.now()
const result = doExpensiveWork(code)
const elapsed = performance.now() - start
if (elapsed > 50) {
console.warn(`[slow-plugin] ${id}: ${elapsed.toFixed(0)}ms`)
}
return result
},Common causes:
- A plugin performs unnecessary full AST parsing
- A regular expression runs slowly on large files
- Synchronous I/O operations are being called
Problem 3: Unnecessary HMR propagation
Symptom: changing one small file triggers a large-scale module update.
Diagnosis: use handleHotUpdate to print propagation details:
handleHotUpdate({ file, modules }) {
console.log(`[HMR] ${file} triggered ${modules.length} module updates`)
modules.forEach(m => console.log(` - ${m.id}`))
return modules
},Fix:
- Filter out unnecessary modules inside
handleHotUpdate - Replace module-level HMR with custom events
- Check whether "global state" is causing all modules to depend on the same base module
Problem 4: Slow execution in the browser
React Fast Refresh needs to diff the old and new component trees during an HMR update. This can be slow for large component trees.
Fix:
- Make sure component files don't mix in non-component code (which causes the entire file to reload)
- Use
React.memoto reduce unnecessary re-renders - Split large components into smaller files
From Symptom To Metric
When HMR feels slow, classify the symptom before changing code structure:
| Symptom | Check first |
|---|---|
| Long delay before any log appears | File watcher or editor save behavior |
| Logs appear quickly but the page is slow | Browser-side framework update or component rendering |
propagate takes a long time | The module graph propagation chain is too large |
| Transform logs take a long time | A plugin or compiler step is slow |
| Every change causes full reload | HMR boundary failed or a plugin invalidated deliberately |
Keep a temporary diagnostic plugin that can be enabled when needed:
function hmrTimerPlugin() {
return {
name: "hmr-timer",
handleHotUpdate(ctx) {
const start = performance.now()
const modules = ctx.modules
queueMicrotask(() => {
const elapsed = performance.now() - start
console.log(`[hmr] ${ctx.file} -> ${modules.length} modules in ${elapsed.toFixed(1)}ms`)
})
return modules
},
}
}This does not replace Vite's internal logs, but it helps a team quickly spot "editing this file type fans out to many modules". After that, decide whether to split barrel files, adjust plugin filters, or improve framework component boundaries.
Self-check
- Which phases make up HMR time? Which is most likely to become a bottleneck?
- Why do barrel files (
index.tsthat re-exports everything) cause slow HMR propagation? - How do you use
handleHotUpdateto block unnecessary HMR propagation? - Why does modifying
store.tssometimes cause all page components to HMR update?
// Diagnose the HMR performance problem in the following scenario:
// - The project has 200+ components
// - After modifying any component, HMR always takes 800ms+
// - The module graph shows src/index.ts has 180+ importers
//
// 1. What is the problem?
// 2. Write a handleHotUpdate to print propagation details
// 3. How would you refactor src/index.ts to fix this?
// Current src/index.ts (the problematic barrel file):
export { Button } from "./components/Button"
export { Input } from "./components/Input"
// ... exports 100+ components
// TODO: How would you refactor to reduce HMR propagation?