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.

CI bundler cache flow: prime, restore, build, push A prime job checks the cache store; on a miss it runs a full build and pushes. Parallel feature runners restore the cache, run incremental builds, and push updated artifacts. A rotate job evicts entries older than the TTL. Cache Store node_modules/.vite node_modules/.cache /webpack Prime Job cache-miss → full build Runner A restore → incremental build Runner B restore → incremental build Rotate Job evict entries > 14 d TTL Key Generator SHA-256(lockfile + bundler-ver + branch) push (write) restore (read)

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=error

Verify 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 clean

Step 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 first

Step 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 run

Step 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: production

GitLab 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: production

Jenkins (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.log

A 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.


← Back to Build Optimization & Caching Strategies