Caching Vite optimizeDeps Across CI Runs

Every fresh CI runner starts with an empty node_modules/.vite, so it pays the full esbuild pre-bundle cost on the first vite dev or vite build — cost your laptop only pays once. This guide persists that pre-bundle across runs with actions/cache, keyed correctly so it warms rather than silently missing. It is the CI-restoration piece of Vite build performance and caching, building on the pre-bundling behaviour explained in speeding up Vite cold starts.

The problem exists because a CI runner is deliberately ephemeral. Each job boots a clean image, checks out the repository, installs dependencies, and is then destroyed — nothing on disk survives to the next run unless you explicitly persist it. Vite’s dependency optimizer writes its output to node_modules/.vite, which lives inside the very directory that gets recreated from scratch on every job, so the optimizer has no prior work to reuse. On your workstation the pre-bundle is written once and reused for weeks; in CI it is written once and immediately thrown away, over and over. The cost is not hypothetical. On a mid-sized application with a few hundred transitive dependencies, cold pre-bundling routinely adds several seconds to tens of seconds per job, and that tax is paid by every push, every pull-request update, and every matrix leg in parallel.

Where this sits in the pipeline matters for getting the fix right. Pre-bundling runs lazily: Vite scans your entry HTML and source for bare imports, hands the discovered dependencies to esbuild, and writes flattened ESM bundles plus a _metadata.json fingerprint into node_modules/.vite/deps. That happens the first time the dev server or a build touches those dependencies — after npm ci, not during it. So the cache you want is not the package manager’s download cache and not node_modules itself; it is the small, derived .vite directory that sits downstream of install and upstream of the actual build. Restore it at the wrong moment and it gets clobbered; key it on the wrong input and it either never hits or hits when it should not. The rest of this page is about getting both of those right.

A fresh runner restores the pre-bundle instead of rebuilding it A CI runner with a cache miss pre-bundles dependencies from scratch and then saves node_modules/.vite keyed on the lockfile hash; a later runner with a matching key restores that cache and skips pre-bundling entirely. Restore .vite keyed on the lockfile hash runner A: misspre-bundle + save cache storekey: os-vite-{lockHash} runner B: hitrestore, skip pre-bundle the key must change only when the resolved dependencies change
Figure: the whole design hinges on a key that tracks the resolved dependencies and nothing more volatile.

Prerequisites & reproducible setup

# .github/workflows/ci.yml — GitHub Actions, Node 20, Vite 5.4.x
name: CI
on: [push, pull_request]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: npm }
      - run: npm ci
      - run: npm run build

This baseline works but re-pre-bundles on every run. The cache: npm on setup-node caches the npm download cache, not node_modules/.vite — a common point of confusion.

The distinction is worth spelling out because both caches sound like “the Vite cache” to someone reading the logs. setup-node’s cache: npm persists ~/.npm, the tarball store that npm ci reads from to avoid re-downloading packages from the registry. That speeds up the install step, and you should keep it. But it does nothing for pre-bundling: after the packages are unpacked into node_modules, esbuild still has to read them, resolve their entry points, and produce the flattened bundles. Those bundles are what live in node_modules/.vite, and nothing in the baseline workflow above ever saves or restores that directory. You can watch this happen: with only the setup-node cache in place, install gets faster on the second run while the “optimizing dependencies” phase stays exactly as slow. Two caches, two jobs, and you need the second one.

The baseline workflow re-pre-bundles every run The starter workflow checks out, installs with npm ci, and builds, but because nothing persists node_modules/.vite the runner pre-bundles from scratch on every run; the rest of this page adds the missing cache step. npm ci no .vite persistedcold pre-bundle npm run build nothing carries .vite between runs — yet
Figure: the starter workflow has no step that persists .vite, so every run is cold.

How it works under the hood

