DEV Community

Cover image for What Happens When You Run `npm run dev`
Athreya aka Maneshwar
Athreya aka Maneshwar

Posted on

What Happens When You Run `npm run dev`

Hello, I'm Maneshwar. I'm building git-lrc, a Micro AI code reviewer that runs on every commit. It is free and source-available on Github. Star git-lrc to help devs discover the project. Do give it a try and share your feedback for improving the project.

You've run npm run dev approximately 1,000 times in your life.

You've watched the terminal blink.

You've switched to the browser.

Magic happened.

But do you actually know what's going on? Like, really know?

Today we go all the way down the hole understanding shell, process spawning, module graphs, Hot Module Replacement, React Fast Refresh.

Step 0: You press Enter

npm run dev
Enter fullscreen mode Exit fullscreen mode

Your shell looks up npm in your PATH and finds it — often inside whatever directory nvm installed Node into, or at a system path like /usr/local/bin/npm. It hands control to the npm CLI, which is itself just a Node.js script.

npm then does one thing: reads your package.json.

{
  "scripts": {
    "dev": "vite"
  }
}
Enter fullscreen mode Exit fullscreen mode

It finds "dev": "vite" and prepares to run it.

Step 1: npm spawns a child process

npm temporarily adds ./node_modules/.bin to your PATH, then spawns a child process running the dev command.

So "vite" resolves to ./node_modules/.bin/vite, which is a symlink to the actual Vite binary inside node_modules/vite/bin/vite.js.

No magic. It's just Node running a JS file.

Step 2: Vite boots up

Vite reads your vite.config.js (or .ts), registers plugins, and does something really clever before anything is served.

The esbuild pre-bundling trick

Vite uses esbuild written in Go, absurdly fast to pre-process your node_modules dependencies.

It does two things:

  1. Converts CommonJS to ESM. Browsers can't natively load require(). esbuild rewrites it.
  2. Collapses packages with many internal files into one. lodash has 600+ tiny files. esbuild merges them so the browser makes one request, not 600.

The result is cached in .vite/deps/. Touch node_modules? Cache busts.
Otherwise it skips this step entirely on restart.

Step 3: The dev server is just... an HTTP server

Vite starts a plain Node.js HTTP server on localhost:5173.

No webpack dev server magic, no complex middleware chains.

When the browser requests a file, Vite:

  1. Reads it from disk
  2. Transforms it on-the-fly (JSX → JS, TypeScript → JS, .css → injected <style>)
  3. Serves it as a native ES Module

The browser itself handles module resolution using <script type="module">.

The key insight: Vite doesn't bundle your source code in dev mode at all.

The browser fetches each file individually as an ES module.

This is why Vite's dev startup is near-instant regardless of your project size it only processes what the browser actually asks for.

Each arrow is a real HTTP request. The browser is doing the graph traversal for you.

Step 4: The module graph

As files are requested, Vite builds an internal module dependency graph a map of who imports what.

This graph is lazy, only nodes that get requested exist in it.

If you never navigate to a route, those components are never fetched, never in the graph.

This is the structure that makes HMR fast.

When a file changes, Vite doesn't scan all your code.

It just walks this graph.

Step 5: You save a file — here's where it gets interesting

5a. OS notifies Vite immediately

Vite uses chokidar which wraps OS-native file system events:

OS API Mechanism
Linux inotify Kernel watches inodes directly
macOS FSEvents Core Services file-system event API
Windows ReadDirectoryChangesW Win32 API

Zero polling. The OS taps Vite on the shoulder the moment your editor flushes to disk.

5b. Walk the graph, find the HMR boundary

Vite walks up the module graph from the changed file, looking for the first ancestor that has registered an HMR handler via import.meta.hot.accept().

With React, you never write import.meta.hot.accept() yourself.

The @vitejs/plugin-react plugin (which uses React Fast Refresh) automatically injects this into every component file at transform time. It's invisible to you.

If no boundary is found all the way up to main.jsx full page reload.

You'll see this happen when you edit vite.config.js or a non-component JS utility that isn't HMR-aware.

5c. Server re-transforms only the changed file

Vite re-runs just Button.jsx through its plugin pipeline JSX transform, any Babel plugins, etc.

The result is a fresh ES module in memory.

5d. Browser fetches the new module via dynamic import

The HMR client (a small script Vite injects into your page) is told an update is available and calls:

import('/components/Button.jsx?t=1718023456789')
Enter fullscreen mode Exit fullscreen mode

The ?t=timestamp query param is the cache-buster. The browser sees a URL it hasn't fetched before, makes a real HTTP request, gets the new module.

5e. The module swap — hot.dispose and hot.accept

Vite's HMR runtime is built around two functions you (or, with React, the plugin) register on import.meta.hot:

// In a module that wants to handle its own updates:

// Clean up before this module is replaced —
// cancel timers, unsubscribe, remove listeners.
import.meta.hot.dispose((data) => {
  clearInterval(timer)
})

// Receive the freshly imported module and re-wire things.
import.meta.hot.accept((newModule) => {
  // newModule is the updated exports
})
Enter fullscreen mode Exit fullscreen mode

A subtle but important detail: Vite's HMR doesn't literally swap the original imported module object for everyone up the chain.

