What Actually Happens When Vite Serves a Module

A walk through one request to a Vite dev server: resolution, the transform pipeline, import rewriting, pre-bundling, and why production differs.

A spring-loaded roller feed pushing steel bar stock into a slotted die block, beside a rotary index plate carrying blank fixture plates, with one brass ring at its hub.

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 8.2.0 on Node 24.18.0, by reading what the dev server actually returns. Vite 8 replaced esbuild with Rolldown for both pre-bundling and the TypeScript transform, so anything you read about this pipeline that names esbuild describes Vite 7 or earlier. The shape of the request path did not change with it.

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, Rolldown’s built-in transform 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:

js
import { createApp } from 'vue';

Vite rewrites it to a URL:

js
import { createApp } from '/node_modules/.vite/deps/vue.js?v=87249665';

That is copied from what the server actually returned for /src/main.ts, with a rewritten ./helper on the next line and the types stripped out of both.

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 Rolldown 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.

Rolldown 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/deps, next to a _metadata.json whose keys say what the cache is keyed on: lockfileHash, configHash, and a fileHash per optimized dependency. 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 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. The transform runs on each file in isolation, so it cannot know whether an imported name is a type or a value:

ts
import { User } from './types';   // erased or kept? not knowable from this file

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 Rolldown — the same bundler that pre-bundles your dependencies in dev, and since Vite 8 the only one — over the whole graph, with tree shaking, code splitting, minification and asset hashing.

Vite’s plugin API is Rolldown’s, which is Rollup-compatible, and that 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. Some hooks exist in only one mode — configureServer has no meaning in a build, and the output generation hooks including generateBundle are not called in dev. Others run in both and hand you a different thing: transformIndexHtml gets the dev server in development and the output bundle in a build.

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 the production build selects. 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 pre-bundled 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.

FAQ

Frequently asked

Why does the dev server run code that tsc rejects?

Because there is no type checker in the request path, and that is a design decision rather than an oversight. Checking one module requires the whole program, which would put a multi-second step in front of every keystroke, so the transform strips annotations without reading them. Both vite dev and vite build will happily run code tsc refuses. If your CI does not run tsc --noEmit (or astro check, or vue-tsc) as its own step, you do not have type checking; you have type syntax.

How does the browser know to re-fetch a dependency after I upgrade it?

A version query on the rewritten specifier, backed by two different cache policies. Measured against a Vite 8.2.2 dev server: a pre-bundled dependency returns Cache-Control max-age=31536000,immutable, and your own modules return no-cache, so an edit always reaches the browser while a dependency is fetched once. The ?v= is what invalidates the first, and it comes from node_modules/.vite/deps/_metadata.json, whose keys say what the cache is keyed on: lockfileHash, configHash, and a fileHash per optimized dependency. Ask for a dependency with a stale version query and the server answers 504 Outdated Optimize Dep rather than serving the wrong bytes.

Arrow keys to move, Enter to open.