Vite in development and webpack in production is a real answer

The dev server took thirty-eight seconds to start and two to four seconds to reflect a one-line change. Nobody had measured it, because it had grown a second at a time over three years, and the accumulated cost was a team that ran the build once in the morning and worked around it.

The symptom

$ time npx webpack serve
<i> [webpack-dev-server] Project is running at http://localhost:8080/
compiled successfully in 38402 ms

real	0m41.902s

# and a one-character change in a component:
#   webpack 5.24 compiled successfully in 2841 ms

$ npx vite
  vite v2.0.5 dev server running at http://localhost:3000/
  ready in 412ms

Four hundred milliseconds against thirty-eight seconds is not an optimisation, it is a different mechanism. Understanding which mechanism, and what it costs, is what decides whether the number is worth anything.

Why it happens

A bundler builds the whole module graph before it can serve anything, because the output is one file and every input contributes to it. That work is proportional to the size of the project and is paid on every cold start.

A native ESM server serves modules individually and transforms each one on request, so the browser walks the graph rather than the tool. Startup is constant, and the work is proportional to what is actually on screen.

The fix

The two-engine arrangement

development    esbuild pre-bundles dependencies once;
               source modules are served untransformed
               except for the framework transform

production     Rollup builds a real bundle, because 400
               module requests over HTTP/2 is still slower
               than one file with a cache header

so the module graph is resolved twice, by two tools, with
different semantics — and that is where the bugs live.

This is presented as an implementation detail and it is a design constraint. Anything that behaves differently between esbuild and Rollup produces a bug that appears in exactly one of development and production, which is the most expensive kind to find.

export default defineConfig({
  plugins: [vue()],
  build: {
    // run the production build in CI on every push, not
    // only at release — this is the whole mitigation
    sourcemap: true,
    rollupOptions: {
      output: {
        manualChunks: { vendor: ['vue', 'vue-router', 'pinia'] },
      },
    },
  },
  server: {
    proxy: { '/api': { target: 'https://turkeryildirim.com', changeOrigin: true } },
  },
})

What did not port

webpack loader              vite equivalent
---------------------------------------------------------
babel-loader                built in (esbuild), for TS/JSX
sass-loader                 built in, `npm i sass`
file-loader / url-loader    built in, ?url and ?raw suffixes
vue-loader                  @vitejs/plugin-vue
thread-loader               unnecessary

svg-sprite-loader           nothing. a plugin had to be
                            written — 40 lines.
imports-loader              nothing. the two libraries
                            needing it were replaced.

Two loaders out of eleven had no equivalent, and one of those was solving a problem that had a better answer anyway — imports-loader was patching a library that expected a global, and replacing the library was less code than porting the workaround.

The SVG sprite plugin is forty lines and is now something we maintain. That is the honest cost of leaving a mature ecosystem for a young one, and it was worth paying here because the plugin is small and the interface it uses is stable.

The bugs the arrangement makes possible

// worked in dev, undefined in the build:
// esbuild hoists differently for a circular import
import { formatMoney } from './format'
import { Currency } from './currency'   // imports ./format

// and the CommonJS case, which is the common one:
import pkg from 'some-cjs-package'
// dev:   pre-bundled by esbuild to ESM, works
// build: Rollup's interop chooses differently, and
//        pkg.default is the thing you wanted

Both of these are real and both were found by running the production build in CI rather than by anybody reasoning about it. Circular imports are a latent bug that a bundler happens to tolerate, so the fix is removing the cycle rather than configuring around it.

The CommonJS interop case is more annoying because the package is not wrong and neither tool is wrong — they make different defensible choices about a module format that predates ES modules. Adding the package to optimizeDeps.include and testing the build is the whole diagnostic loop.

Verifying it worked

$ npx vite
  ready in 398ms

# a one-line change in a component: ~30ms to update,
# and the component state survives it

$ npx vite build
vite v2.0.5 building for production...
✓ 412 modules transformed.
dist/assets/vendor.8f21ac3d.js   142.11 KiB / gzip: 51.02 KiB
dist/assets/index.d4e2b19f.js     88.40 KiB / gzip: 28.14 KiB
✓ built in 8.41s

$ npx playwright test
  38 passed

The browser test suite against the production build is the assertion that matters, because it is the only thing exercising the Rollup output. Running it against the dev server would have passed while shipping a broken bundle, which is precisely the failure mode this arrangement introduces.

State surviving a hot update is the change people notice most and it is hard to put a number on. Editing a form component without losing the form contents removes a category of small friction that had been invisible because everybody had adapted to it.

What this costs

Two module graphs, resolved by two tools with different opinions, and a class of bug that exists only in one environment. The mitigation is running the production build on every push, which costs about nine seconds of pipeline time and is not optional — a team that builds only at release will find these bugs at release.

The younger ecosystem is the other cost and it is temporary in a way that is easy to underestimate at the time. In February 2021 Vite is a year old, several common plugins do not exist, and the answer to an unusual requirement is frequently to write forty lines yourself. That is fine for a team that can, and it is a genuine reason for a team that cannot to wait.