The optimizer’s own caching logic is what makes an external CI cache safe. When Vite starts, it computes a hash over the inputs that could change the pre-bundle: the resolved versions of the dependencies it is about to optimize, the relevant parts of your Vite config (optimizeDeps.include, exclude, esbuildOptions), the lockfile, and a handful of environment signals. It writes that hash into _metadata.json alongside the bundled deps. On the next start it recomputes the hash and compares. If they match, Vite reuses the existing bundles and prints nothing about optimizing; if they differ, it discards node_modules/.vite/deps and re-runs esbuild.

This is the property you are leaning on. Restoring a stale .vite directory from an old CI run cannot produce a wrong build, because Vite validates the fingerprint before trusting it. The worst case for a mismatched restore is that Vite ignores the restored bundles and pre-bundles anyway — you lose the speedup, you do not get a corrupt output. That is why keying the actions/cache entry on the lockfile is belt-and-suspenders rather than strictly required for correctness: even a coarse key that occasionally restores an inapplicable cache degrades to a cold pre-bundle, never to a broken one. The lockfile key exists to make the cache hit usefully as often as possible, and to avoid saving a fresh entry on every run when nothing changed.

The two layers compose cleanly. actions/cache moves the .vite directory from one runner’s disk to the next; Vite’s _metadata.json decides whether the moved directory is still valid. Get the actions/cache key roughly aligned with what invalidates the pre-bundle and the two agree almost always, so a restore is also a Vite hit and the optimizer stays quiet.

Diagnosis workflow

  1. Confirm CI is re-pre-bundling. The build log shows Vite optimizing dependencies on every run; that time is what the cache eliminates. Look for the “optimizing dependencies” line (or, with --debug, the vite:deps entries listing each discovered import); its presence on the second and third runs of an unchanged branch is the smoking gun that nothing is being reused between runners.
  2. Pick the cache path. It is node_modules/.vite (Vite 5) — the pre-bundle and its _metadata.json, not the whole node_modules. Older Vite 2 releases wrote to node_modules/.vite as well but with a different internal layout, and monorepos that set a custom cacheDir relocate it; if you have overridden cacheDir in vite.config.ts, cache that path instead, because the default no longer applies.
  3. Pick a stable key. Hash the lockfile so the cache invalidates exactly when resolved dependencies change, with a restore-keys prefix so a near-miss still restores a usable older cache. The lockfile is the right hash input because it pins the exact resolved versions esbuild will bundle; hashing package.json instead would miss transitive upgrades that a ^ range silently pulls in, and hashing source files would churn the key on every commit for no benefit.
Cache the pre-bundle, not the npm download cache setup-node's cache option only caches the npm download cache, which speeds installs but not pre-bundling; to skip pre-bundling you must separately cache node_modules/.vite keyed on the lockfile. setup-node cache: npmspeeds install onlynot the pre-bundle cache node_modules/.viteskips pre-bundlingkeyed on lockfile two different caches — you need the second one
Figure: setup-node's cache is a different cache — pre-bundling needs its own .vite entry.

Annotated solution config

# .github/workflows/ci.yml — actions/cache v4, Vite 5.4.x
name: CI
on: [push, pull_request]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: npm }
      - run: npm ci

      # Restore the Vite pre-bundle. Key on the lockfile so it invalidates
      # exactly when resolved dependencies change.
      - name: Cache Vite pre-bundle
        uses: actions/cache@v4
        with:
          path: node_modules/.vite
          key: ${{ runner.os }}-vite-${{ hashFiles('**/package-lock.json') }}
          # A near-miss (deps changed slightly) still restores a usable base.
          restore-keys: |
            ${{ runner.os }}-vite-

      - run: npm run build

