My Code Changes Aren't Updating: Troubleshooting HMR
1. "My F5 Key is Wearing Out"
I am an impatient person. As soon as I tweak the code and hit Cmd + S, I turn my head to the browser.
The screen should essentially have changed. This is the blessing of modern web development, HMR (Hot Module Replacement).
But at some point, my project started ignoring me.
I changed the background to red, but the screen stayed blue.
"Huh?" I smash the save button. No reaction.
Finally, sighing, I hit the browser refresh (F5). Only then does it change.
After doing this 100 times a day, reality hit me.
"Am I coding right now, or am I just operating a refresh machine?"
My development productivity hit rock bottom, and my stress levels went through the roof. So I decided. I am going to fix this HMR thing no matter what.
2. HMR is Not Magic
I thought HMR was just magic. Save, and it updates.
But looking under the hood, it's a very sophisticated conditional contract.
HMR works similarly to "Building Renovation".
- Live Reload (Full Reload): Blow up the building and rebuild from the foundation. (Slow, Memory/State is all lost)
- HMR (Hot Module Replacement): Just peel off the wallpaper (Module) in Room 301 and stick a new one. (Fast, The person living in Room 301 (State) stays put)
The problem is, what if I try to peel Room 301's wallpaper, but Room 301 is glued to Room 302 with superglue (Strong Dependency)?
Trying to peel it shakes the whole building. The renovator (Bundler, Webpack/Vite) goes, "Screw it, it's too risky. Just rebuild the whole thing!" and gives up.
This is why HMR breaks and Full Reload occurs.
So what exactly prevents the wallpaper from being peeled?
3. The Culprits Who Ruined My HMR (Common Mistakes)
Digging through the project, the culprits appeared one by one. Usually, mistakes common among React developers.
Culprit 1: Case Sensitivity
My computer (Mac) treats filenames case-insensitively. (Case Insensitive File System)
It treats Header.tsx and header.tsx as the same file.
But bundlers like Linux or Webpack are strict.
// ❌ Actual file is Header.tsx, but imported with lowercase
import Header from './header';
This confuses the bundler.
"Uh, header changed? But the module tree I know has Header? Whatever, cut the connection."
Match the casing exactly when importing filenames.
Culprit 2: Anonymous Exports
The problem was my laziness in not naming components.
// ❌ Nameless component
export default () => <div>Hello</div>;
React Fast Refresh (React version of HMR) looks at the name of the component to swap it saying, "Ah, this guy is that guy."
No name? It gets ruled as "Unknown Identity! Cannot Replace!" Especially when wrapping with React.memo or forwardRef, it's easy to lose the name.
// ✅ Please give it a name
const MyComponent = () => <div>Hello</div>;
export default MyComponent;
Culprit 3: Circular Dependency
This was the hardest one to find.
Dance of Death where A calls B, and B calls A back.
User.ts uses Post.ts type.
Post.ts uses User.ts for author info.
If I modify User.ts -> need to update Post.ts -> Uh? then update User.ts again...?
The bundler gets a brain freeze watching this infinite loop. And goes "Ew, I don't know!" and cuts the HMR connection.
Solution: Extract common types or logic to a third file (types.ts, etc.) to break the loop.
4. Network Issues: Proxy and WebSocket
Sometimes it's not the code, but the tool settings.
HMR establishes a WebSocket connection between the browser and the development server. If this line is cut, it's game over.
Nginx Reverse Proxy Issues
Are you using Nginx or Docker in your dev environment?
Nginx drops WebSocket connections by default. You must explicitly allow them.
# nginx.conf
location /ws {
proxy_pass http://frontend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # Mandatory header for WebSocket
proxy_set_header Connection "Upgrade"; # Mandatory header for WebSocket
}
Check your browser DevTools Network tab to see if /ws or socket.io requests are red (failed).
If WebSocket is blocked, the HMR signal cannot reach the browser.
HTTPS and Certificate Issues
If using HTTPS locally (https://localhost) with an invalid certificate (Self-signed), the browser might block the WebSocket connection (WSS) for security reasons.
In this case, use a tool like mkcert to properly issue a local CA certificate.
5. Vite/Webpack Configuration Issues
Attention WSL2 or Docker Users
In Windows WSL2 or Docker environments, the OS's file system event (File Watch Event) sometimes doesn't reach the bundler. (Limitation of virtual environments)
You saved the file, but the bundler goes "Huh? Did something change?"
In this case, turn on Polling. It commands, "Hey, keep watching every 1 second!" It uses a bit more CPU, but it's certain.
// vite.config.ts
export default defineConfig({
server: {
watch: {
usePolling: true, // "Don't take your eyes off it"
interval: 100,
},
},
});
Modifying .env File
I once wrestled for 30 minutes changing an environment variable (.env) and wondering "Why isn't it changing?"
Environment variables are read only once when the server starts. So saving the code is useless.
If you fixed .env, restart the server no matter what. This is the golden rule.
6. Limitations of HMR: State Preservation
Sometimes HMR works, but the screen looks weird. It's the State Reset problem.
When a React component file is swapped, it tries to preserve the useState values inside. However, useEffect might run again.
useEffect(() => {
const timer = setInterval(() => console.log('Tick'), 1000);
return () => clearInterval(timer); // Cleanup function
}, []);
When HMR happens, the old component is destroyed (executing cleanup), and the new component is mounted (executing useEffect again).
If you stored something in a Global Variable or window object, HMR doesn't manage that, so it can get messed up.
Don't trust HMR too much; it's important to write code that cleans up Side Effects properly.
6.9. Deep Dive: How Webpack HMR Runtime Works
"How does the browser know the file changed instantly?"
It's not magic. It's because a tiny piece of JavaScript called HMR Runtime is injected into your browser.
- Connection: When the browser opens, the HMR Runtime establishes a WebSocket connection with the server (
localhost:3000).
- Receive: When you save a file, the server sends a WebSocket message (Manifest): "Hey,
Header.js hash has changed!"
- Download: The Runtime downloads the new
Header.js chunk via JSONP or fetch.
- Bubbling: The Runtime tries to swap
Header.js. If it fails? It propagates (Bubbles Up) the error to the parent component. If it reaches App.js and still fails?
- Fallback: "Screw it.
window.location.reload()!" (This is the Full Reload we see).
So when HMR breaks, it usually means step 4 failed because the parent refused to accept the updated child.
7. Conclusion: Before Blaming the Machine
At first, I blamed the tools: "Vite is buggy", "Webpack is too heavy".
But it turned out 90% was my code (circular deps, casing mistakes, anonymous functions).
Solving HMR issues fixed my dev habits too.
- Check Console Logs: When HMR crashes, the browser console warns something like
[HMR] The following modules couldn't be hot updated.... Don't ignore it—read it.
- Make Components Small: If you stuff 10 components into one file, HMR struggles. Stick to the One Component per File principle.
Once HMR was fixed, development speed feels like it doubled.
That pleasure when the screen pops! and changes as soon as you save. A joy only developers know.
How is your HMR doing? I hope you build a pleasant dev environment where your F5 key gathers dust.
6.95. FAQ
Q: Does HMR work in Production?
A: No! HMR is strictly for the Development Server. When you run npm run build, all HMR logic is stripped away.
Q: Does useLayoutEffect preserve state better than useEffect?
A: No. State preservation is handled by React Fast Refresh, not the hook type. Both hooks re-run on re-mount.
Q: My Next.js HMR is broken.
A: Check your simple things first: Case sensitivity in filenames (Pages vs pages) or modifying next.config.js without a restart.