Optimizing Webpack and Vite for CI Environments
Frontend build steps regularly consume the majority of CI compute budget on large applications — not because bundlers are slow by design, but because runners execute them without the persistence strategies that make local builds fast. Without persistent caching, filesystem cache invalidation, and deterministic key generation, every push triggers a cold compilation that can take four to ten minutes for a mid-sized React or Vue application. This page delivers the configuration patterns, step-by-step implementation, and failure-mode remediation that platform teams need to bring that down to under ninety seconds for warm runs.
Prerequisites
How Bundler Caching Works in a CI Context
Understanding what each bundler actually persists helps you configure cache keys without unnecessary misses or stale hits.
Webpack 5 filesystem cache serialises the entire module graph — resolved modules, dependency metadata, chunk assignments, and emitted asset hashes — into a binary store under cacheDirectory. On subsequent runs, Webpack reads this store before resolving any module. The buildDependencies option declares which files, when changed, should bust the entire cache. Omitting your config file from buildDependencies is the most common cause of stale output after a config change.
Vite dependency pre-bundling (esbuild) and production builds (Rollup) use separate caches. The esbuild pre-bundle lives at cacheDir (top-level, not inside build) and converts CommonJS dependencies to ESM, which is expensive the first time. Rollup outputs land in build.outDir and do not benefit from Vite’s internal cache beyond what Rollup itself memoises. The critical mistake teams make is setting cacheDir inside the build key, where Vite ignores it entirely.
The diagram below shows the artifact flow for a parallel three-runner pipeline: a prime job populates the cache, feature-branch runners restore it, and a post-build job rotates the store on key changes.
Environment Parity & Dependency Management
Deterministic builds require strict alignment between local workstations and ephemeral CI runners before touching any bundler config. Pin Node.js major and minor versions using .nvmrc or the engines field in package.json, then enforce the same version on runners via actions/setup-node or the equivalent platform step. Install dependencies exclusively with npm ci, pnpm install --frozen-lockfile, or yarn install --immutable — never npm install — to guarantee lockfile-bound resolution.
Set CI=true and NODE_ENV=production globally to suppress interactive prompts and enable production-mode tree-shaking. When containerising build steps, aligning base images with Docker layer caching for full-stack applications ensures that the node_modules installation layer is reused rather than rebuilt from scratch on every runner. Configure .npmrc with engine-strict=true and loglevel=error to catch version drift immediately and eliminate verbose output during dependency resolution.
Step-by-Step Implementation
Step 1 — Pin versions and enforce lockfile integrity
Add version constraints to package.json and .npmrc:
{
"engines": {
"node": ">=20.0.0 <21.0.0",
"npm": ">=10.0.0"
}
}# .npmrc
engine-strict=true
loglevel=errorVerify the Node version is honoured on the runner:
node --version # must match .nvmrc or engines range
npm ci --dry-run # should exit 0 with no network activity if lockfile is cleanStep 2 — Configure Webpack 5 filesystem cache
Enable persistent caching and scope buildDependencies to your config file so that any change to webpack.config.js automatically busts the cache. Direct the cacheDirectory to a path your CI platform can archive and restore:
// webpack.config.js — CI-optimised persistent filesystem cache
const path = require('path')
module.exports = {
cache: {
type: 'filesystem',
// Direct writes to a stable, archivable path
cacheDirectory: path.resolve(__dirname, 'node_modules/.cache/webpack'),
// Bust the cache if the config file itself changes
buildDependencies: { config: [__filename] },
// Namespace by Node version to avoid cross-version corruption
name: `webpack-${process.env.NODE_VERSION || 'default'}`
},
// Disable source maps in CI — use hidden-source-map if you need them for error tracking
devtool: process.env.CI ? false : 'eval-source-map',
// Strip HMR and dev-only plugins conditionally
...(process.env.CI && {
optimization: {
splitChunks: { chunks: 'all' },
runtimeChunk: 'single'
}
})
}Verify the cache is being written:
npx webpack --json 2>/dev/null | jq '.time' # first run (ms)
npx webpack --json 2>/dev/null | jq '.time' # second run — should be <30% of firstStep 3 — Configure Vite for CI
Set cacheDir as a top-level key in vite.config.ts — not inside build. Set mode and disable source maps for CI runs. Use loadEnv to enforce production variables without importing dotenv directly:
// vite.config.ts — cacheDir is top-level, not nested under build
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
return {
// Pre-bundle cache lands here — must be archivable and branch-scoped
cacheDir: 'node_modules/.vite',
plugins: [react()],
build: {
target: 'es2020',
// Source maps bloat CI cache and slow push; disable unless tracking errors
sourcemap: false,
rollupOptions: {
output: {
// Deterministic 8-char content hash for long-lived CDN caching
chunkFileNames: '[hash:8].js',
assetFileNames: '[hash:8].[ext]'
}
}
},
// Force fresh pre-bundle when CI env flag is set during dependency upgrades
optimizeDeps: {
force: env.FORCE_PREBUNDLE === 'true'
}
}
})Verify the pre-bundle cache is populated:
ls -lh node_modules/.vite/deps/ # should list .js files after first build
vite build --mode production 2>&1 | grep 'Pre-bundling' # should not appear on warm runStep 4 — Generate deterministic cache keys
Cache keys must encode every input that can change output. A key that’s too broad causes stale hits; one that’s too granular causes unnecessary misses.
# Compose a cache key from lockfile hash + bundler version + OS + branch
LOCKFILE_HASH=$(sha256sum pnpm-lock.yaml | cut -d' ' -f1 | head -c 16)
BUNDLER_VER=$(node -p "require('./node_modules/webpack/package.json').version" 2>/dev/null || \
node -p "require('./node_modules/vite/package.json').version")
CACHE_KEY="${RUNNER_OS}-bundler-${BUNDLER_VER}-${LOCKFILE_HASH}"
echo "Cache key: ${CACHE_KEY}"Step 5 — Wire cache restore and push in your CI platform
GitHub Actions:
- name: Restore bundler cache
uses: actions/cache@v4
with:
path: |
node_modules/.vite
node_modules/.cache/webpack
# Primary key: exact match required to write on hit
key: ${{ runner.os }}-bundler-${{ hashFiles('**/pnpm-lock.yaml') }}
# Fallback: restore partial cache from any prior run on this OS
restore-keys: |
${{ runner.os }}-bundler-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
env:
CI: "true"
NODE_ENV: productionGitLab CI:
build:
cache:
key: "$CI_RUNNER_EXECUTABLE_ARCH-$CI_COMMIT_REF_SLUG"
policy: pull-push # restore first, push after — prevents race conditions
paths:
- node_modules/.vite
- node_modules/.cache
untracked: false # omit untracked files to reduce storage footprint
script:
- pnpm install --frozen-lockfile
- pnpm build
variables:
CI: "true"
NODE_ENV: productionJenkins (shared pipeline library):
stage('Build') {
steps {
// Restore cross-stage cache via stash; replace with S3 plugin for distributed runners
unstash name: 'bundler-cache'
sh 'pnpm install --frozen-lockfile'
sh 'CI=true NODE_ENV=production pnpm build'
// Persist updated cache for downstream stages
stash name: 'bundler-cache', includes: 'node_modules/.vite/**,node_modules/.cache/**'
// Archive build outputs with fingerprinting for auditability
archiveArtifacts artifacts: 'dist/**', fingerprint: true, allowEmptyArchive: false
}
}Step 6 — Verify with build telemetry
After implementing the above, run two consecutive builds and capture the delta:
# First build (cold or after cache miss)
time pnpm build 2>&1 | tee /tmp/build-cold.log
# Restore cache and rebuild
time pnpm build 2>&1 | tee /tmp/build-warm.log
# Compare
grep -E 'built in|Done in' /tmp/build-cold.log /tmp/build-warm.logA healthy warm run is 70–85% faster than a cold run for a mid-sized application.
Configuration Reference
| Option | Bundler | Type | Default | Effect |
|---|---|---|---|---|
cache.type |
Webpack | 'memory'|'filesystem' |
'memory' |
'filesystem' persists across runs; 'memory' is per-process only |
cache.cacheDirectory |
Webpack | string |
node_modules/.cache/webpack |
Target path for serialised module graph — must be archivable |
cache.buildDependencies.config |
Webpack | string[] |
[] |
Files whose change busts the entire cache; always include __filename |
cache.name |
Webpack | string |
'default' |
Namespace to isolate caches by environment or Node version |
cacheDir |
Vite | string |
node_modules/.vite |
Top-level; stores esbuild pre-bundle; not a build option |
optimizeDeps.force |
Vite | boolean |
false |
Forces fresh pre-bundling regardless of cache state |
build.sourcemap |
Vite | boolean|'hidden' |
false |
false in CI; 'hidden' if error-tracking service requires maps |
build.target |
Vite | string |
'modules' |
Set to 'es2020' to eliminate unnecessary transpilation overhead |
devtool |
Webpack | string|false |
'eval' |
false in CI; 'hidden-source-map' if uploading to Sentry |
Integration with Sibling Topics
This topic sits within the broader build optimization and caching strategies domain and connects to several adjacent concerns.
Remote caching at the monorepo level: Webpack’s filesystem cache avoids redundant compilation within a single package, but for monorepos you need a layer above the bundler. Integrating Turborepo remote caching allows task graphs spanning multiple packages to skip entire build steps — Webpack never runs if Turborepo’s hash matches. The two layers are complementary, not redundant.
Container image optimisation: When your CI pipeline containerises the build environment, aligning the Webpack or Vite cacheDirectory with a Docker COPY --mount=type=cache instruction in a BuildKit-enabled multi-stage Dockerfile avoids rebuilding the module graph on every image push. Full patterns are in Docker layer caching for full-stack applications.
Affected-scope execution: On large monorepos, running vite build or webpack for every package on every commit wastes the cache wins from this page. Combine bundler caching with incremental builds and affected detection so that only the packages touched by a PR are compiled at all.
Environment matrix alignment: Webpack cache corruption is particularly common when runners with different OS or Node versions share a single cache key. Structured key composition using environment matrices in GitHub Actions lets you include the OS and Node version in the key automatically.
Framework-specific SSR/SSG patterns: Next.js has its own incremental compilation layer on top of Webpack that requires separate cache key treatment. The dedicated guide on optimizing Next.js static generation in CI pipelines covers ISR invalidation and .next/cache management for server-rendered builds.
GitLab-specific remote caching: Teams on GitLab can extend artifact sharing beyond platform-native cache with Redis-backed remote caching for GitLab CI, which co-locates the cache server close to runners to reduce restore latency.
Performance Benchmarks
The following numbers are representative of a 150-module React application (Vite) and a 220-module enterprise SPA (Webpack 5) on GitHub-hosted ubuntu-latest (2-core, 7 GB RAM) runners:
| Scenario | Cold build | Warm build (filesystem cache) | Saving |
|---|---|---|---|
| Vite 5 — 150 modules | 98 s | 14 s | 86% |
| Webpack 5 — 220 modules | 3 m 42 s | 38 s | 83% |
| Webpack 5 + remote cache (Turborepo) | 3 m 42 s | 9 s | 96% |
| Vite + pnpm lockfile unchanged (pre-bundle hit) | 98 s | 8 s | 92% |
Cache hit rate depends on key granularity. Teams using lockfile + bundler version as the primary key sustain 88–94% hit rates in practice. Keys that also encode a branch name see lower hit rates (68–76%) because new branches never restore from an existing key until they produce one.
Runner minutes saved per 100 pushes (assuming a five-developer team, one push per hour):
- Webpack without caching: ~370 runner-minutes/day
- Webpack with filesystem cache: ~62 runner-minutes/day (83% reduction)
- Adding Turborepo remote cache: ~15 runner-minutes/day (96% reduction)
Troubleshooting
Error: Module build failed: Error: ENOENT: no such file or directory, scandir '…/node_modules/.cache/webpack'
Root cause: The CI runner restored the cache archive but the cacheDirectory path was not preserved because the archive excluded node_modules/.cache. The Webpack process then tries to read a path that does not exist.
Fix: Ensure node_modules/.cache/webpack is explicitly listed in the paths array of your cache step, and that the restore step runs before the npm ci or pnpm install step (not after, which would overwrite node_modules).
Error: [vite] Pre-bundle failed: EACCES: permission denied, mkdir '…/node_modules/.vite'
Root cause: The restored cache archive was written by a different UID than the current runner process, or the cacheDir path sits inside a Docker volume with restricted permissions.
Fix: Add a chmod -R u+rw node_modules/.vite step after cache restore, or set cacheDir to a path outside node_modules (e.g., .vite-cache) that is explicitly owned by the runner user.
Stale output: production bundle still references development API endpoint after cache hit
Root cause: The define / DefinePlugin values are not included in the cache key, so an old pre-compiled module containing the dev URL is restored. This is distinct from an optimizeDeps miss — it can occur in the main compilation cache.
Fix: Add a hash of .env.production to your Webpack cache.name or Vite cacheDir path. In Webpack: name: \webpack-${crypto.createHash(‘md5’).update(fs.readFileSync(‘.env.production’)).digest(‘hex’).slice(0,8)}`. In Vite: set optimizeDeps.force: truewhenNODE_ENV=production` is toggled for the first time in a pipeline run.
Cache stampede: two parallel runners both miss and then race to push the same key
Root cause: The pull-push policy allows multiple runners to write the same key simultaneously, resulting in inconsistent cache contents and occasional corruption of the binary store.
Fix: Use a pull-only policy on feature branch runners and a dedicated prime job on the default branch that holds the push write. GitHub Actions does this naturally via restore-keys semantics (only exact-key hits allow subsequent write). In GitLab CI, set policy: pull on feature branches and policy: push on the main job.
Frequently Asked Questions
Should I cache node_modules or just the build cache directory?
Cache only the bundler-specific directories (node_modules/.vite, node_modules/.cache/webpack) alongside lockfile-bound installation via npm ci. Full node_modules caching across runners risks architecture-specific native binaries resolving incorrectly — particularly for packages with postinstall scripts or platform-specific binaries like esbuild and sharp.
How do I handle cache invalidation when the bundler version updates?
Include the bundler’s version string in your cache key alongside the lockfile hash. When either changes, the key rotates automatically and the runner falls back to a clean build. A CI-friendly one-liner: node -p "require('./node_modules/vite/package.json').version" outputs the version without installing a separate tool.
Is it safe to share Vite or Webpack caches across branches?
No. Branch-scoped keys prevent cross-contamination of feature flags and environment-specific module graphs. Use pull-only fallback keys derived from the default branch (restore-keys: ${{ runner.os }}-bundler-) to seed cold feature-branch runners safely without risking stale artifact pollution.
What runner disk size is recommended for persistent caching?
Allocate a minimum of 20 GB for ephemeral runners. Filesystem caches and Vite pre-bundle directories can exceed 2 GB for large applications. Implement automated pruning (Webpack’s maxAge option in cache) or TTL eviction on your cache store to prevent disk exhaustion across long-lived runner fleets.
Related
- Build Optimization & Caching Strategies — the parent topic covering cache topology, key design, and cost tracking across the full delivery pipeline.
- Implementing Remote Build Caching with Turborepo — extend bundler caching to the task graph level and share compiled outputs across all packages in a monorepo.
- Docker Layer Caching for Full-Stack Applications — combine Webpack/Vite cache directories with BuildKit layer mounts to eliminate module graph rebuilds inside containers.
- Incremental Builds and Affected Detection in Monorepos — scope which packages even invoke Webpack or Vite on a given PR, multiplying the cache savings from this page.
- Optimizing Next.js Static Generation in CI Pipelines — Next.js-specific Webpack cache and ISR invalidation patterns for SSG/SSR pipelines.
← Back to Build Optimization & Caching Strategies