Vite Deep Intuition
An experienced engineer's guide to Vite
1. One-Sentence Essence
Vite is a dev server that exploits the browser’s native ES module loader to skip bundling during development, paired with a production bundler (Rolldown, as of v8) wearing the same plugin clothes.
That’s it. Strip away the marketing and the templates and the framework integrations, and Vite is two things glued together: a clever dev server that says “the browser can resolve imports itself now, why am I bundling?”, and a production bundler that does the boring-but-necessary work of producing optimized output for the real world. The reason it feels magical is that the magic lives in noticing the browser already grew the capability we’d been simulating for ten years.
2. The Problem It Solved
To understand why Vite exists, you have to remember what 2019 felt like.
You’d npm run dev on a moderately large React or Vue app. You’d see “Compiling…” in the terminal. Then you’d wait. Sometimes 30 seconds. Sometimes 90 seconds. On a large enterprise codebase, the joke was you’d go make coffee. The reason: Webpack — the dominant tool — had to read every file in your application, transform every TypeScript file, every JSX file, every SCSS file, resolve every import, build a dependency graph, and concatenate it all into one (or several) bundle files. Only then could it serve your app. Every file save triggered some fraction of that work again. Hot Module Replacement helped, but it still meant Webpack had to re-process the changed module and its bundle context.
The painful part is that this work was, in some sense, useless during development. The browser only ever rendered one route at a time, only ever executed a small slice of the code. But Webpack didn’t know that — it bundled everything just in case. And in 2017-2019, it had to, because browsers didn’t understand import statements. They needed a flat <script> tag pointing at one big file.
Then in 2017-2018, every modern browser added native support for ES modules. You could write <script type="module" src="./main.js"> and the browser itself would walk the import graph, fetch each module, resolve dependencies. The plumbing we’d been simulating in build tools for a decade became a browser feature.
Snowpack was the first tool to lean into this — “what if we just stopped bundling during dev?” Vite, created by Evan You (the creator of Vue.js) and first released in 2020, took the same insight and made it actually work for serious applications. The key design decision: split the problem in two. Source code is served as native ES modules, transformed per-file, on demand — the browser asks for App.tsx, Vite transforms it, sends it back. But node_modules dependencies — which are big, slow-changing, and often shipped as CommonJS — get pre-bundled once at startup and cached. You get a dev server that starts in 300ms regardless of project size, and HMR that updates a single file in under 50ms.
For production, Vite still bundles. Through v7 it used Rollup; in v8 it switched to Rolldown, a Rust-based Rollup-compatible bundler from the same team, eliminating the long-standing dev/prod inconsistency. We’ll see why that mattered.
3. The Concepts You Need
Before you can think clearly about Vite, you need its vocabulary. This is the language of the rest of the document.
Module concepts
-
ES Module (ESM): the JavaScript standard for
import/export. Browsers natively support this since ~2018. A file declaredtype="module"in a<script>tag lets the browser walk the import graph itself. Vite is built entirely around the assumption that ESM is the universal source format. -
CommonJS (CJS): the older Node.js module format using
require()andmodule.exports. Many older npm packages ship as CJS. Browsers don’t understand CJS at all. This is why Vite has to pre-bundle CJS dependencies into ESM before the browser can use them. -
Bare import:
import { foo } from 'lodash'. The specifierlodashisn’t a relative path. Browsers can’t resolve it — they expect./or/or a full URL. Vite intercepts these and rewrites them to/node_modules/.vite/deps/lodash.js?v=hashso the browser can fetch them.
Vite’s pieces
-
Dev server: a long-running Node.js process that intercepts browser requests, transforms files on the fly (TypeScript → JavaScript, JSX → JavaScript, etc.), and serves them. It also runs a WebSocket connection to push HMR updates to the browser.
-
Pre-bundling (a.k.a. dependency optimization): the one-time process of scanning your source for bare imports, finding the packages in
node_modules, and bundling each one into a single ESM file. Cached innode_modules/.vite/. Happens at server start. See Section 6. -
Plugin: an object with hooks that fire at specific points in Vite’s pipeline (
resolveId,load,transform,transformIndexHtml, etc.). Vite’s plugin API is a superset of Rollup’s. The reason this matters is that one plugin definition works for both dev and production — Vite was the first tool to unify this. See Section 6. -
Rolldown: the Rust-based bundler that, as of Vite 8 (March 2026), powers both pre-bundling and production builds. It speaks the Rollup plugin API, which is why the ecosystem migrated without a war. Replaces the previous esbuild-for-dev + Rollup-for-prod split.
-
Oxc: the Rust-based JavaScript/TypeScript toolchain (parser, transformer, minifier) that powers Vite’s source code transformation. Replaces Babel for transpilation in most cases. Fast.
-
HMR (Hot Module Replacement): the mechanism by which an edited module is swapped into the running application without a full page reload. Preserves application state. Implemented over WebSocket. See Section 6 for how it actually works.
Build concepts
-
Module graph: Vite’s internal representation of which files import which files. Edits propagate up this graph to find HMR boundaries. This data structure is the brain of the dev server.
-
HMR boundary: a module that has opted into handling its own updates by calling
import.meta.hot.accept(). When a file changes, Vite walks up the graph from the changed file until it hits a boundary. The boundary is what gets re-evaluated. If no boundary is found, Vite falls back to a full page reload. Framework plugins (React, Vue, Svelte) make every component an implicit boundary — that’s why you almost never writeaccept()yourself. -
Code splitting: producing multiple output JavaScript files (chunks) instead of one giant bundle, so the browser only loads what each route needs. Vite does this automatically based on dynamic
import()calls. -
Tree-shaking: dead code elimination based on static analysis of ESM imports. If you
import { a } from 'lib'and never useb,bshouldn’t end up in the bundle. Only works well when the source is ESM (another reason Vite is opinionated about ESM). -
Asset: anything that isn’t JavaScript — images, CSS, fonts, SVGs, WASM. Vite has rules for how each is imported (
import logo from './logo.svg'), processed (hashed for cache busting), and emitted. -
Environment: as of Vite 6, the abstraction for where code runs —
client(browser),ssr(Node), or a custom one (e.g. Cloudflare Workers). Each has its own module graph and transform pipeline. The Environment API lets framework authors model edge runtimes and other targets that don’t fit the old client/server binary. See Sections 6 and 8.
Config concepts
-
vite.config.js/.ts: the config file. Exports a default object (often wrapped indefineConfigfor type help). Lives at project root. -
index.htmlas entry: in Vite,index.htmlis the entry point of your app, sitting at the project root. Not inpublic/, not generated from a template — the real file. Vite reads it, finds<script type="module" src="...">references, and uses those as the seed of the module graph. This is a deliberate inversion of the Webpack model. -
public/directory: assets that should be served as-is, without hashing or processing. For things likerobots.txt,favicon.ico, or files you must reference at a stable URL. -
Mode:
development,production,staging, etc. Determines which.env.[mode]file is loaded and setsimport.meta.env.MODE. Defaults todevelopmentforvite devandproductionforvite build. -
import.meta.env: Vite’s compile-time-replaced object holding env variables. Only variables prefixedVITE_are exposed to client code, as a guardrail against accidentally leaking secrets.
4. The Distilled Introduction
This section is the 10-hour tutorial, compressed to its essence. After reading this, you will be able to install Vite, scaffold a project, edit code, configure it, build for production, and ship. The mechanics. We get to why in later sections.
Installation and scaffolding
Vite requires Node.js 20.19+ or 22.12+. The official scaffolding tool spins up a project for any popular framework:
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev
Available templates: vanilla, vue, react, preact, lit, svelte, solid, qwik, each with a -ts TypeScript variant. The create-vite tool is paper-thin — it just copies a starter directory. Open the resulting package.json and you’ll see three npm scripts:
{
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
}
}
That’s the whole CLI surface. vite (or vite dev) runs the dev server. vite build produces a production bundle in dist/. vite preview runs a tiny static server against dist/ so you can sanity-check the production build before deploying.
Project anatomy
my-app/
├── index.html ← entry point (read this!)
├── package.json
├── vite.config.ts ← config
├── tsconfig.json
├── public/ ← unprocessed assets
└── src/
├── main.tsx ← app entry, referenced from index.html
└── App.tsx
The thing to internalize: index.html is the entry point. Not a generated file. Not a template. Vite reads it, finds the <script type="module" src="/src/main.tsx"> tag inside <body>, and treats src/main.tsx as the root of the module graph. Open it in any editor. Add a meta tag. Reference a <link rel="stylesheet" href="/src/style.css">. It just works — Vite processes the file as if it were source code, rebasing URLs and discovering imports.
Running the dev server
npm run dev
You should see:
VITE v8.0.10 ready in 287 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
Three hundred milliseconds. Even on a 3000-file monorepo, you’ll see numbers in this range. Open the URL. Open DevTools → Network. Reload. You’ll see something striking: dozens of separate file requests, one per module, each returning transformed JavaScript. There is no bundle. The browser is walking the import graph itself.
Edit a file. Save. Look at DevTools — a WebSocket message arrives, and the browser fetches just the changed file. No reload. State preserved. This is HMR.
Writing code
// src/App.tsx
import { useState } from 'react'
import logo from './logo.svg' // asset → URL string
import styles from './App.module.css' // CSS Modules → object
import data from './data.json' // JSON → parsed object
export default function App() {
const [count, setCount] = useState(0)
return (
<div className={styles.container}>
<img src={logo} />
<button onClick={() => setCount(c => c + 1)}>{count}</button>
</div>
)
}
Notable points without explanation (we’ll explain in later sections):
- TypeScript is transpiled, not type-checked. Vite uses Oxc, which is fast but doesn’t know types. Run
tsc --noEmitseparately for type errors, or usevite-plugin-checker. .svgimports return a URL string by default. Append?rawfor the file’s text content,?inlineto force base64 inlining,?urlto force a URL even for unrecognized extensions.*.module.csstriggers CSS Modules — class names get hashed, you get an object of class-name mappings.- JSON imports work and are tree-shakable when you use named imports:
import { name } from './pkg.json'.
Configuration
Most projects need almost nothing. Here’s a typical vite.config.ts:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'node:path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': resolve(import.meta.dirname, './src'),
},
},
server: {
port: 3000,
proxy: {
'/api': 'http://localhost:8080', // dev API proxying
},
},
})
That’s it for 80% of projects. defineConfig is a no-op wrapper that gives you TypeScript IntelliSense. plugins is an array of plugins — note that React isn’t built in, the official plugin handles JSX and React Fast Refresh (the React variant of HMR). resolve.alias lets you write import x from '@/utils' instead of '../../../utils'. server.proxy is the unsung hero of full-stack dev — Vite proxies API requests to your backend so the browser doesn’t hit CORS.
Environment variables
Create .env.development and .env.production:
# .env.development
VITE_API_URL=http://localhost:8080
VITE_DEBUG=true
In code: import.meta.env.VITE_API_URL. Only the VITE_-prefixed variables are exposed to client code (this is a deliberate guardrail — anything without the prefix stays server-side, but anything with it gets statically inlined into your client bundle, so it’s effectively public). Vite also gives you import.meta.env.MODE, .DEV, .PROD, .SSR, and .BASE_URL.
Mode is set by the command: vite dev → development, vite build → production. Override with --mode staging to load .env.staging. This is how you ship the same code with different config to multiple environments.
Importing assets
import imgUrl from './img.png' // URL string
import shaderSource from './shader.glsl?raw' // file contents as string
import workerUrl from './worker.ts?worker&url' // worker as URL
// Modern way — works in browsers natively:
const url = new URL('./img.png', import.meta.url).href
Small assets (under 4KB by default) get inlined as base64 data URLs. Larger ones get a hashed filename like assets/img.2d8efhg.png in production. Hashes make caching aggressive and safe — change the content, the hash changes, the browser fetches the new version.
The new URL('./file', import.meta.url) pattern is special: it’s native ESM — Vite doesn’t even need to transform it in dev. Production bundling rewrites it to point at the hashed location.
Building for production
npm run build
Output appears in dist/. By default that’s:
dist/
├── index.html
└── assets/
├── index-A8b7c.js
├── index-B92ka.css
└── logo-3kxR9.svg
The HTML is generated from your source index.html, with script and link tags rewritten to point at hashed assets. CSS is extracted into its own file (CSS code splitting per chunk). Dynamic import() calls automatically become separate chunks with preload directives in the HTML.
Browser target defaults to “Baseline Widely Available” — browsers released in the last ~2.5 years. Override with build.target: 'es2020' (or 'es2015' for the lowest reasonable target). Vite does not polyfill, only transpiles syntax. For old browsers, add @vitejs/plugin-legacy.
To check that it actually works:
npm run preview
This is the only legitimate way to check your production build locally. Don’t open dist/index.html from the file system — module imports won’t work due to CORS restrictions on file://.
Deployment
The output of vite build is a static site. Drop dist/ on any static host: Vercel, Netlify, Cloudflare Pages, S3 + CloudFront, nginx, GitHub Pages. The only configuration concern: if your app uses client-side routing (React Router, Vue Router), configure the host to fall back to index.html for unmatched paths so deep links work.
Common commands
vite # dev server
vite --host # expose to network (e.g. for mobile testing)
vite --port 3000 # custom port
vite --open # open browser on start
vite --force # delete dep cache and re-bundle (fixes weird stale-state bugs)
vite build # production build
vite build --watch # build in watch mode (for libraries)
vite build --mode staging # use .env.staging
vite preview # serve the production build locally
vite --debug transform # log how long each file takes to transform (perf debugging)
--force is the universal “turn it off and on again” — when Vite is being weird about a dependency, this fixes it 80% of the time. Equivalent to rm -rf node_modules/.vite && npm run dev.
Library mode
Vite has a special mode for publishing libraries instead of apps:
export default defineConfig({
build: {
lib: {
entry: resolve(import.meta.dirname, 'src/index.ts'),
name: 'MyLib',
fileName: 'my-lib',
},
rolldownOptions: {
external: ['react', 'react-dom'], // don't bundle these
output: { globals: { react: 'React' } },
},
},
})
This builds both ESM and UMD/CJS outputs, externalizes peer dependencies, and skips the HTML generation. We’ll see in Section 8 when this is and isn’t the right tool.
That’s the working knowledge. Everything from here is why.
5. The Mental Model
Four ideas. If you internalize these, Vite stops surprising you.
Core Idea 1: Source code and dependencies live in different worlds.
This is the single most important thing to understand about Vite. Webpack treats everything as a node in one giant module graph — your code, lodash, React, your team’s design system, all the same. Vite splits the world in two.
Source code (src/, your index.html, anything outside node_modules) is treated as fast-changing, plain ESM. Vite serves it file-by-file, transforming on demand, never bundled during dev. The browser walks the import graph natively.
Dependencies (anything in node_modules) are treated as slow-changing, possibly-broken-ESM, possibly-CJS. Vite pre-bundles them once at server start, caches the result in node_modules/.vite/deps/, and serves them with aggressive HTTP caching (max-age=31536000,immutable). The browser fetches each dep at most once per Vite version.
What this predicts:
-
Adding
console.logtosrc/foo.tsis instant. Adding a new npm package may trigger a “Pre-bundling…” stall. Source-code edits are cheap because Vite only does per-file work. New deps mean re-running the bundler. The first import of a not-yet-discovered dep also forces a page reload, because Vite has to bundle it before the browser can load it. -
Symlinked monorepo packages confuse Vite. Vite uses “is it in
node_modules?” as the heuristic for “treat it like a dependency.” Apnpmworkspace package that lives at../other-packageand gets symlinked intonode_modules/@org/other-packagedoesn’t always trip the heuristic cleanly, and you end up with either duplicate bundling, no HMR, or both. We come back to this in Section 7. -
The dep cache is keyed by lockfile hash. Bump a package, lockfile changes, cache is invalidated, Vite re-bundles on next start. This is why “switching branches makes the next
npm run devslow” — lockfile differs. -
optimizeDeps.includeandoptimizeDeps.excludeare your levers when this heuristic gets it wrong. Almost every “Vite is being weird” story ends with one of these flags.
Core Idea 2: The browser is part of the build pipeline.
In a Webpack world, the bundler does all the work. The browser receives finished output. In a Vite world, the browser is an active participant: when you save a file, Vite doesn’t compile the whole app. Vite transforms the changed file, sends a WebSocket message saying “fetch this URL again”, and the browser pulls the updated module over HTTP.
The browser walks the import graph. The browser caches modules. The browser dynamically imports new chunks. Vite is, in a sense, just a smart static file server with a transform pipeline.
What this predicts:
-
Vite’s dev startup time is constant regardless of project size. Because Vite doesn’t bundle anything up-front, “starting up” means launching a Node process and listening on a port. The work is deferred until the browser requests something. A 50-file app and a 5,000-file app start in roughly the same time. The 5,000-file app just generates more requests as the browser walks the graph.
-
HMR cost is proportional to the changed module, not the project size. Edit
Button.tsx, onlyButton.tsxis re-transformed and re-fetched. This is why Vite HMR is “instant” even on huge apps where Webpack HMR was multi-second. -
The browser’s network panel becomes your debugging tool. When something is slow or wrong in dev, open DevTools → Network and look at which file is slow or which file is failing. There’s no opaque bundle to dig through.
-
Very large apps may experience “request waterfall” lag during the first page load. If
main.tsximports A, A imports B, B imports C — the browser has to fetch A to discover B, fetch B to discover C. Each round trip is fast (~1ms locally), but at 3,000+ modules, that adds up. This is precisely why Vite is experimenting with “Full Bundle Mode” — bundling during dev for very large codebases, now that Rolldown is fast enough.
Core Idea 3: Plugins run in both dev and prod, but the world they see is different.
Vite has one plugin API. The same plugin definition runs during vite dev (per-file, on demand, as the browser requests things) and during vite build (in a Rolldown bundle pipeline, batch-processing everything). This unified API is the reason Vite has the ecosystem it has — frameworks only have to write integration code once.
But: the execution context is wildly different.
During dev, Vite calls plugin hooks in response to individual HTTP requests. The transform hook runs when the browser asks for one file. There’s no concept of a final bundle. generateBundle and renderChunk (output hooks) never fire. The module graph is built lazily.
During build, Rolldown invokes the same plugin in a batch pipeline. It calls resolveId and load and transform for every file in the dependency graph, then renderChunk and generateBundle at the end. The full graph is known.
What this predicts:
-
A plugin that works in dev may break in build, and vice versa. Specifically, plugins that depend on output hooks won’t do anything in dev. Plugins that maintain global state across
transformcalls may see different ordering in build vs dev. -
The
applyoption ('serve'or'build') exists for this reason. Plugins can opt themselves into one mode only when their work isn’t relevant to the other. -
Pre/post ordering matters. Plugins have an
enforce: 'pre' | 'post'option that controls when in the chain they run.preplugins run before Vite’s internal plugins (useful for source-level transforms that should see raw code).postruns after (useful for emitting things based on what other plugins produced). -
Most plugins you’ll write should be inline in your config. A plugin is just an object with hooks. You don’t need a package for it. Need to log every imported file? Five lines, dropped right into
vite.config.ts.
Core Idea 4: Dev is fast because it skips work, not because it’s faster at the same work.
This sounds obvious, but it changes how you reason about Vite vs the alternatives. Webpack tried to make bundling faster. Vite skips bundling. Turbopack tries to make incremental bundling faster. Vite skips incremental bundling.
The asymmetry: most of what makes a production build slow (whole-graph analysis, tree-shaking, code splitting, minification) is not work you need during development. You need to see your edits reflected in the browser. The browser doesn’t care about minified output. It doesn’t care about optimal chunk boundaries. It just needs working JavaScript.
What this predicts:
-
Vite’s dev fast-path doesn’t help your production build. Production builds still do the slow work — they have to. Vite’s production build time is roughly comparable to Rollup (better with Rolldown in v8+, but still bundler work). If your CI is slow because of production builds, “switch to Vite” only helps to the degree Rolldown beats Rollup, which is real (10-30x in benchmarks) but not magic.
-
Vite’s speed advantage shrinks as your dev requirements creep toward “production-like.” If you need source maps, full type checking, lint-on-save, complex SSR, every framework’s bells and whistles — you’re adding work back. The 300ms startup is a starting budget. Plugins spend from it.
-
The right mental frame for “Vite is faster” is “Vite asks for less work.” Webpack-with-aggressive-caching is competitive in pure compile speed on changed files. What Vite beats it on is the work that didn’t have to happen at all.
These four ideas — split world, browser-as-pipeline, one plugin API two contexts, fast-by-skipping — predict almost every Vite behavior. When something surprises you, ask which of these is being violated or stretched.
6. The Architecture in Plain English
Let’s narrate what happens when you npm run dev and then open the browser.
Server startup
The vite CLI launches a Node.js process. It reads vite.config.ts (using a tiny built-in TS loader, because the config file itself usually isn’t transpiled yet). It resolves all plugins. It calls each plugin’s config and configResolved hooks — these let plugins mutate the final config. So far, this takes about 100ms.
Then Vite starts the dep scan. It crawls your source files starting from index.html, looking for bare imports (import X from 'lodash'). It uses fast regex-based scanning, not full parsing. It builds a list of npm packages your app uses.
Vite hands this list to Rolldown (as of v8) and asks for an ESM bundle of each one. Rolldown processes them in parallel. The result lands in node_modules/.vite/deps/. Each file gets a content-hash query string for cache busting. The hash key is derived from your lockfile, your Vite config, and NODE_ENV — anything that could change the right answer.
Vite spins up an HTTP server (using connect, the same middleware framework as Express) and a WebSocket server. The HTTP server binds to port 5173. Vite prints “ready in 287 ms” and waits.
If --open was passed, Vite triggers server.warmup — pre-transforming the entry files so they’re cached when the browser arrives milliseconds later. Worth knowing about for big apps.
First page load
The browser requests http://localhost:5173/. Vite’s middleware serves index.html, but with one transformation: it injects a <script type="module" src="/@vite/client"> at the top. This is the HMR client — a tiny runtime that opens a WebSocket back to the dev server.
The browser parses the HTML, finds <script type="module" src="/src/main.tsx">, and requests /src/main.tsx over HTTP. Vite’s middleware handles this. It:
- Resolves the URL to the file on disk.
- Checks if the file is in the module graph already. If not, adds a node.
- Runs every plugin’s
transformhook in order. For a.tsxfile, the React plugin’s transform runs Oxc to compile JSX → JS and inject Fast Refresh runtime markers. - Rewrites imports. This step is the most important transformation. The source file says
import React from 'react'. The browser can’t fetch'react'. Vite rewrites it toimport React from '/node_modules/.vite/deps/react.js?v=ab9c2f1'— a path the browser can fetch. - Sends the transformed code back with
Content-Type: application/javascript.
The browser receives the transformed module, parses it, sees its imports, and starts fetching them. Each one cycles through the same pipeline. The module graph grows as a side effect of the browser walking it.
For dependency requests (/node_modules/.vite/deps/react.js), Vite just serves the pre-bundled file with strong cache headers. The browser caches it forever (until the version query changes).
This is why startup is fast: there’s no “compile everything first” step. The browser is the scheduler.
HMR
Vite watches your filesystem using chokidar. You save src/Button.tsx. Chokidar fires a change event. Vite’s HMR handler:
- Looks up
Button.tsxin the module graph. - Walks up the graph (importers of
Button.tsx, importers of those, etc.) until it finds an HMR boundary — a module that calledimport.meta.hot.accept(). For React, the React plugin marks every React component as a boundary automatically. - Sends a WebSocket message to the browser:
{ type: 'update', path: '/src/Button.tsx', ... }. - The browser’s HMR client receives the message. It dynamically
import()s the new version ofButton.tsx(Vite returns the freshly transformed code). It invokes the registeredacceptcallback, which (for React) tells Fast Refresh to re-render the component with the new implementation. - State preserved. No reload. Total time: tens of milliseconds.
If no boundary is found (e.g. you edited a module that affects the root of the app), Vite gives up and sends a full-reload message. The browser reloads.
If the change is to a CSS file, the path is simpler — Vite sends an update, the client swaps out the <style> tag with the new content. No state lost, no DOM blink.
Where state lives
This is the killer insight for debugging:
- The module graph lives in the Node process. It’s not persisted. Restart Vite, it rebuilds from scratch.
- The dep cache lives on disk at
node_modules/.vite/deps/. Persisted across restarts. Invalidated by lockfile/config hash. - The browser holds its own state — the transformed code it’s already fetched, the WebSocket connection, application state. Vite has no view into it; it can only send messages.
When something is wrong:
- Wrong source code in browser? It’s a transform problem — check Vite’s terminal output, check the file content with
view-source:. - Wrong dep version in browser? Pre-bundling cache is stale.
--force. - Browser sees old code after restart? Browser cached aggressively. Hard reload, or disable cache while DevTools open.
- HMR not firing on file change? Filesystem watcher issue (especially on Linux/WSL2). Check
server.watch.usePollingif you’re in a Docker container.
Production build
vite build is a different beast. Vite hands the module graph entry points (index.html typically) to Rolldown. Rolldown:
- Walks the graph and reads every file.
- Runs every plugin’s
transformhook (same plugins as dev). - Tree-shakes (eliminates unused exports).
- Does code-splitting based on dynamic
import()calls — each dynamic import becomes a separate chunk. - Identifies “common” chunks shared between multiple async chunks.
- Minifies (via Oxc minifier by default; you can opt into Lightning CSS for CSS).
- Hashes filenames based on content.
- Generates the final
index.htmlwith rewritten asset URLs and<link rel="modulepreload">directives for known direct imports of dynamic chunks (the “preload waterfall optimization”). - Writes everything to
dist/.
The key thing: in dev, the browser walks the graph and Vite responds per-request. In build, Rolldown walks the graph and produces files. Same module graph concept, very different machinery.
This is the moment to internalize Vite’s deepest architectural commitment: dev and prod produce different output, by design. Dev gives the browser a faithful representation of source modules; prod gives it an optimized bundle. As of Vite 8, both phases share Rolldown for the heavy lifting, which closes the longstanding gap where dev used esbuild and prod used Rollup with subtle behavioral differences. But the shape of what’s served is still different — dev: many modules; prod: few chunks. This is the source of an entire category of bugs (Section 11).
7. The Things That Bite You
Gotcha 1: “Works in dev, broken in prod”
The intuitive model: if my app works in vite dev, it’ll work in vite build && vite preview. The reality (especially through Vite 7): dev and prod use(d) different bundlers with different semantics. Even in v8 with Rolldown unifying both, there are differences. Dev serves each module individually with side effects firing in import order as the browser walks the graph; prod bundles modules together and tree-shakes side-effecting imports differently.
Why it happens: In dev, every module is fetched and evaluated separately. In production, modules are bundled together, which can change evaluation order. Tree-shaking can also drop modules with side effects you depended on without realizing.
How to handle it: Run vite preview before considering anything “done.” Test locally against dist/, not against the dev server. If you have a CI pipeline, make sure the production build is what’s deployed — don’t vite dev in CI smoke tests. When debugging a “prod-only” bug, the first question is “does the module rely on a side effect on import?” Common culprit: a top-level console.log you used to debug something, or a module that registers itself in a global registry.
Gotcha 2: Bare imports outside the dep scanner’s reach
The intuitive model: I import 'some-package' somewhere, Vite finds it. The reality: Vite’s dep scanner uses regex on source files, before plugins run. If a plugin generates an import (e.g. a CSS-in-JS plugin that injects import 'styled-components' after transform), the scanner won’t see it. The browser hits the import, Vite scrambles to pre-bundle it on the fly, then triggers a page reload.
Why it happens: Pre-bundling is a startup phase. The scan is intentionally cheap and shallow. Plugin-generated imports happen later in the pipeline.
How to handle it: Add the package explicitly to optimizeDeps.include. Symptom: you see “new dependencies optimized” in the terminal followed by an unexplained browser reload, every time the dev server starts.
Gotcha 3: Linked / workspace dependencies
The intuitive model: my monorepo workspace package is just a package; Vite handles it the same as a node_module. The reality: Vite uses “is the resolved path inside node_modules?” as the rule. Workspace packages with pnpm end up symlinked, and Vite’s heuristics get inconsistent — sometimes the package is bundled (treated as a dep, doesn’t HMR on edit), sometimes treated as source (HMRs correctly, but may break if the package ships CJS exports).
Why it happens: This is the source/dep split (Mental Model 1) leaking. The rule “is it in node_modules?” is a proxy for “does it change frequently?” Symlinks break that proxy.
How to handle it: For an ESM-only workspace package, exclude it from optimization: optimizeDeps.exclude: ['@org/my-pkg']. Vite will treat it as source and HMR will work. For a CJS or mixed package, you have to include it: optimizeDeps.include: ['@org/my-pkg'] plus build.commonjsOptions.include: [/my-pkg/]. When you change the workspace package, restart Vite with --force. Yes, this is annoying.
Gotcha 4: The aggressive dependency cache
The intuitive model: I deleted node_modules, reinstalled, my app should be fresh. The reality: Vite’s dep cache at node_modules/.vite/ is keyed by lockfile hash and config hash. If you ran a previous version’s Vite, the cache might still match the new lockfile (because npm install is deterministic), and you’d be running stale bundled deps.
The browser also caches dependency URLs forever (max-age=31536000,immutable). Until you bump the version query.
Why it happens: This is mostly a feature — the cache makes reloads fast. But cache invalidation is hard.
How to handle it: When in doubt, --force (or rm -rf node_modules/.vite). Don’t bother debugging “is my dep stale?” — just bust the cache and confirm. If you’re authoring a workspace dep and your changes aren’t taking effect, this is almost certainly the cause.
Gotcha 5: Default imports from CJS
The intuitive model: import express from 'express' gives me the express function, because that’s what module.exports = express should do. The reality: depending on the package’s package.json exports field and the version of Vite/Rolldown, you might get { default: express } instead.
Why it happens: ESM and CJS have a fundamentally different shape. ESM has named exports including default; CJS has a single module.exports value. When a CJS module is converted to ESM, there’s ambiguity: is module.exports the default export, or is it the whole module namespace? Different tools have answered this differently over the years.
How to handle it: If you get an is not a function error on a default import of a CJS module, try import * as foo from 'foo' and inspect foo. You’ll probably find your value at foo.default or directly on foo. The fix is usually import express from 'express' → import { default as express } from 'express', or the package needs to ship proper ESM (file an issue upstream).
Gotcha 6: process.env in browser code
The intuitive model: I migrated from Webpack which polyfilled process.env, the same code should work. The reality: Vite does not polyfill Node.js globals. process.env.NODE_ENV in browser code is undefined; you’ll get a process is not defined error in older code or wrong-shaped data in newer code.
Why it happens: Vite is opinionated. The browser is not Node. Polyfilling encourages writing code that doesn’t belong in a browser.
How to handle it: Use import.meta.env.MODE (or .PROD, .DEV) instead of process.env.NODE_ENV. For custom variables, use VITE_-prefixed .env variables and access them via import.meta.env.VITE_FOO. If you absolutely need process.env for a third-party library, use the define config option: define: { 'process.env.NODE_ENV': JSON.stringify(mode) }. This does a literal find-and-replace at build time — be careful, it’s a sledgehammer.
Gotcha 7: Dynamic imports with variable file extensions
The intuitive model: I can dynamically import any file. The reality: Vite’s dynamic import scanning is restricted to a fixed pattern. import(./modules/${name}.js) works (extension is literal). import(name) does not (entirely dynamic, would bundle the entire file system). import(./modules/${name}) does not (no extension).
Why it happens: For dynamic imports to work in production, Vite needs to know at build time which files might be imported, so it can produce chunks for them. A fully-dynamic path can’t be analyzed.
How to handle it: Always include the literal directory and extension. Use import.meta.glob('./modules/*.js') for the “import everything that matches a pattern” use case. It generates a literal map at build time and lazily imports each one.
Gotcha 8: HMR full-reload on circular dependencies
The intuitive model: my edit was inside a leaf component, why is the whole page reloading? The reality: if your modules have a circular dependency (A imports B imports A), Vite often can’t propagate HMR safely — re-executing one module in the cycle creates ordering chaos — so it falls back to a full reload.
Why it happens: HMR works by walking the import graph from the changed module up to a boundary, re-importing the boundary, and trusting the framework to re-render. A cycle has no clean “up” direction.
How to handle it: Run vite --debug hmr to see when a full reload was triggered by a cycle (Vite logs the cycle path). Then break the cycle — usually by extracting the shared interface into a third module that both A and B import. The error message in your terminal will tell you exactly which files are involved.
Gotcha 9: CSS injection during SSR
The intuitive model: I do import './styles.css' in my component, Vite handles it. The reality: in dev SSR, Vite’s CSS handling can clash with framework hydration. The dev server injects styles via runtime JavaScript (a <style> tag added by an import './styles.css'). If your framework hydrates the document element, that hydration can wipe out the injected styles, and your dev app looks unstyled while production works fine.
Why it happens: Dev uses runtime CSS injection (so HMR works for styles). Production extracts CSS into static <link> tags in the HTML, which hydration doesn’t touch.
How to handle it: Mostly framework-specific. Vite-based frameworks (Nuxt, SvelteKit, Astro) handle this for you. If you’re writing your own SSR setup, you may need to use ?url imports for CSS in SSR contexts and load them as link tags, or special-case dev mode.
Gotcha 10: HMR over a reverse proxy
The intuitive model: I put nginx in front of vite dev for HTTPS, everything works. The reality: HMR uses WebSockets. Reverse proxies often don’t proxy WebSocket connections by default. HMR silently stops working — your file saves do nothing, you have to manually reload.
Why it happens: WebSockets use an HTTP upgrade handshake. Proxies need explicit configuration to support it (proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; in nginx).
How to handle it: Either configure the proxy properly, or use server.hmr.clientPort to tell the client where to connect to the WebSocket directly, bypassing the proxy. Worth knowing about specifically because the failure mode is silent.
8. The Judgment Calls
Judgment 1: Vite vs. a meta-framework (Next.js / Nuxt / SvelteKit / Remix)
The split most teams get wrong: Vite is a build tool. Next.js/Nuxt/SvelteKit/etc. are frameworks built on top of build tools. They use Vite (Nuxt, SvelteKit, Astro, Remix) or their own bundler (Next.js with Turbopack), and add a router, data fetching, SSR/SSG patterns, file-system routing, and deployment integration.
Reach for raw Vite when: You’re building a single-page app with client-side routing and a separate backend API. You want full control. You’re a library author. You’re building a tool that itself needs a build step (a CLI, a docs site that’s not Markdown-driven, an admin panel).
Reach for a meta-framework when: You need SSR, SSG, or ISR. You need server endpoints colocated with your UI. You care about SEO. You don’t want to think about routing, data loading, or deployment patterns. Anything user-facing in production almost always belongs here.
The signal: if you’re typing import { BrowserRouter } from 'react-router-dom', you’re probably one rung too low — most teams would be better served by a framework that owns the router. Raw Vite for a “real” public-facing web app is increasingly a niche choice in 2026.
Judgment 2: Library mode vs. a real bundler-for-libraries (tsup, tsdown, unbuild)
Vite has build.lib. It works. But it’s a simple, opinionated wrapper around Rolldown — it produces ESM and one CJS-flavored output, externalizes peer deps, and that’s it. It’s not designed for libraries with multiple entry points, conditional exports, dual ESM/CJS publishing with proper subpath exports, or .d.ts emission.
Use Vite library mode when: Your library is browser-only, single entry, framework-specific (a Vue component library, a React component library), and you want a fast iteration loop with vite dev against a demo index.html.
Use tsdown (or unbuild) when: Your library needs Node and browser support, multi-entry, complex conditional exports, type declarations (.d.ts), bundling control, or library-specific concerns like bundling internal modules while externalizing dependencies. tsdown is from the same team as Vite and Rolldown — it’s the “for libraries” sibling.
Use tsc alone when: You’re publishing a pure TypeScript library with no bundling needed. Just tsc --emitDeclarationOnly && tsc. Don’t reach for a bundler at all. Boring is good.
Judgment 3: Pre-bundling vs. excluding from pre-bundling
For a given dependency, do you let Vite pre-bundle it (default), force it to (optimizeDeps.include), or skip it (optimizeDeps.exclude)?
Include when: the dep is large and ESM but imported from a virtual module that the scanner misses; the dep is CJS-only and gets discovered too late, causing reload churn at startup; the dep is in a workspace package not detected as ESM.
Exclude when: the dep is small and already valid ESM; you’re authoring a local package and want to edit it without restarting Vite; the dep is your own workspace package; the dep is a wasm-loading package that breaks pre-bundling.
Default (let Vite decide) when: it’s a normal published npm package. Don’t touch this until you have a reason to.
The signal: if you see “new dependencies optimized” in the terminal during normal use (not just on startup), include them. If a workspace package isn’t HMR-ing, exclude it. If you see “cannot find module …/node_modules/.vite/deps/…” after an HMR update, the cache is fighting you — --force.
Judgment 4: When to write a custom plugin
The temptation: I see Vite has a plugin API, I’ll write a plugin for everything. The reality: most “I need a plugin” needs are already covered by Vite features (asset imports, glob imports, raw imports, environment variables).
Write a plugin when: you genuinely need to transform code in a way Vite doesn’t (e.g. compiling a custom DSL to JS, generating types from a schema, processing a custom file extension). Or you need to inject middleware in dev (e.g. proxy with custom logic). Or you need to compile a unique source format (MDX is a great example).
Don’t write a plugin when: you want to alias an import (use resolve.alias), inline a small file (use ?inline), import as a string (use ?raw), conditionally include code (use import.meta.env.DEV), or run a script alongside the dev server (just use npm-run-all or concurrently).
The signal: if your “plugin” is mostly return code.replace(...), you should probably be using a Rollup plugin like @rollup/plugin-replace or vite-plugin-string-replace. Reinventing common transforms inside an ad-hoc plugin is the slow path to a fragile build.
Judgment 5: SSR with raw Vite vs. with a framework
Vite’s SSR support is described in the docs as a “low-level API meant for library and framework authors.” Read that sentence twice. It means: Vite gives you the primitives — ssrLoadModule, transformIndexHtml, the Environment API — and expects you to wire up the production server, the hydration entry, the route resolution, and the deployment.
Build SSR on raw Vite when: you’re building a framework yourself. You have a unique runtime constraint (a niche edge platform, a custom orchestration). You really, really want full control and are okay with maintaining 800 lines of glue code yourself.
Use a meta-framework’s SSR when: you have a normal web app. Even “I want to use React with my own router” is overwhelmingly better served by Remix/React Router 7 or TanStack Start than by hand-rolling SSR on Vite.
The hard-won signal: the Vite SSR doc page repeatedly says “if your goal is to create an application, check out the higher-level SSR plugins.” That’s not boilerplate humility. They mean it.
Judgment 6: The Environment API (use it or wait?)
As of Vite 6 (late 2024) and onward, the Environment API lets you model multiple build targets — client, SSR, Cloudflare Workers, Deno Deploy, service worker, etc. — each with its own module graph and config. It’s the long-term replacement for the old “client vs SSR” binary.
Use it when: you’re authoring a framework that needs to target multiple runtimes. You’re integrating with an edge platform (Cloudflare, Vercel Edge, Deno) and want dev parity with production.
Don’t reach for it when: you’re an app developer using a meta-framework — Nuxt/SvelteKit/etc. abstract this away from you. The Vite docs themselves say “We don’t recommend switching to Environment API yet” for plugins, until ecosystem adoption catches up.
The signal: if you don’t know what an environment is for you, you don’t need the Environment API. It’s plumbing for framework authors. The vast majority of Vite users will never touch it directly.
Judgment 7: Vite vs. Webpack vs. Turbopack vs. Rspack on an existing project
Stay on Webpack when: you have a multi-year-old enterprise codebase with extensive custom loaders, you use Module Federation (Webpack’s micro-frontend story is still the most mature), or you have hundreds of lines of webpack config that encode business-critical behaviors. The migration cost is real; the dev-experience win is real. Calculate the ROI. Migrating a 50-engineer app from Webpack to Vite takes weeks-to-months of work, not days.
Migrate to Vite when: dev startup is genuinely hurting productivity, your config is shallow, your dependencies are mostly ESM. Greenfield is a no-brainer; in-place migration is a calculation.
Use Turbopack when: you’re on Next.js. The integration is tight, the Vercel team is investing heavily, and you don’t have a meaningful choice anyway — Next.js doesn’t fit on Vite cleanly. (There are community efforts, but the official path is Turbopack.)
Use Rspack / Rsbuild when: you want a near-drop-in Rust-powered replacement for Webpack with maximum config compatibility, but don’t want to switch mental models. It’s “Webpack but fast.” Good for in-place upgrades of complex Webpack setups where you can’t afford to rewrite everything.
The signal: pick the tool that your framework picked. If you’re on Vue/Svelte/Solid/Astro/Remix, the answer is Vite, no further thought needed. If you’re on Next.js, it’s Turbopack. If you’re framework-less or hand-rolled, Vite is the default choice for new code in 2026.
Judgment 8: TypeScript checking inside or outside Vite
Vite transpiles TypeScript (via Oxc) but does not type-check it. This is by design — type checking requires whole-program analysis, which is fundamentally at odds with Vite’s per-file transform model.
Run type-check separately (recommended): tsc --noEmit --watch in another terminal, or as a pre-commit hook, or in CI. Faster Vite startup, cleaner separation.
Use vite-plugin-checker when: you want type errors to surface in the browser overlay during dev. Be aware: it runs tsc in a worker process, which adds CPU pressure on every change. Convenient but not free.
Don’t try to make Vite type-check itself. It can’t, and any plugin promising “type checking in Vite” is just shelling out to tsc behind the scenes.
Judgment 9: When to abandon the dev server’s defaults
Out of the box, vite dev listens on localhost:5173, opens browser-to-Node connections, and assumes file-system access. This breaks in some realistic environments:
- Docker / dev containers: bind to
0.0.0.0viaserver.host: true. WSL2 may needusePolling: truefor file watching. - Behind a reverse proxy with HTTPS: configure
server.hmr.clientPortandserver.hmr.protocol. Otherwise WebSocket fails silently. - Mobile testing on the local network:
--host, then access via your laptop’s IP. Don’t forget thatlocalhostreferences in your code (API base URLs, etc.) break on mobile. - Multiple Vite projects at once: each grabs a port (5173, 5174, etc.). For deterministic port assignment, set
server.portandserver.strictPort: true. - Big repos with watched node_modules: by default Vite ignores
node_modulesfor watching. If you’re editing a workspace dep, add a negated pattern toserver.watch.ignored.
The signal: if you’re spending significant time fighting the dev server, you’re not crazy — these defaults are tuned for the median developer on a Mac. Anyone in Docker, WSL2, or behind enterprise infra usually needs a handful of overrides.
Judgment 10: Code-splitting strategy
Vite (via Rolldown) does automatic code splitting around import() calls. For most apps that’s enough. But sometimes you want to force certain libraries into their own chunks — e.g. put react and react-dom in a vendor chunk that caches separately.
Don’t customize chunks when: your app is small, you don’t care about precise cache strategies, you trust the defaults. Default behavior is well-tuned.
Customize chunks when: you have a measurable cache-hit goal (e.g. you want vendor chunks that change rarely to be cached for a year while app code rotates), you have a known-large dep that should be its own chunk for parallel loading, or you have a micro-frontend architecture with shared deps.
As of Vite 8: the API for this changed. Old manualChunks (object form) is gone, function form is deprecated. Use build.rolldownOptions.output.codeSplitting or the advancedChunks config. If you’re migrating from Vite 7 with chunk customization, this is the breaking change to budget for.
9. The Commands/APIs That Actually Matter
This section is the 20% you use 80% of the time, with why. The Distilled Introduction covered them in workflow context; here they’re grouped by task for quick reference.
Daily-driver CLI
vite # dev server, equivalent to `vite dev`
vite --open # also opens browser; warms up entry files
vite --host # expose to LAN (needed for mobile/Docker)
vite --port 3000 --strictPort # deterministic port (CI, multi-project setups)
vite --force # delete dep cache; THE universal "fix it"
vite build # production build
vite build --watch # rebuild on changes (library development)
vite build --mode staging # use .env.staging instead of .env.production
vite preview # serve dist/ for sanity-checking the build
Debugging-flag CLI (less known, very useful)
vite --debug # log everything (verbose; for triage)
vite --debug transform # log each file's transform time
vite --debug hmr # log HMR decisions and circular-dep paths
vite --debug plugin-transform # log plugin transform timings
vite --profile # collect CPU profile; press 'p' to dump to .cpuprofile
The --debug family is underused. When something is “Vite-being-weird”, these tell you what Vite thinks is happening.
Essential config keys
import { defineConfig } from 'vite'
export default defineConfig({
// The plugins list. Order matters.
plugins: [react()],
// Path resolution
resolve: {
alias: { '@': '/src' },
// For monorepos with shared types:
dedupe: ['react', 'react-dom'], // prevent duplicate copies
},
// Dev server config
server: {
port: 5173,
host: true, // expose to LAN; needed in Docker
proxy: {
'/api': { target: 'http://backend:8080', changeOrigin: true },
'/ws': { target: 'ws://backend:8080', ws: true },
},
warmup: {
clientFiles: ['./src/main.tsx', './src/router.tsx'],
},
fs: {
// For monorepos: allow access outside project root
allow: ['..'],
},
},
// Dependency pre-bundling
optimizeDeps: {
include: ['some-package-the-scanner-misses'],
exclude: ['@my-org/workspace-pkg'],
},
// Production build
build: {
target: 'es2020',
sourcemap: true, // sourcemaps in prod (recommended for error tracking)
outDir: 'dist',
assetsInlineLimit: 4096, // inline assets smaller than this as base64
rolldownOptions: {
output: {
// Advanced chunk control if you need it
},
},
},
// Env variable handling
envPrefix: 'VITE_', // default; only these get exposed to client
// Define replacements (compile-time variables)
define: {
__BUILD_TIME__: JSON.stringify(new Date().toISOString()),
},
// SSR config (if you're doing SSR yourself)
ssr: {
noExternal: ['some-cjs-package'], // bundle this into the SSR output
},
})
Import-time query suffixes (the “secret syntax”)
These are not standard JavaScript. They’re a Vite extension that controls how imports resolve:
import url from './img.png' // → asset URL (default)
import url from './img.png?url' // → asset URL (explicit)
import data from './img.png?inline' // → base64 data URL (force inline)
import text from './shader.glsl?raw' // → file content as string
import Worker from './worker.ts?worker' // → Web Worker constructor
import workerUrl from './worker.ts?worker&url' // → worker as URL
import css from './style.css?inline' // → CSS as string, NOT injected
These are enormously useful. Worth committing to memory.
import.meta APIs
// Compile-time env (replaced literally)
import.meta.env.MODE // 'development' | 'production' | etc.
import.meta.env.DEV // true in dev
import.meta.env.PROD // true in prod
import.meta.env.SSR // true when SSR
import.meta.env.BASE_URL // app base URL
import.meta.env.VITE_API_URL // user-defined VITE_*-prefixed var
// HMR (runtime API; only present in dev)
if (import.meta.hot) {
import.meta.hot.accept(newModule => {
// re-initialize with new module
})
import.meta.hot.dispose(() => {
// cleanup before replacement
})
import.meta.hot.invalidate() // give up; ask for full reload
}
// Asset URL resolution (native ESM; Vite-aware)
const imgUrl = new URL('./img.png', import.meta.url).href
// Bulk import (Vite-specific)
const modules = import.meta.glob('./pages/*.tsx') // lazy
const modules = import.meta.glob('./pages/*.tsx', { eager: true }) // eager
import.meta.glob deserves special mention. It’s how you implement file-system routing, lazy route loading, plugin discovery — anything where you want to enumerate a directory of files at build time without naming each one. Combined with eager: true and named imports, it tree-shakes well.
Plugin authoring essentials
// vite.config.ts - inline plugin
import type { Plugin } from 'vite'
const myPlugin = (): Plugin => ({
name: 'my-plugin', // required
enforce: 'pre', // optional: 'pre' | 'post'
apply: 'serve', // optional: 'serve' | 'build'
// Run before config is resolved
config(config, { command, mode }) {
return { /* merged into config */ }
},
// Resolve a custom specifier
resolveId(id) {
if (id === 'virtual:foo') return id
},
// Provide the source for a module
load(id) {
if (id === 'virtual:foo') return `export const foo = 'bar'`
},
// Transform a module
transform(code, id) {
if (id.endsWith('.special')) {
return { code: transformedCode, map: sourceMap }
}
},
// Inject middleware in dev
configureServer(server) {
server.middlewares.use('/health', (req, res) => res.end('ok'))
},
// Modify the HTML
transformIndexHtml(html) {
return html.replace('<title>App</title>', '<title>Custom</title>')
},
})
The “virtual module” pattern (resolveId + load) is one of the most useful tricks. It lets you write import x from 'virtual:my-thing' in source and have a plugin synthesize the content. Frameworks use this everywhere — Nuxt’s auto-imports, SvelteKit’s $app/stores, etc.
10. How It Breaks
Failure mode 1: Server starts but the page is blank
Symptoms: Browser shows nothing. DevTools console either shows an error or shows the WebSocket connecting and then silence. Network tab shows requests succeeding but no rendering.
Likely causes:
- Plugin error in
transformthat returned malformed JS. Check Vite’s terminal — Vite usually logs transform errors clearly. - Wrong base path. If you set
base: '/foo/'but you’re loading from/, assets resolve wrong. - Browser extension blocking the WebSocket or specific chunk requests (ad blockers blocking files with “track” or “ad” in the name is a real issue).
Diagnostic steps:
- Open DevTools → Console. Errors there are your fastest signal.
- Open Network. Look for failed (red) requests.
- Open
view-source:on the page. Make sure the script tags are pointing at the right URLs. - Try in an incognito window (rules out browser extensions).
Failure mode 2: HMR isn’t working
Symptoms: File saves don’t trigger updates. You have to manually reload.
Likely causes:
- WebSocket isn’t connecting (check DevTools → Network → WS, should show an active connection).
- You’re behind a reverse proxy that doesn’t proxy WebSockets.
- File watcher isn’t firing — common on Linux at file watcher limits, Docker, or WSL2 editing files from Windows.
- The change requires a full reload (no HMR boundary in the import path).
- Circular dependency triggering a full reload.
Diagnostic steps:
- DevTools → Network → WS. Is there a connection? If not, fix WebSocket connectivity (reverse proxy config, or
server.hmr.clientPort). vite --debug hmr. Vite will log every file change it sees and every HMR decision.- If on Linux, check
cat /proc/sys/fs/inotify/max_user_watches. If less than 524288, increase it. - If in Docker/WSL2, try
server.watch.usePolling: true. Slower but works.
Failure mode 3: Production build fails with cryptic errors
Symptoms: vite dev works. vite build fails with something about a transform or chunk error.
Likely causes:
- A dep that “works” in dev (because of pre-bundling) breaks in build (different processing path). Common with mixed-format packages or packages that import Node-only modules.
- TypeScript types are wrong somewhere (Vite doesn’t catch these in dev; production catches more aggressive issues).
- A circular import that dev tolerates but tree-shaking can’t handle.
- Plugin that emits invalid code in build mode.
Diagnostic steps:
vite build --debugfor verbose output.- Try
vite build --sourcemapand use the source maps to find the actual offending source line. - Remove plugins one at a time to find the culprit.
- If it’s a dep issue, look for an ESM-only version of the package, or add
ssr.noExternalif it’s an SSR build.
Failure mode 4: “Failed to fetch dynamically imported module”
Symptoms: Production-only error. Users see broken pages, especially after a deploy.
Likely causes: This is the version skew problem. User has the old HTML cached, references old chunk names (e.g. App-abc123.js). You deployed a new version where the chunk is now App-xyz789.js. Old chunk is gone. Dynamic import fails.
Diagnostic steps and fixes:
- Listen for
window.addEventListener('vite:preloadError', ...)andlocation.reload()on failure. This is the standard recipe — Vite emits an event specifically for this. - Configure your hosting to set
Cache-Control: no-cacheon the HTML response, so users always get fresh HTML. Assets can still be cached for a year because they’re hashed. - Consider keeping the previous deploy’s chunks live for a few hours during deploy.
This bug is not a Vite bug — it’s an inherent SPA-deploy issue. But Vite hands you the tools.
Failure mode 5: “Outdated pre-bundled deps” / cache fight
Symptoms: After branch switch, dependency change, or workspace package edit, you get errors like The file does not exist at "node_modules/.vite/deps/chunk-XYZ.js" or 504 Outdated Optimize Dep.
Likely causes: The dep cache is in a weird state. Browser is caching a URL with a version query that no longer matches the current cache.
Fix:
rm -rf node_modules/.vite
# Or equivalently:
vite --force
Don’t try to debug it. Bust the cache. If it recurs frequently in a workspace setup, look at your optimizeDeps.include/exclude config — you might be fighting Vite’s heuristics.
Failure mode 6: SSR hydration mismatch
Symptoms: Console error Hydration completed but contains mismatches or Text content does not match server-rendered HTML. Page rendering glitches on first paint.
Likely causes:
- Browser-only API used in render (
window,document,localStorage). - Date/time formatting that differs between server timezone and browser timezone.
Math.random()orDate.now()producing different values.- Module loaded with different versions between client and server bundles — this is a Vite-specific gotcha, where pre-bundling can create duplicate copies.
Diagnostic steps:
- Read the framework’s hydration error carefully — it usually names the offending element.
- Wrap browser-only logic in
if (import.meta.env.SSR)checks or inuseEffect. - For the duplicate-module issue, use
resolve.dedupe: ['react', ...]to force a single copy. - Try
server.warmup.clientFilesto ensure the client and server pre-bundle the same set of modules.
The general debugging workflow
When Vite isn’t working and you don’t know why, in order:
- Read the terminal output of
vite dev. Plugins log errors here. Transform errors log here. It’s usually obvious if you look. - Open DevTools → Network. Look for red failures. Look for the WebSocket. Look for files taking >100ms.
- Open DevTools → Console. Are there runtime errors? Are there Vite-specific warnings (
Vite Error Overlay)? - Try
vite --force. Cache-bust the deps. Solves 30% of “weird” issues. - Try
vite --debug. Specifically--debug hmr,--debug transform,--debug plugin-resolve. Vite logs surprisingly useful internal state. - Try in an incognito window or with a fresh browser profile. Rules out browser extensions and stale cache.
- Bisect the config. Comment out plugins one at a time. Find the culprit.
- Bisect the source. Comment out imports. Find the file that triggers the issue.
- Search the Vite GitHub issues. Many gotchas have known issues with workarounds. Don’t reinvent.
11. The Downsides / Disadvantages
After all the praise, here is the honest accounting.
Downside 1: Dev and prod are different worlds, and that difference is yours to manage.
The single sharpest cost of Vite’s architecture. Dev serves unbundled ESM, modules evaluate in import order as the browser fetches them, side effects fire one-by-one. Prod bundles, tree-shakes, hoists, and minifies. Even in Vite 8, where Rolldown handles both ends, the output shape differs by design.
Where it comes from: this is Core Mental Model 1 plus Core Mental Model 4 — split worlds and skip-work-during-dev — taken to their logical conclusion. The asymmetry is the feature; the inconsistency is the bill.
What it costs you: “Works in dev, broken in prod” debugging sessions. Every team using Vite eventually hits one. They’re harder to debug than bundler bugs because the dev experience is so smooth you trust it. You’re forced to run vite preview as a final check before every deploy, and ideally to run it in CI smoke tests too.
When it’s a dealbreaker: never quite — every modern build tool has some version of this. But it’s a tax. Plan for it.
What people think mitigates it but doesn’t: “Just write better code.” No. The bugs are structural — module evaluation order, side-effect tree-shaking, top-level await semantics — not coding hygiene. Vite 8 unifying the bundler closes the gap between dev and the production pipeline, not the gap between “many modules in dev” and “few chunks in prod.” That gap is permanent by design.
Downside 2: Performance degrades on truly large codebases (~3000+ modules in dev).
The unbundled-ESM model assumes a small-to-medium app. At 3000+ first-page modules, the browser has to issue thousands of HTTP requests on initial page load. Locally over HTTP/2 this is manageable but not instant. Over a slower link (Codespaces, remote dev environments), it becomes painfully slow.
Where it comes from: Mental Model 2 — the browser is part of the pipeline. The pipeline scales with request count.
What it costs you: cold page-load times in the 5-20 second range for the largest apps. Real engineers on real codebases report this. You don’t notice on a 50-file demo; you very much notice on a 3000-module enterprise app.
When it’s a dealbreaker: never, because Vite is shipping Full Bundle Mode to address exactly this (using Rolldown to also bundle during dev). But as of Vite 8, that’s experimental, not default. If you’re on a giant app today, your fastest path is to lean on server.warmup for the entry points and accept that the cold-load is a few seconds.
Downside 3: The plugin API is Rollup’s API, with Vite extensions and historical baggage.
Vite extends Rollup’s plugin API, which is good (huge ecosystem). But it means Vite plugins inherit Rollup’s quirks: hooks that don’t quite fit the per-request dev model, ordering semantics that are subtle, context objects that differ between dev and build. Writing a non-trivial plugin requires understanding both Rollup’s hook contract and Vite’s overlay.
Where it comes from: the decision to be compatible with Rollup’s ecosystem rather than design a clean-slate API. This was the right call strategically — without it, Vite would have started with zero plugins and lost the platform war.
What it costs you: when a plugin doesn’t work the way you expect, you have to read two sets of docs. The “Plugin API” page on the Vite docs basically says “go read Rollup’s plugin docs first.” Plugin authors sometimes get caught on the dev/build divide — a hook that works in build doesn’t fire in dev, or vice versa.
When it’s a dealbreaker: never, because you almost never need to write a plugin from scratch. But it’s a barrier to entry if you do.
Downside 4: SSR is “framework author territory,” not “app developer territory.”
Vite’s SSR API is genuinely low-level. It gives you primitives (ssrLoadModule, Environment API, hooks for transforming HTML), and expects you to assemble a full SSR pipeline yourself: route resolution, data fetching, server entry, client entry, hydration. There is no built-in router. There are no built-in conventions for loaders/actions. You write all of it.
Where it comes from: Vite is a build tool. It deliberately doesn’t try to be a framework. The lean-core philosophy.
What it costs you: if you try to do “DIY SSR on Vite,” you’ll write 500-1500 lines of glue code that essentially reimplements the same patterns every meta-framework already implements. You’ll discover all the edge cases (hydration mismatches, CSS injection during SSR, data serialization, route streaming) the hard way.
When it’s a dealbreaker: if you want SSR. Reach for a meta-framework (Remix, Nuxt, SvelteKit, Astro, SolidStart) and stop fighting. The teams that build apps “on raw Vite + SSR” almost always end up regretting it, or rebuilding 80% of a framework before realizing they should have just used one.
Downside 5: The configuration surface is huge and growing.
Vite’s config docs run to dozens of pages: server.*, build.*, optimizeDeps.*, resolve.*, css.*, ssr.*, worker.*, experimental.*, and now environments.* and build.rolldownOptions.*. Most config keys you never touch — but when you need them, you need them, and figuring out which knob applies to your situation takes real time.
Where it comes from: Vite is “lean core + extensible plugins” in philosophy, but the core has accrued many options over years to handle real-world cases. Plus the dual nature of build + serve means most concerns have two settings.
What it costs you: junior engineers configuring Vite often cargo-cult settings they don’t understand, copy-pasted from blog posts. Senior engineers can find the right knob, but it takes longer than it should. The config experience is “powerful and underdocumented per-option” rather than “small and well-explained.”
When it’s a dealbreaker: never, but it’s a real onboarding cost for teams.
Downside 6: ESM-only opinions break some legacy ecosystems.
Vite has chosen ESM as the universal source format. Node-only modules don’t work in the browser without explicit shims. CJS dependencies need pre-bundling, and some CJS edge cases (dynamic require(), circular CJS imports, monkey-patching module.exports) can’t be cleanly converted.
Where it comes from: the philosophy doc says it plainly — “source code can only be written in ESM” and “Node.js modules cannot be used in the browser.” Opinionated by design.
What it costs you: integrating with older codebases (Backbone, jQuery-era code, custom CJS-heavy internal libraries) requires upfront work to ESM-ify them. Some packages just don’t work. The ESM-first stance is forward-looking but unforgiving.
When it’s a dealbreaker: if you have a large in-house CJS-shaped library that you can’t easily migrate, Webpack’s permissiveness might be the safer choice for now. Vite is voting for the future of the platform, which is good — but it costs the legacy present.
Downside 7: The ecosystem moves fast, and breaking changes happen.
Vite 6 introduced the Environment API (with backward-compat). Vite 7 dropped Node 16 and 18 support. Vite 8 (March 2026) swapped Rollup for Rolldown, deprecated manualChunks object form, renamed esbuild config to oxc, and changed several other knobs. Each version migration is small individually; cumulatively over 18 months, it’s a real maintenance ask.
Where it comes from: the project’s “evolve with the platform” philosophy. The team treats it as a feature — they want Vite to track the web platform’s evolution. From the user perspective, it’s a churn cost.
What it costs you: every 6-12 months you spend a half-day to a day on a Vite major upgrade for each project. The migration guides are excellent; the compatibility layers help. But you can’t pin Vite forever — security patches force you to upgrade, and meta-frameworks pull you along.
When it’s a dealbreaker: never. The migrations are usually mechanical. But if you maintain dozens of Vite projects, the cumulative cost is non-trivial.
Downside 8: It’s so framework-agnostic that “Vite app” isn’t really a thing.
Vite is infrastructure. By itself, it gives you a dev server and a build command. What you actually want — a router, data fetching, deployment patterns — comes from your framework. This is a feature, but it means “I’m using Vite” tells someone almost nothing about how your app is structured. The Vite community is fragmented across Vue/React/Svelte/Solid/Astro communities because the tool itself doesn’t impose patterns.
Where it comes from: “Lean Extendable Core” + “Building Frameworks on Top of Vite” — both stated philosophies. Vite wants to be the substrate, not the framework.
What it costs you: best practices are framework-specific, not Vite-specific. The “Vite community” is small relative to the size of the ecosystem because most discourse happens in the framework community.
When it’s a dealbreaker: never. But if you wanted “the Vite way to do routing” or “the Vite way to fetch data,” there isn’t one. You have to pick a framework first.
12. The Taste Test
What does good vs bad use of Vite look like? When you glance at someone’s repo, what tells you whether they understand it?
Configuration: lean vs cargo-culted
Bad (vite.config.ts from a real-ish project):
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@components': path.resolve(__dirname, './src/components'),
'@utils': path.resolve(__dirname, './src/utils'),
'@hooks': path.resolve(__dirname, './src/hooks'),
'@pages': path.resolve(__dirname, './src/pages'),
'@assets': path.resolve(__dirname, './src/assets'),
'@types': path.resolve(__dirname, './src/types'),
'@services': path.resolve(__dirname, './src/services'),
},
},
server: {
port: 3000,
open: true,
cors: true, // ← cargo-culted; cors is already true by default
host: '0.0.0.0', // ← cargo-culted unless you actually need LAN access
},
build: {
outDir: 'dist', // ← default
assetsDir: 'assets', // ← default
sourcemap: true,
rollupOptions: { // ← wrong name in Vite 8! should be rolldownOptions
output: {
manualChunks: { // ← deprecated in Vite 8
vendor: ['react', 'react-dom'],
mui: ['@mui/material'],
},
},
},
},
})
This config has more lines than insight. Eight aliases each pointing one directory deep — they should just be relative imports, or one @ alias covering the whole src. Default values are restated. The chunk strategy is a guess (why mui as its own chunk? Did someone measure?). Vite 8 keys are stale.
Good:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'node:path'
export default defineConfig(({ mode }) => ({
plugins: [react()],
resolve: {
alias: { '@': resolve(import.meta.dirname, './src') },
},
server: {
proxy: { '/api': 'http://localhost:8080' },
},
build: {
sourcemap: true, // we ship source maps for Sentry; opt-in choice
},
}))
Twelve lines. Every line is there for a reason the author could explain. Mode-aware via the function form. No magic chunk customization until profiling shows it’s needed.
Rule of thumb: if you can’t explain why a config line is there, it shouldn’t be there.
Imports: explicit vs lazy
Bad:
import _ from 'lodash' // imports entire lodash, ~70KB
_.debounce(fn, 200)
Good:
import debounce from 'lodash-es/debounce'
debounce(fn, 200)
// or use lodash-es with destructured imports + tree shaking:
import { debounce } from 'lodash-es'
The good version tree-shakes. The bad version brings the entire library. In dev, the difference is invisible (pre-bundling handles both); in prod, the bad version inflates your bundle.
Asset handling: import the asset, don’t string-concat
Bad:
<img src="/src/assets/logo.png" /> // breaks in production; path won't exist
<img src={`/assets/${name}.png`} /> // hash gets in the way; broken in prod
Good:
import logo from './assets/logo.png'
<img src={logo} />
// Or for dynamic asset selection at build time:
function logoUrl(name: 'a' | 'b' | 'c') {
return new URL(`./assets/${name}.png`, import.meta.url).href
}
The bad versions rely on Vite not hashing assets, which only happens to be true in dev. The good versions let Vite track which assets are needed and rewrite paths.
Env variables: tight prefix, narrow scope
Bad:
// .env
DATABASE_URL=postgres://...
API_SECRET=sk-...
PUBLIC_API_URL=https://api.example.com
// in client code
const apiUrl = import.meta.env.PUBLIC_API_URL || 'fallback' // undefined; no VITE_ prefix
Good:
// .env (server-only stuff, NOT prefixed)
DATABASE_URL=postgres://...
API_SECRET=sk-...
// .env.local or .env.development for VITE_* (these end up in the client bundle)
VITE_API_URL=https://api.example.com
// in client code
const apiUrl = import.meta.env.VITE_API_URL
The bad version both fails (wrong prefix) and mixes secrets with client-side config. The good version respects the prefix as a security boundary: if it’s VITE_*, it’s effectively public — treat the prefix as a promise that this value is okay to ship to the browser.
Plugins: needed vs ambient
Bad:
plugins: [
react(),
vue(), // not actually using Vue
legacy({}), // shipping to legacy browsers? are you sure?
visualizer(), // bundle analyzer always running
compression(), // doing what nginx already does
pwa({}), // do you actually want a PWA?
inspect(), // dev tooling left in
],
Good:
plugins: [
react(),
// bundle analyzer only when explicitly requested:
process.env.ANALYZE && visualizer(),
].filter(Boolean)
Plugins are not free. Each one adds startup time, transform time, and potential conflict. Audit your plugin list every six months.
Dynamic imports: clean lazy boundaries
Bad:
// Eagerly imports everything, then conditionally renders. No code splitting.
import HeavyDashboard from './HeavyDashboard'
import HeavyAdmin from './HeavyAdmin'
function App({ user }) {
return user.isAdmin ? <HeavyAdmin /> : <HeavyDashboard />
}
Good:
import { lazy, Suspense } from 'react'
const HeavyDashboard = lazy(() => import('./HeavyDashboard'))
const HeavyAdmin = lazy(() => import('./HeavyAdmin'))
function App({ user }) {
return (
<Suspense fallback={<Spinner />}>
{user.isAdmin ? <HeavyAdmin /> : <HeavyDashboard />}
</Suspense>
)
}
Vite splits each dynamic import() into its own chunk. The admin code only loads for admins. The dashboard only loads for normal users. The eager version ships everything to everyone.
Workspace setup: explicit boundaries
Bad:
// vite.config.ts in a pnpm monorepo
export default defineConfig({
plugins: [react()],
})
// Then complains the workspace package isn't HMR-ing
// Then adds optimizeDeps.exclude
// Then notices duplicate React copies
// Then adds resolve.dedupe
// Then it kind of works
Good:
export default defineConfig({
plugins: [react()],
resolve: {
// Avoid duplicate React copies when workspace pkgs each pull their own
dedupe: ['react', 'react-dom'],
},
optimizeDeps: {
// Workspace ESM packages — let Vite treat them as source for HMR
exclude: ['@org/ui', '@org/utils'],
},
server: {
// Allow Vite to read from workspace package source paths
fs: { allow: ['..'] },
},
})
The good version anticipates monorepo issues upfront. The bad version discovers each one as a fire.
Production output: hash, don’t pin
Bad:
<!-- index.html in dist -->
<script src="/assets/main.js"></script> <!-- no hash -->
Good (Vite default):
<script type="module" src="/assets/index-DhJk2pq9.js"></script>
<link rel="modulepreload" href="/assets/vendor-Lf3kdsoq.js" />
If you find yourself disabling Vite’s hashing (build.rolldownOptions.output.entryFileNames: 'main.js'), have a really good reason. The default — hashed filenames with eternal cache headers — is the optimal caching strategy for most apps. Disabling it is almost always a mistake driven by an unfamiliarity with Cache-Control on the host.
The terminal: when something’s wrong
A senior engineer using Vite looks like this when something’s wrong:
- Glances at Vite’s terminal output (10 seconds).
- Glances at DevTools console (10 seconds).
- Glances at DevTools Network → WS (5 seconds).
- Runs
vite --forceif still confused (30 seconds). - Searches the GitHub issues with the exact error message (1 minute).
- Adds an
optimizeDeps.includeorexcludeif the fix matches their case.
A junior engineer looks like this:
- Refreshes the browser ten times.
- Restarts the dev server.
- Deletes
node_modulesand reinstalls. - Asks ChatGPT.
- Eventually gives up and reaches out to the team.
The difference isn’t intelligence. It’s knowing where Vite logs the answers (the terminal, the WebSocket, the --debug flags). The terminal is the most underused debug tool in Vite-land.
13. Where to Go Deeper
A short, curated list. Don’t try to read all of these. Pick what matches your need.
The official docs — read these specifically
- Why Vite and Philosophy: short, opinionated, written by the maintainers. Read both. They tell you what Vite is for better than this document does.
- Dependency Pre-Bundling: re-read this once you’ve actually used Vite for a month. It’ll make sense in a new way.
- Performance: when your dev server feels slow, this page tells you what to do.
- Troubleshooting: bookmark it. The Linux file watcher section alone has saved me hours.
Deep-dive primary sources
- The DeepWiki on Vite’s source at https://deepwiki.com/vitejs/vite: an auto-generated but high-quality walkthrough of Vite’s internals — HMR, plugin pipeline, module graph. The best resource if you ever need to debug Vite itself or write a serious plugin.
- Rolldown docs: as of Vite 8, Rolldown is half the story. The “in-depth” guides on its docs site explain bundling decisions Vite inherits.
- The Vite changelog: https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md. Read the breaking-change sections of major versions when you upgrade. The team writes them well.
Talks and recordings
- The Vite documentary (linked from https://vite.dev/): about an hour. Mostly origin story, but it’ll help you understand why the project’s culture is what it is.
- ViteConf talks on YouTube (search “ViteConf 2024” or 2025): the best ones are framework integration deep-dives — how Nuxt, SvelteKit, or Astro build on Vite. Watching how the experts use the plugin API is more useful than reading the plugin docs.
Hands-on projects to build
- A custom Vite plugin that does one thing. Pick something small — a virtual module, a file-extension handler, an HTML transform. The exercise of writing a plugin from scratch (no
create-vite-pluginscaffolding) is how the plugin API stops being mysterious. - A library published to npm via Vite library mode. Going through the publishing dance (peer deps, externalization, dual ESM/CJS exports, types) teaches you what library mode actually does and where its limits are.
- Migrate a Create React App project to Vite. Even on a small project, you’ll hit the realistic challenges: env variables (
REACT_APP_*→VITE_*),process.env→import.meta.env, public assets, dev server proxying.
When to keep digging
If you find yourself authoring framework-level tooling (a meta-framework, a code generator, a custom dev environment), the Environment API and the Module Runner are the next level of depth. The framework guide pages (api-environment-frameworks, api-environment-runtimes) are dense and worth reading slowly.
If your concern is performance and big codebases, follow the work on Full Bundle Mode in the Vite repo and the Rolldown community. This is where the next year of meaningful changes will happen.
14. The Final Verdict
Vite is the rare tool that earned its position not by being incrementally better than what came before, but by being qualitatively different — and then doing the unglamorous follow-through work of becoming the substrate everyone else builds on. That second part is what most “fast new tool” projects fail at. Vite didn’t fail. It became infrastructure. In 2026, when a serious frontend project starts and someone says “what’s the build tool?”, “Vite” is the answer-without-reasons that you have to consciously override to use anything else.
What Vite gets profoundly right: first, the recognition that the browser is no longer a passive consumer. Native ESM has been around since 2018; Vite was the first tool to take it seriously as part of the dev pipeline rather than as a deployment target. That insight — the browser can walk the import graph, so why am I doing it for it? — is the kind of clarity that retroactively makes every alternative look like it was solving the wrong problem. Second, the plugin API decision. By extending Rollup’s contract rather than inventing a new one, Vite inherited a decade of ecosystem instead of starting from zero. The cost (the API has Rollup-shaped quirks) is a fraction of the benefit (frameworks adopted Vite because integration was tractable). Third, the team’s discipline about lean core and treating the ecosystem as part of the product — vite-ecosystem-ci is not a marketing artifact, it’s a deeply unsexy engineering practice that means upgrades don’t break the world.
What it gets wrong, or what it costs you: the dev/prod asymmetry is permanent and yours to manage. The “works in dev, broken in prod” bug is the rite of passage for every Vite team, and even Vite 8’s unified Rolldown pipeline doesn’t fully close the gap — the output shapes are different by design. Run vite preview before every deploy, no exceptions. SSR support is a low-level primitive that the docs themselves describe as “for library and framework authors” — read that, believe it, and don’t try to build production SSR on raw Vite unless you’re literally building a framework. And the configuration surface, while shallow on day one, has accumulated enough knobs that genuinely-useful options stay obscure. Half the “Vite is being weird” stories in any chat channel end with someone discovering a config flag they’d never heard of.
Who should reach for Vite, and who shouldn’t. Reach for it: any new web project, any single-page app, any browser-targeting library, any team that values dev iteration speed, any monorepo where you’ve felt Webpack groan. Reach for the meta-framework that uses Vite (SvelteKit, Nuxt, Astro, Remix, SolidStart) rather than raw Vite if you’re building a user-facing app with SSR/SSG needs — the framework absorbs the parts of Vite that aren’t fun to write yourself. Don’t reach for raw Vite when: you need Module Federation (Webpack’s micro-frontend story is more mature), you have a large existing Webpack config encoding business-critical behaviors (calculate the migration cost honestly), you’re on Next.js (use Turbopack, you don’t have a choice), or you need to support browsers older than 2.5 years without @vitejs/plugin-legacy (Vite’s defaults assume modern targets).
What the student should now believe. Believe that fast dev iteration changes how you write code in ways you don’t notice until you’ve had it for a while — every minute saved on file-save compounds into different debugging habits, different willingness to experiment. Believe that “the tooling layer matters” — Vite is evidence that an entire ecosystem can shift on the strength of one good architectural decision plus good follow-through. Don’t believe the “10-30x faster builds” marketing for any individual project — that’s a peak number on huge codebases; on a typical app you’ll see 2-5x, which is still great. Don’t believe that you need a “Vite expert” — the surface area for app developers is small, and the 80% that matters is in this document. When you hear someone say “Vite is just a faster Webpack,” know that they are confused; the speed is the symptom, the architectural inversion is the cause, and a tool built the same way as Webpack-but-in-Rust would not have produced the developer experience Vite has. Rspack is the proof of that — fast, faithful to Webpack, and notably not the thing the ecosystem rallied around.
The hard-won line: the build tool you choose doesn’t make your app fast for users — bundle splitting and caching strategy do that, and any modern bundler can do those. What the build tool decides is how often you’ll want to open your editor. Vite, more than any other tool I’ve worked with, makes that “as often as possible.” That is its real product, and that is why it won.
The ideas are mine. The writing is AI assisted