Webpack vs Vite Build Performance in CI

The build takes four minutes and someone has proposed migrating to Vite. The dev-server difference everyone quotes is irrelevant here β€” CI runs a production build, and Vite’s production path is Rollup, not the unbundled dev pipeline. This is how to measure the difference that actually applies to your pipeline before committing to a migration.

When to use this pattern

  • Production build time is a visible share of your pipeline and a tool change is on the table.
  • You need a defensible number rather than a benchmark from someone else’s repository.
  • You suspect the build is memory-bound rather than CPU-bound, which changes the answer entirely.

Prerequisites

What actually differs on a CI runner

Three properties drive the outcome, and only one of them is the thing people usually compare.

What Differs Between the Two Production Builds Three rows compare the tools. Transform throughput favours Vite because esbuild handles TypeScript stripping. Cold-start cost favours Vite slightly. Peak memory favours Vite substantially, which matters most because it is what causes out-of-memory failures that only appear on CI runners. webpack 5 Vite 5 (Rollup + esbuild) Transform TS β†’ JS babel-loader / ts-loader JS-based, per-file esbuild native, ~20Γ— faster at this step Cold start no cache full graph + loaders + plugins pre-bundle deps, then Rollup Peak memory the CI killer 2.9 GB on a 900-module app needs --max-old-space-size 1.3 GB, same app fits a 2-vCPU runner comfortably Memory is the property most likely to produce a CI-only failure: it works locally on 32 GB and dies on the runner.

Complete working example

#!/usr/bin/env bash
# scripts/bench-bundler.sh β€” cold and warm timings plus peak memory, comparably.
set -euo pipefail
RUNS=${RUNS:-3}

bench() {                       # bench <label> <cache-dir> <command…>
  local label="$1" cache="$2"; shift 2
  local cold warm

  rm -rf dist "$cache"
  cold=$( { /usr/bin/time -f '%e %M' "$@" >/dev/null; } 2>&1 | tail -1 )

  # Warm: keep the cache, drop the output β€” this is the second-run case.
  local best=999999 mem=0
  for _ in $(seq "$RUNS"); do
    rm -rf dist
    read -r t m < <( { /usr/bin/time -f '%e %M' "$@" >/dev/null; } 2>&1 | tail -1 )
    awk -v a="$t" -v b="$best" 'BEGIN{exit !(a<b)}' && best="$t"
    (( ${m%.*} > mem )) && mem="$m"
  done
  printf '%-10s cold %6.1fs (peak %5.0f MB)   warm %6.1fs (peak %5.0f MB)\n' \
         "$label" "${cold%% *}" "$(( ${cold##* } / 1024 ))" "$best" "$(( mem / 1024 ))"
}

bench webpack node_modules/.cache/webpack npx webpack --mode production
bench vite    node_modules/.vite          npx vite build
# .github/workflows/bench.yml β€” run it on the runner spec that actually matters.
name: Bundler Benchmark
on: workflow_dispatch

jobs:
  bench:
    runs-on: ubuntu-latest          # 2 vCPU / 7 GB β€” the spec the comparison must hold on
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: npm }
      - run: npm ci --ignore-scripts
      - run: sudo apt-get install -y time
      - run: bash scripts/bench-bundler.sh | tee bench.txt
      - uses: actions/upload-artifact@v4
        with: { name: bench, path: bench.txt, retention-days: 7 }

Step-by-step walkthrough

Benchmark on the runner, not the laptop. A 10-core developer machine with 32 GB of memory hides both differences that matter: webpack parallelises across cores you do not have in CI, and its memory ceiling is invisible until it is not. Numbers gathered locally routinely reverse on a 2-vCPU runner.

Separate cold from warm deliberately. Cold is what an ephemeral runner sees by default. Warm is what it sees if you persist the cache directory, which is an active choice with its own cost β€” webpack’s filesystem cache for a mid-size application runs 400–900 MB, and restoring that can take longer than the time it saves.

Capture peak RSS, not just duration. /usr/bin/time -f '%M' gives maximum resident set in kilobytes. This is the number that predicts the JavaScript heap out of memory failure that appears only in CI, and it is the reason some teams migrate regardless of build duration.

