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.
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.shRepresentative 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/.viteIf 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.
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.
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.
Related
- Optimizing Webpack & Vite for CI Environments β the parent guide on tuning either tool for CI.
- Optimizing Next.js Static Generation in CI Pipelines β the framework-level equivalent of this problem.
- Using Restore Keys for Safe Cache Fallbacks β persisting either toolβs cache between runs.
- Build Optimization & Caching Strategies β the section overview.
β Back to Optimizing Webpack and Vite for CI Environments