
Start a Vite project, open the network panel, and you see something that looks wrong: dozens of separate requests for individual source files, each returning a few lines of JavaScript. No bundle. Ten years of build tooling said that was the thing to avoid.
It works because the browser is doing the module graph traversal that a bundler used to do ahead of time. Following one request through the server is the clearest way to see how.
Checked against Vite 7.3.6 on Node 24.18.0, by reading what the dev server actually returns. Plugin ordering and the pre-bundling cache layout have been stable for several major versions; the production bundler is the part currently in motion, and I say where below.
The request
Your index.html contains <script type="module" src="/src/main.ts">. The
browser requests that URL. The dev server has no dist/ to serve it from, so it
builds the response on the spot, in four steps.
Resolve. The URL is turned into a file path. /src/main.ts is
project-relative and resolves directly. Plugins participate here — this is the
hook that makes aliases, virtual modules and framework-specific imports work.
Load. The file is read from disk. A plugin can intercept and return content
for a module that has no file at all, which is how virtual: modules exist.
Transform. The source runs through the plugin pipeline in order. For a
.ts file, the built-in esbuild plugin strips the types. For a .vue or
.svelte file, the framework plugin compiles it. Each plugin receives the
previous plugin’s output.
Rewrite imports. The final step, and the one that makes the whole design work. Vite parses the module’s import statements and rewrites every specifier into something the browser can actually fetch.
That last step matters because browsers cannot resolve bare specifiers. This is valid in Node and meaningless in a browser:
import { createApp } from 'vue';
Vite rewrites it to a URL:
import { createApp } from '/node_modules/.vite/deps/vue.js?v=8f3a1c2b';
Relative imports get rewritten too — extensions added, query parameters attached — so that every specifier in the served module is a URL the browser can request, which starts the cycle again for the next file.
Where that pre-bundled file came from
/node_modules/.vite/deps/vue.js is not node_modules/vue. It is an artifact
esbuild produced when the server started, and it exists for two reasons.
Format. A large part of npm still ships CommonJS, or ships ESM with
CommonJS dependencies underneath. A browser cannot execute require.
Pre-bundling converts each dependency into a single ESM file.
Request count. Some packages are hundreds of internal modules. Served as native ESM, importing one of them would mean hundreds of round trips before the page could run — the waterfall that made native ESM impractical in the first place. Pre-bundling flattens each dependency to one file, so the browser makes one request per package rather than one per file inside it.
esbuild does this work because it is fast enough to run on every cold start,
and the dependency pre-bundling
guide is where the escape hatches live.
The result is cached in node_modules/.vite, keyed on the lockfile and the
relevant config; change a dependency and the cache is invalidated and rebuilt.
That key is one more reason the lockfile is worth
reading: it decides both what you install and when
your dev server throws its cache away.
If a dependency shows up as a request that was not pre-bundled, Vite discovers
it mid-session, bundles it, and reloads the page. Repeated discovery reloads
are the symptom optimizeDeps.include exists to fix.
The part that surprises people about TypeScript
The esbuild transform removes type annotations. It does not check them.
There is no type checker in the request path at all, by design: type checking a module requires the whole program, which would put a multi-second step in front of every keystroke. The dev server’s job is to be fast and wrong-tolerant.
The consequence is that vite dev and vite build will both happily run code
that tsc rejects. If your CI does not run tsc --noEmit (or vue-tsc,
astro check, or whatever your framework’s equivalent is) as a separate step,
you do not have type checking — you have type syntax.
This also explains a class of confusing errors. esbuild transpiles each file in isolation, so it cannot know whether an imported name is a type or a value:
import { User } from './types'; // erased or kept? esbuild cannot tell alone
verbatimModuleSyntax in tsconfig.json resolves it by requiring the
import type form, which makes the answer syntactic and therefore knowable
from the single file. It is one of several places where TypeScript’s behavior
is easier to predict once you know what the checker can and cannot
prove.
Then production changes the rules
vite build does not use the dev server. It runs a bundler — Rollup today,
with Rolldown replacing it as that work lands — over the whole graph, with tree
shaking, code splitting, minification and asset hashing.
Vite’s plugin API is Rollup’s plugin API, which is what keeps a plugin working
in both modes. But “same API” is not “same execution”: in development, plugins
run per module, on demand, in a long-lived process. In production they run
across the whole graph at once, and hooks that only exist in one mode
(transformIndexHtml in dev, generateBundle in build) behave differently by
definition.
That gap is where the classic Vite bug lives — the one that works locally and breaks after deploy. The usual causes are worth knowing:
- Code that survives in dev because it is never tree-shaken, and disappears in production because nothing statically references it.
- Import side effects that run in a different order once modules are bundled together.
- A dependency whose CommonJS build is what gets pre-bundled in dev, while its ESM build is what Rollup selects for production. Different code, same package.
The fix is not clever: run vite build && vite preview before you believe
anything about production behavior. It takes seconds and it exercises the code
path that actually ships.
Worth taking away
The dev server is a transform pipeline with a resolver in front of it, not a bundler in disguise. Every source file you author is served roughly as you wrote it; every dependency is served as a single esbuild-produced artifact; and every import specifier is rewritten to bridge the two.
Once that model is in your head, the failure modes stop being mysterious. A missing file is a resolve problem. A file served untransformed is a plugin ordering problem. A dependency that reloads the page is pre-bundling discovery. And anything that only happens after deploy is the bundler, not the server.