So you've started a new frontend project. You run the dev command, walk to the kitchen, pour a coffee, come back - and the bundler is still warming up. You change one line in a button component. Hot reload spins. The page reloads from scratch. Your form state is gone. You sigh and re-type the test data for the ninth time today.

That was the daily reality with the first generation of frontend tooling, and we'd convinced ourselves it was fine. Webpack 4 took ten seconds to cold-start a small app. Webpack 5 took thirty seconds on a real one. HMR worked, sort of, until you nested it inside a context provider and the whole tree blew away anyway. We optimised. We code-split. We wrote loaders. We learned the difference between module.rules and module.rules[].use[].options. We accepted it.

Then Vite showed up and quietly took the entire problem and threw it out.

Vite isn't faster because it tries harder. It's faster because it stopped doing the slow thing. The original sin of webpack-era tooling was that the dev server treated your codebase the same way a production build did - bundle everything, transform everything, ship a single optimised graph. That made sense in 2015 when browsers didn't speak ES modules. By 2020 every browser the project cared about did. Vite was the first popular tool to ask: if the browser can do the work, why are we doing it on the server?

The Insight That Made Everything Else Possible

The browser speaks <script type="module">. It can fetch a file, see import { foo } from './foo.js', and go fetch ./foo.js on its own. It can do this for hundreds of files in parallel and cache them aggressively.

Vite's dev server does almost nothing on startup. It boots an HTTP server, serves your index.html, and waits. When the browser asks for /src/main.ts, Vite reads that file, transforms it on the fly (TS to JS, JSX to JS, SCSS to CSS), and ships it back as a native ES module. When the browser then asks for the modules main.ts imported, Vite does the same for those. Lazy. On demand. One file at a time.

So the dev server doesn't have a "ready" state in the bundler sense. It's ready the moment Node finishes loading. That's why vite cold-starts in a couple hundred milliseconds on a project where webpack-dev-server would chug for thirty seconds - there's nothing to do until the browser asks.

The catch is that node_modules can't be served this way directly. A typical React dep tree has thousands of tiny ES modules with import paths that aren't valid in browsers (import React from 'react' - there's no ./react.js to fetch). And shipping ten thousand HTTP requests on every page load would be miserable.

So Vite pre-bundles dependencies once, on first run, using esbuild - the Go-based bundler that's roughly an order of magnitude faster than equivalent JS-based tools. Your source code stays unbundled and lazy. Your node_modules get squashed into a handful of files that look like a single ES module to the browser. The split is the whole trick.

Architecture diagram showing how Vite&#39;s dev server transforms files on request and pre-bundles dependencies once with esbuild.

HMR That Actually Survives Edits

Hot module replacement existed before Vite. Webpack had it. Parcel had it. Both were fragile. The fundamental problem was bundler-shaped: when a module changed, the bundler had to figure out what other modules depended on it, re-bundle the affected chunk, and ship a patch to the browser. The bigger your graph, the more there was to walk.

Vite's HMR is fast for the same reason its startup is fast - it operates on the actual import graph the browser is using, which it already knows because it served every file. When you save a component, Vite figures out the precise set of modules that need to be invalidated, tells the browser to re-import just those, and lets the rest of the app stay running. Form state, scroll position, modal-open flags - they stay.

The framework integrations make this real. @vitejs/plugin-react wires in React Fast Refresh; @vitejs/plugin-vue does the equivalent for Vue's SFC system. If you change a React component's JSX, the component re-renders with the same hook state. If you change a Vue SFC's <template>, the component re-renders with the same reactive state. If you change a non-component module - a util, a store - Vite walks up the import chain looking for the closest "HMR boundary" the framework declared, and replaces from there.

You can opt in manually too, for plain modules outside a framework:

TypeScript src/store/cart.ts
export const cart = createCart();

if (import.meta.hot) {
  import.meta.hot.accept((newModule) => {
    if (newModule) {
      Object.assign(cart, newModule.cart);
    }
  });
}

import.meta.hot is the Vite-defined API the dev server injects. In production it's undefined and tree-shaken away. The accept call says "I know how to replace myself" - without it, Vite falls back to a full page reload, the same way webpack would.

The practical effect: you stop saving the file, watching the browser, and reaching for the test data. You save, glance, keep typing. The five-second context switch is gone. Over a day that's not a small thing.

Pre-Bundling: The Step You Never Have To Think About

The first time you run vite on a project, you'll see a Pre-bundling dependencies message and a short pause. That's esbuild scanning your imports, walking node_modules, and producing the squashed dep bundles. It runs once, the result lives in node_modules/.vite/, and Vite re-runs it only when your package.json deps change or you bust the cache manually.

You almost never touch this. The only time you reach for optimizeDeps is when something weird happens - a dep ships CommonJS in a way Vite's heuristic doesn't catch, or you have a sibling workspace package you want Vite to pre-bundle anyway:

TypeScript vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  optimizeDeps: {
    include: ['my-monorepo-ui-lib'],
    exclude: ['some-esm-only-package'],
  },
});

