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

- Published: 2026-08-04
- Tags: javascript, tooling, typescript
- Source: https://jsledger.com/blog/what-happens-when-vite-serves-a-module/
- Language: en-US
- Author: Jonah Vail

---
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:

```js
import { createApp } from 'vue';
```

Vite rewrites it to a URL:

```js
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](https://vite.dev/guide/dep-pre-bundling) 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](/blog/reading-a-lockfile/): it decides both what you install and when
your dev server throws its cache away.

:::note
The `?v=` on the URL is a content hash. Dependency files are served with a
long-lived immutable cache header — they only change when the cache is rebuilt
— while your own source files are served with `no-cache` so an edit always
reaches the browser.
:::

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:

```ts
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](/blog/type-level-narrowing-in-typescript/).

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