Read the ordering of that workflow closely, because it is load-bearing. The actions/cache step sits after npm ci, not before it. npm ci removes node_modules in its entirety before reinstalling, so a .vite directory restored before the install would be deleted milliseconds later and the cache would silently do nothing. Restoring after the install means the cache lands into a node_modules that already exists, and npm run build then finds a warm .vite waiting. The key combines runner.os with the lockfile hash: the OS prefix keeps a Linux pre-bundle from ever being handed to a macOS or Windows runner, where esbuild’s platform-specific binaries and path handling differ, and the hash pins the entry to one exact resolved dependency set. The restore-keys block is the graceful-degradation path — when the lockfile changes and the exact key misses, actions/cache walks the prefix and restores the most recent entry that starts with ${{ runner.os }}-vite-, giving the optimizer a partial base to diff against rather than a bare directory.

If you have pinned the Vite or Node major deliberately and want the key to track it, fold those versions into the key so an upgrade invalidates cleanly instead of restoring a pre-bundle written by the old binary:

# .github/workflows/ci.yml — actions/cache v4, Vite 5.4.x
# Include the Node and Vite versions so a runtime or optimizer bump
# invalidates the pre-bundle format rather than restoring a stale one.
- name: Cache Vite pre-bundle
  uses: actions/cache@v4
  with:
    path: node_modules/.vite
    key: ${{ runner.os }}-node20-vite5-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node20-vite5-
Exact key plus restore-keys fallback The exact key combining the OS and the lockfile hash gives a precise hit when dependencies are unchanged, and the restore-keys prefix falls back to the most recent older cache when the lockfile changed slightly, so the runner rarely starts fully cold. exact key: os-vite-{hash}precise hit restore-keys: os-vite-near-miss fallback on miss precise when possible, warm-ish when not
Figure: the fallback prefix means a dependency bump degrades to a partial warm start, not a full cold one.

Verification

# In the Actions log for the second run of the same commit range:
# - "Cache restored from key: Linux-vite-<hash>"
# - no "optimizing dependencies" line in the build output

A restored run logs the cache key it hit and produces no Vite pre-bundle output, so the build reaches the same result faster. If the log still shows optimizing dependencies, either the key changed (a volatile lockfile) or node_modules/.vite was cleared by an earlier npm ci.

To read the outcome deterministically instead of eyeballing timings, assert on the two signals directly. The actions/cache step exports cache-hit as a step output, and the build’s own log tells you whether the optimizer ran. Wiring both into an explicit check turns a silent regression — a cache that quietly stopped hitting after someone reordered the steps — into a failed job you actually notice:

#!/usr/bin/env bash
# verify-vite-cache.sh — run right after the build step in CI
# Fails the job if Vite re-optimized despite a reported cache hit.
set -euo pipefail

# $CACHE_HIT is wired from steps.<id>.outputs.cache-hit in the workflow.
if [ "${CACHE_HIT:-false}" = "true" ] && grep -q "optimizing dependencies" build.log; then
  echo "Cache hit but Vite still pre-bundled — key or step order is wrong." >&2
  exit 1
fi
echo "Pre-bundle cache behaving as expected."

The first restored run is a fair test only from the second run onward: the very first job on a new key necessarily misses, pre-bundles, and saves the entry, so measure the delta between run one (cold, saving) and run two (warm, restoring). If run two still pre-bundles, the fix is not working, and the diagnosis is almost always one of the two causes above — check the step order before you touch the key.

Two log lines confirm the restore Success is a Cache restored from key line in the Actions log together with the absence of any optimizing dependencies line in the build output; a surviving optimize line means the key changed or the cache directory was cleared. Cache restored from keypresent optimizing dependenciesabsent one line must appear, the other must not
Figure: the restore is confirmed by one line present and one line absent in the same log.

Gotchas & edge cases