Run the warm case several times and take the best. Shared CI runners have noisy neighbours; a single sample can be off by 30%. Three runs and the minimum is a reasonable compromise between accuracy and benchmark cost.

Compare the output, not only the clock. A build that is 40% faster but ships 15% more JavaScript has moved cost from your CI bill to your users. Record total bundle bytes and chunk count alongside the timings, and treat a regression there as disqualifying.

Verification

# 1. Confirm the two branches really produce equivalent output.
du -sb dist && find dist -name '*.js' | wc -l

# 2. Full comparison on the CI runner spec.
RUNS=3 bash scripts/bench-bundler.sh

Representative output from a 900-module React application on a 2-vCPU runner:

webpack    cold  214.8s (peak  2912 MB)   warm   96.3s (peak  2740 MB)
vite       cold  128.4s (peak  1318 MB)   warm   71.2s (peak  1290 MB)
# 3. Does persisting the cache actually pay? Time the restore too.
du -sh node_modules/.cache/webpack node_modules/.vite

If the webpack cache is 800 MB and restoring it takes 45 seconds to save 118, it still pays β€” but only just, and it stops paying entirely on a runner with slower cache storage. Measure rather than assume, using the mechanics in using restore keys for safe cache fallbacks.

The four numbers in that table answer four different questions, and it is worth being explicit about which one your pipeline is actually constrained by.

Which Benchmark Number Answers Which Question Four rows pair a measurement with the decision it informs. Cold build time governs ephemeral runners with no cache restore. Warm build time governs pipelines that persist the cache. Peak memory governs runner size and out-of-memory risk. Output bytes govern the cost paid by users rather than by CI. MEASUREMENT DECIDES cold build time the default: ephemeral runners restore no build cache at all warm build time whether persisting a 400–900 MB cache directory is worth its restore cost peak resident memory runner size β€” and whether CI-only heap failures are ahead of you output bytes and chunk count whether the saving was moved onto users instead of removed

When migrating is not worth it

A 40% build-time reduction sounds compelling until it is priced against the migration. The parts that transfer cleanly are source code and most plugins; the parts that do not are the ones that consume the schedule.

Custom webpack loaders have no direct equivalent and must be rewritten as Rollup or Vite plugins. Anything relying on require.context, webpack-specific magic comments, or module federation needs a genuine redesign rather than a translation. CommonJS dependencies that webpack handled silently need explicit interop configuration. In a mature application these add up to somewhere between one and four engineer-weeks, and the risk is concentrated in exactly the places least covered by tests.

Set the threshold before you start measuring, so the decision is not made by whoever is most enthusiastic. A reasonable one: migrate if the build exceeds three minutes and the memory ceiling is causing CI failures, or if the same change also fixes a developer-experience problem the team feels daily. A 40% saving on a 45-second build is nine minutes a week across a team of ten β€” real, but not worth four weeks and a class of new bugs.

Payback Period by Current Build Duration Three scenarios sharing a two-week migration cost. A four-minute build saving ninety-six seconds per run pays back in about eight weeks. A ninety-second build pays back in about thirty weeks. A forty-five-second build never repays the migration within a year. MIGRATION COST β‰ˆ 2 ENGINEER-WEEKS IN ALL THREE CASES 4m build pays back in ~8 weeks β€” do it 90s build ~30 weeks β€” only alongside another reason 45s build beyond a year β€” the migration is the more expensive option 0 20 weeks 52 weeks Assumes 200 builds a week. A memory-ceiling failure changes the calculation entirely β€” that is a correctness problem, not a speed one.

Common pitfalls

Benchmarking a dev server against a production build. The famous Vite speed comparison is about unbundled development. CI never runs that path.

Leaving --max-old-space-size set after migrating. If it was there to stop webpack running out of heap, it is now masking a much lower real ceiling. Remove it and re-measure so the new headroom is known rather than assumed.

Comparing without matching output targets. Different target settings mean different levels of downlevelling, and a build that emits modern syntax is naturally faster and smaller. Align the browser targets before comparing anything.

Re-run the benchmark after any major dependency upgrade. Both tools change their transform pipelines between major versions, and a comparison eighteen months old describes software neither of you is running any more.

← Back to Optimizing Webpack and Vite for CI Environments