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.
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.
.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
- 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, thevite:depsentries 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. - Pick the cache path. It is
node_modules/.vite(Vite 5) — the pre-bundle and its_metadata.json, not the wholenode_modules. Older Vite 2 releases wrote tonode_modules/.viteas well but with a different internal layout, and monorepos that set a customcacheDirrelocate it; if you have overriddencacheDirinvite.config.ts, cache that path instead, because the default no longer applies. - Pick a stable key. Hash the lockfile so the cache invalidates exactly when resolved dependencies change, with a
restore-keysprefix 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; hashingpackage.jsoninstead would miss transitive upgrades that a^range silently pulls in, and hashing source files would churn the key on every commit for no benefit.
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-
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.
Gotchas & edge cases
.vite before npm ci gets it deleted again.npm cideletesnode_modules. The symptom is a cache that reports a hit yet the build still prints “optimizing dependencies”. The root cause is ordering:npm ciunconditionally removesnode_modulesbefore reinstalling, so a.viterestored ahead of it is gone by build time. The fix is to place theactions/cachestep afternpm 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-savesemantics), which changeshashFilesand therefore the key every time. The fix is to commit a deterministic lockfile and runnpm ci(which refuses to mutate it) rather thannpm 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 thatnpm ciwould have rebuilt correctly. Cache only.viteand letnpm ciownnode_modules. The measurable tell is restore time — anode_modulescache 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.jsonno 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.
Related
- Vite build performance and caching — the parent cluster covering pre-bundling and build memory.
- Speeding up Vite cold starts with dependency pre-bundling — the local behaviour this cache preserves in CI.
- Configuring Turbopack cache for Next.js projects — the same actions/cache pattern for Turbopack.