JS Ledger

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.

6 min read
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 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.

Share

Related posts

Arrow keys to move, Enter to open.