include forces a dep into the pre-bundle. exclude keeps a dep out (you'd do this when a dep is already pure ESM and Vite's pre-bundle would only add overhead). The defaults are right 95% of the time.

The interesting design choice here is that dev and prod use different bundlers on purpose. esbuild is blistering fast but its plugin API is less mature than Rollup's, and a few production-grade features (advanced code-splitting, certain CSS handling, library-mode builds) are better-served by Rollup. So Vite uses esbuild where speed matters most - transforming files on the fly, pre-bundling deps - and Rollup where ecosystem maturity matters most - production builds.

You don't have to think about this split. You write your config once, and Vite picks the right tool for each phase.

The Production Build Is Rollup In A Nice Coat

vite build runs your code through Rollup with a curated set of defaults - code-splitting per dynamic import, automatic CSS extraction, modulepreload tag injection, hashing, legacy bundle generation if you opt in via @vitejs/plugin-legacy. The output is a dist/ directory you can drop on any static host.

What changed compared to writing Rollup by hand is that you don't have to. Vite ships sensible production defaults - and because dev and build share the same plugin pipeline, plugins you used in dev (PostCSS, MDX, image imports, env vars) keep working in build. The "build runs fine but dev is broken" problem that haunted webpack configs becomes much harder to create.

You configure the build the way you'd configure Rollup, through build.rollupOptions, but you rarely need to:

TypeScript vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    target: 'es2022',
    sourcemap: true,
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ['react', 'react-dom'],
        },
      },
    },
  },
});

manualChunks is the one knob you reach for on real apps - pinning libraries you know will be cached across deploys into their own chunk so a code change in your app doesn't bust the React bundle. Everything else is defaults you can usually leave alone.

The Plugin System Is Why The Ecosystem Came Along

Vite's plugin API is intentionally Rollup-compatible. A plugin is an object with hooks - transform, load, resolveId, the same names Rollup uses - plus a few Vite-specific extensions for the dev server (handleHotUpdate, configureServer, transformIndexHtml). The implication is huge: any Rollup plugin that doesn't depend on bundler-specific behaviour also works in Vite. That gave Vite a working plugin ecosystem on day one.

A trivial plugin to show the shape:

TypeScript plugins/vite-plugin-version-banner.ts
import type { Plugin } from 'vite';
import { readFileSync } from 'node:fs';

export function versionBanner(): Plugin {
  const version = JSON.parse(readFileSync('./package.json', 'utf-8')).version;

  return {
    name: 'vite-plugin-version-banner',
    transformIndexHtml(html) {
      return html.replace(
        '</head>',
        `<meta name="app-version" content="${version}"></head>`
      );
    },
    handleHotUpdate({ file, server }) {
      if (file.endsWith('package.json')) {
        server.ws.send({ type: 'full-reload' });
        return [];
      }
    },
  };
}

transformIndexHtml injects a version meta tag at build and dev time. handleHotUpdate watches for changes to package.json and triggers a full reload (since the injected version would otherwise stay stale). One file, both modes, no separate dev and build configurations.

The maturity of this surface is what let frameworks build on Vite instead of next to it. SvelteKit, Nuxt 3, Astro, SolidStart, Remix (after their 2024 move), Qwik City - they all use Vite as their dev server and production bundler under the hood. The framework provides routing, server rendering, data loading. Vite provides the build pipeline. That separation of concerns is why a Svelte app and an Astro app feel similar to start up and develop, even though the runtime models are very different.

Poster diagram of the frameworks built on Vite: SvelteKit, Nuxt 3, Astro, SolidStart, Remix, Qwik City, and Vitest.

Vitest Is The Quiet Win