Four CI cache edge cases A volatile regenerated lockfile changes the key every run; npm ci deleting node_modules wipes the restored .vite before the build; caching all of node_modules is slow and fragile; and a Vite or Node version bump changes the pre-bundle format so include the versions in the key. volatile lockfile→ commit a stable lockfile npm ci wipes .vite→ restore after install caching all node_modules→ cache only .vite Vite/Node bump→ add versions to the key
Figure: the order of steps matters — restoring .vite before npm ci gets it deleted again.
  • npm ci deletes node_modules. The symptom is a cache that reports a hit yet the build still prints “optimizing dependencies”. The root cause is ordering: npm ci unconditionally removes node_modules before reinstalling, so a .vite restored ahead of it is gone by build time. The fix is to place the actions/cache step after npm ci. Confirm it by checking that the restore log line appears below the install step in the job timeline, not above it.
  • Volatile lockfile. The symptom is a cache that never hits — every run shows a fresh save and no restore. The root cause is a lockfile that changes content on each install (a floating registry field, a tool rewriting it, or an install run with --no-save semantics), which changes hashFiles and therefore the key every time. The fix is to commit a deterministic lockfile and run npm ci (which refuses to mutate it) rather than npm install. Confirm it by hashing the lockfile in two consecutive runs and checking the values match.
  • Do not cache all of node_modules. The temptation is to cache the whole install tree and skip both the download and the pre-bundle. In practice it is a bad trade: the directory is large, so upload and restore are slow; it is fragile across OS and CPU architecture because native addons are compiled per-platform; and a partial restore can leave a subtly inconsistent tree that npm ci would have rebuilt correctly. Cache only .vite and let npm ci own node_modules. The measurable tell is restore time — a node_modules cache often takes longer to download and unpack than a clean install takes to run.
  • Version bumps. The symptom is a green build that is mysteriously slow again after a dependency PR, with the optimizer running despite an apparent restore. The root cause is that a Vite or Node major can change the pre-bundle’s on-disk format, so _metadata.json no longer validates and Vite discards the restored bundles. The fix is to fold the Vite and Node versions into the cache key, as shown above, so the upgrade lands on a new key and invalidates cleanly instead of restoring an incompatible directory. Confirm it by verifying the key string in the log changed on the bump.

Performance considerations

The realistic upside is bounded by how much of your job time is pre-bundling in the first place. On a small app with a dozen dependencies the optimizer finishes in a second or two, and the cache save/restore overhead can eat most of that saving — measure before you assume a win. The cache pays off on applications with large dependency graphs, deep icon or UI libraries, or many optimizeDeps.include entries, where cold pre-bundling is a visible chunk of every job. actions/cache itself is not free: it compresses, uploads to and downloads from GitHub’s cache service, and counts against your repository’s 10 GB cache quota, with least-recently-used eviction once you exceed it. Keeping the entry small — just .vite, not node_modules — keeps both the transfer time and the quota pressure low, which is a second reason to resist the temptation to cache the whole install tree.

When not to use this

Skip the cache when the pre-bundle is cheap relative to the rest of the job, when your dependency set changes on nearly every commit (the key would miss constantly and you would pay save overhead with no restore benefit), or when the job never runs the dev server or a build that triggers optimization — a pure lint or type-check job has no .vite to cache. It is also redundant on self-hosted runners with a persistent workspace, where node_modules/.vite already survives between jobs; adding actions/cache there duplicates state and can restore an older entry over a newer local one. Reach for it specifically when runners are ephemeral and pre-bundling is a real, repeated cost.

Comparison with caching the npm store alone

Teams often stop at setup-node’s cache: npm and assume they are done. That cache and this one solve different halves of the cold-start problem and are complementary, not alternatives. The npm store cache eliminates network time in the install step by reusing downloaded tarballs; the .vite cache eliminates CPU time in the optimize step by reusing esbuild’s output. Keep both: the npm cache under setup-node, the pre-bundle cache as its own actions/cache step after install. Dropping the npm cache to keep only .vite slows installs; dropping .vite to keep only the npm cache leaves the pre-bundle cold, which is the exact cost this page removes. The same layering applies on other providers — GitLab CI’s cache: with a key: files clause and CircleCI’s save_cache/restore_cache express the identical pattern: an install cache plus a separate .vite cache keyed on the lockfile.