The accepting module i.e the HMR boundary is responsible for taking the new exports and applying them.

That simplified model is what lets Vite skip the expensive work of rewriting every importer, and it's enough for almost every real dev scenario.

dispose is where you cancel a setInterval, unsubscribe from a store, or remove an event listener, anything that would leak if the old module just got abandoned.

Note on CSS: stylesheets take a slightly different path. When a .css file changes, Vite doesn't dynamically re-import a JS module, it swaps the <link> (or <style>) tag for the updated stylesheet, which avoids a flash of unstyled content.

Step 6: React Fast Refresh does the actual re-render

React Fast Refresh is a separate system built by the React team. When the new component module lands, it:

  1. Compares hook signatures. If Button had useState, useEffect before and still has useState, useEffect after, state is preserved. Add or remove a hook? State resets for that component only.

  2. Finds all live instances in the React fiber tree.

  3. Surgically re-renders only the affected subtree. Parent components don't re-render. Siblings don't re-render. Just the changed component and its children.

This is why you can edit a component's styling while a form is mid-fill and the form doesn't reset. The form state lives in a parent or sibling — Fast Refresh never touched it.

The full picture

Why does this matter?

If you're on webpack's dev server, a file save triggers a rebuild of the bundle, even with HMR. On a mid-size project that can mean noticeable latency between save and browser update.

With Vite's native-ESM approach, the server does almost nothing (one file transform), the browser fetches one URL, and Fast Refresh touches one subtree.

The entire round trip is typically very fast and stays fast as your project grows.

That's not a marginal improvement.

It fundamentally changes how you work — edit → see result becomes nearly instantaneous.

AI agents write code fast. They also silently remove logic, change behavior, and introduce bugs -- without telling you. You often find out in production.

git-lrc fixes this. It hooks into git commit and reviews every diff before it lands. 60-second setup. Completely free.*

Any feedback or contributors are welcome! It's online, source-available, and ready for anyone to use.

⭐ Star it on GitHub:

GitHub logo HexmosTech / git-lrc

Free, Micro AI Code Reviews That Run on Commit




AI agents write code fast. They also silently remove logic, change behavior, and introduce bugs -- without telling you. You often find out in production.

git-lrc fixes this. It hooks into git commit and reviews every diff before it lands. 60-second setup. Completely free.

See It In Action

See git-lrc catch serious security issues such as leaked credentials, expensive cloud operations, and sensitive material in log statements

git-lrc-intro-60s.mp4

Why

  • 🤖 AI agents silently break things. Code removed. Logic changed. Edge cases gone. You won't notice until production.
  • 🔍 Catch it before it ships. AI-powered inline comments show you exactly what changed and what looks wrong.
  • 🔁 Build a

Top comments (11)

Collapse
 
aloisseckar profile image
Alois Sečkár

Since Vite 8 esbuild is no longer used but rolldown. But I guess it conceptually still does the same thing.

Collapse
 
lovestaco profile image
Athreya aka Maneshwar

Yupp

Collapse
 
tahosin profile image
S M Tahosin

This is a fantastic deep dive! It's so easy to take the magic of npm run dev and tools like Vite for granted. Breaking down the step-by-step process of module resolution really highlights how much performance we gain by serving native ES modules during development. Thanks for this clear breakdown!

Collapse
 
lovestaco profile image
Athreya aka Maneshwar

Thanks bud!

Collapse
 
lcmd007 profile image
Andy Stewart

What a phenomenal breakdown! From OS-level process spawning and Vite’s pre-bundling with esbuild, to native ESM dependency trees and React Fast Refresh state preservation—this completely demystifies the entire lifecycle behind npm run dev.

This "no-bundle, on-demand transformation" philosophy represents the absolute pinnacle of local-first, ultra-responsive engineering. It’s exactly the kind of hardcore systems thinking we need when architecting next-generation development tools and localized infrastructure. Brilliant write-up!

Collapse
 
lovestaco profile image
Athreya aka Maneshwar

Thanks bud!

Collapse
 
neithergalax profile image
neither galax

Thanks for taking us through the npm journey. This came in a perfect timing.
I've run npm run dev command 1 time in my life, today. I just downloaded the module to work on ts based project I am trying to contribute on. It's always fun to learn how the system is actually working.

Collapse
 
lovestaco profile image
Athreya aka Maneshwar

Thanks buddy!

Collapse
 
motedb profile image
mote

Great breakdown of the Node module resolution chain. I spent half a day debugging a module not found error that turned out to be a shadowed node_modules in a monorepo package -- the resolution was finding the wrong version because of how the workspace hierarchy interacted with NODE_PATH.

One thing that's always tripped me up: the interaction between exports field in package.json and the old main/module fields. If a package has exports defined, it completely bypasses the file-system resolution, which means files that should be reachable via main suddenly 404 if you don't include the exact export mapping.

Do you use any tooling to visualize or debug these resolution paths? Or just build intuition over time?

Collapse
 
samuraix13 profile image
SamuraiX[13~]

This was such a fun and interesting article to read, thanks for teaching me something new :D

Collapse
 
lovestaco profile image
Athreya aka Maneshwar

Thanks buddy :)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.