Once Vite became the dev pipeline, the test runner question got awkward. Jest was the default in the React world for years, but Jest runs your code through its own module system, its own transform pipeline, its own resolver. The result was tests that didn't quite match your app's runtime - Babel transforms diverging from your dev build, ESM/CJS edge cases, mock hoisting weirdness, slow startup.

Vitest is the same idea as Vite, applied to tests: reuse your existing Vite config, transform pipeline, and plugin chain. A test file goes through the same loaders your app does. If @vitejs/plugin-react is in your config, your tests get React Fast Refresh-compatible transforms. If you have path aliases in vite.config.ts, your tests resolve them the same way.

The API is Jest-compatible enough that migrating is mostly find-and-replace:

TypeScript src/cart.test.ts
import { describe, it, expect, vi } from 'vitest';
import { addItem, totalPrice } from './cart';

describe('cart', () => {
  it('totals two items correctly', () => {
    const cart = addItem(addItem({ items: [] }, 'apple', 2), 'pear', 3);
    expect(totalPrice(cart)).toBe(5);
  });

  it('calls the audit log on every add', () => {
    const log = vi.fn();
    addItem({ items: [] }, 'apple', 2, { log });
    expect(log).toHaveBeenCalledOnce();
  });
});

vi.fn() is the Vitest equivalent of jest.fn(). describe/it/expect are explicit imports rather than globals (you can opt into globals with test.globals: true). Watch mode reruns only the affected tests, fast, because Vite's import graph already knows the dependency chain.

You don't have to use Vitest with Vite - Jest, Playwright, and others still work fine. But the alignment is the path of least resistance, and on a fresh project it's hard to argue for the more complex setup.

What Actually Changed About How You Work

The technical wins - sub-second startup, instant HMR, dev/prod parity - are the visible part. The deeper change is in the friction surface of a frontend project.

The bundler used to be a thing you maintained. You learned its config language. You debugged its errors. You upgraded it carefully. New team members spent half their first day getting the dev server running. Plugin compatibility was its own research project. When a build broke, you sometimes lost a day to bundler internals.

With Vite, the bundler became a tool you forget you're using. New project? npm create vite@latest. New team member? Clone, pnpm install, pnpm dev. Need a feature? There's a one-line plugin import. Build fails? The error is in your code, almost always, because the framework integrations are thin and the bundler defaults are sane.

That shift moves your attention from tooling back to product. The hours you used to spend on webpack go into the work that actually ships.

The Honest Trade-offs

Vite is not magic and it's worth saying where the edges are.

The dev server uses native ESM, which means it doesn't see the same module graph the production build produces. Most of the time this is fine, but a few classes of bug can hide in dev and only surface in build - circular imports that work via ESM live bindings but break after Rollup tree-shaking, dead code that gets eliminated in prod but not in dev, deps with subtle CJS/ESM interop issues. The fix isn't to abandon Vite; it's to remember vite build && vite preview is part of your testing loop, not an afterthought.

esbuild does TS-to-JS transforms but does not type-check. Vite is faster than tsc because it's not running tsc. If you want type errors to fail your build, you wire vue-tsc or tsc --noEmit into your CI yourself. Most teams do this and it works fine, but it's a thing you have to do - Vite isn't going to surprise you with a type error during dev.

Large monorepos with many cross-package imports can hit a slow case during pre-bundling on first start. The fix is usually optimizeDeps.include for the workspace packages, but it's a knob you'll touch eventually if your repo gets big enough.

And not every framework lives on Vite. Next.js uses its own bundler (Webpack in production, Turbopack increasingly in dev). Rspack - a Rust port of Webpack - is making a real run at the "fast bundler that's drop-in compatible with the webpack config you already have" niche. Vite isn't the only answer in 2026, and on some teams the cost of migrating off an existing webpack config is genuinely higher than the benefit.

So What Changed

The thing Vite changed isn't really "the dev server got fast." It's that frontend tooling stopped being a tax on your day. Cold starts that used to define how you scoped your project - keep the app small or you'll suffer - don't define anything anymore. You're free to build the size of app the product needs, not the size your bundler will tolerate.

That's the part that's hard to undo. Once you've felt sub-second startup and HMR that doesn't blow away your state, going back to webpack feels like switching from an SSD to a spinning disk. You can - and on some legacy projects you have to - but you notice every second of it.

The bundler became invisible, and being invisible is the highest compliment you can pay a developer tool.