Vite Build Performance and Caching
Vite is fast by default, but a real project accumulates cold-start latency, memory pressure during production rollup builds, and CI runs that rebuild from scratch every time. This guide treats build performance as three separate problems — dependency pre-bundling and its cache, chunking strategy and its memory cost, and cross-run cache restoration — and shows how to measure each so you tune the one that actually dominates. It sits under Vite Configuration Ecosystem; the dev-server latency side is covered in optimizing the Vite dev server and HMR, while this page focuses on build throughput and caching.
The reason these three problems are worth separating is that they live in different engines with different failure signatures, and a fix aimed at the wrong one is not merely wasted effort — it can make the dominant cost worse. Pre-bundling latency is an esbuild-in-the-dev-server concern; it never touches the production output. Chunk memory is a Rollup-in-the-build concern; it never touches dev startup. CI cold-start is an artifact-restoration concern that sits outside Vite entirely, in your pipeline’s cache step. Teams routinely spend a day carving up manualChunks to speed up a dev server that was slow because of pre-bundling, or bump --max-old-space-size on a build that was fine on memory but slow on transform. The map matters more than any single option.
What breaks without these fixes is concrete and progressive. Without a warm pre-bundle cache, every vite dev re-optimizes hundreds of CommonJS entry points through esbuild before the first request is served, and mid-session dependency discovery triggers full-page reloads that erase HMR state. Without a deliberate chunk graph, a large application’s production build holds the entire module graph resident until the last chunk is written, and on a memory-constrained CI runner that surfaces as a hard JavaScript heap out of memory crash rather than a gradual slowdown. Without cache restoration, none of your local tuning reaches CI at all: a fresh runner starts cold every time, so the pipeline pays the full pre-bundle and install cost on every push. Each of the following sections isolates one of these, gives the mechanism underneath it, and states how to confirm the fix landed.
Prerequisites
Pin Vite and its Rollup version — chunking behaviour and optimizeDeps options shift across minors.
// package.json — verified toolchain
{
"devDependencies": {
"vite": "5.4.0", // ships Rollup 4 and esbuild-based pre-bundling
"rollup": "4.21.0" // used for the production build
}
}
Node 18.18+ is the floor; Node 20 LTS is recommended because Vite 5 build memory scales with the V8 heap. Establish a baseline before tuning: run vite build --profile once and note wall-clock and peak RSS, because optimizing the wrong phase is the most common wasted effort here.
Pinning matters more than it looks. optimizeDeps gained and lost options across Vite 3, 4, and 5, and the on-disk cache layout under node_modules/.vite changed shape when Vite 5 moved dependencies into a deps/ subdirectory with a sidecar _metadata.json. If your lockfile floats Vite across a minor, a CI runner can restore a cache written by one layout and hand it to a binary that expects another, which manifests as a silent re-optimization on every build — the cache is present but Vite discards it. Pin Vite and Rollup to exact versions, and treat a Vite upgrade as a deliberate cache-invalidation event: bump the cache key at the same time so the first post-upgrade build writes a fresh cache rather than fighting a stale one.
The baseline should capture two numbers per phase, not one. For the build, record both wall-clock and peak resident set size, because the two levers in this guide move different numbers — a vendor split lowers peak RSS with little effect on wall-clock, while pre-bundling changes startup latency with no effect on the production build at all. For dev, record cold startup (after deleting the cache) and warm startup separately; the difference between them is the exact cost the pre-bundle cache is buying you, and if that gap is small there is nothing to optimize in optimizeDeps no matter how it feels.
Core mechanics: pre-bundling, chunking, and cache keys
Vite’s dev server pre-bundles dependencies with esbuild the first time it sees them, writing the result to node_modules/.vite/deps and a _metadata.json hash. On the next start, if the lockfile, config, and a few inputs are unchanged, Vite restores that cache and skips pre-bundling entirely — this is why the second vite dev is dramatically faster than the first. Invalidation is keyed on the resolved dependency versions, not package.json ranges, so a lockfile change busts it.
The production build is a different engine: Rollup reads the whole module graph, transforms every module, then splits it into chunks. Peak memory is set by how many modules are alive at once, which manualChunks controls — a good split lets Rollup finalize and free chunks incrementally, while a single giant chunk keeps the whole graph resident. Cross-run caching in CI is orthogonal: you restore node_modules/.vite (and the dependency install) keyed on the lockfile hash so a fresh runner inherits a warm pre-bundle.
How pre-bundling works under the hood
The pre-bundler runs in three phases, and knowing the order is what lets you predict when it will and will not re-run. First, on vite dev startup, Vite crawls your declared entry points (index.html and anything statically reachable from it) to build a set of bare imports — package specifiers that resolve into node_modules. This static scan is why a dependency imported only inside a lazily-loaded route, or behind a runtime import(), is invisible at startup: it is not statically reachable from the entry, so it is not in the initial set. Second, Vite hands that set to esbuild, which bundles each dependency into a single ESM file per package and writes it to node_modules/.vite/deps/. This is where the speed comes from — a package like lodash-es that ships hundreds of tiny modules becomes one file, so the browser makes one request instead of hundreds, and later modules referencing it hit a flat, already-transformed artifact.
Third, Vite computes a cache hash and stores it in node_modules/.vite/deps/_metadata.json. That hash is derived from the resolved dependency versions in the lockfile, the relevant parts of your Vite config (optimizeDeps, resolve, plugins that declare themselves cache-relevant), and the package manager identity. On the next vite dev, Vite recomputes the hash and compares; a match restores the whole deps/ directory and skips esbuild entirely, which is why the second start is sub-second. A mismatch — a changed lockfile, an edited optimizeDeps.include, a different Vite version — throws the directory away and re-bundles from scratch. There is no partial invalidation: one changed dependency version re-optimizes the entire set, because the metadata hash is a single value covering all of them.
The consequence to internalize is that pre-bundling is discovery-driven, not exhaustive. When a request during the session reaches a dependency the startup scan missed, Vite optimizes it on the fly, appends it to the cache, and — because the module graph the browser already loaded now references a stale version of that dependency’s placeholder — issues a full-page reload. That reload is the visible symptom of a cold discovery, and it is exactly what optimizeDeps.include prevents by forcing the dependency into the startup set before the first request.
How Rollup chunking spends memory
On the build side, the mechanism is graph liveness. Rollup parses every module into an AST, links the module graph, runs tree-shaking to mark live bindings, and then generates output chunks. During generation, a module’s memory can only be released once every chunk that references it has been rendered and written. With one enormous chunk, that condition is never met until the very end, so the entire parsed graph — ASTs, scopes, binding metadata — stays resident through the whole render. Splitting vendors into their own chunks lets Rollup render and flush those chunks earlier, dropping their modules’ retained memory while the remaining app chunks are still being assembled. The output bytes are identical either way; what changes is the high-water mark of simultaneously-live modules, and on a runner with a fixed heap ceiling that high-water mark is the difference between a green build and an out-of-memory crash.
Configuration & CLI reference
// vite.config.ts — Vite 5.4.x
import { defineConfig } from 'vite';
export default defineConfig({
optimizeDeps: {
// Force CJS-heavy or dynamically-imported deps through pre-bundling
// so they are cached instead of re-optimized on the fly.
include: ['lodash-es', '@scope/ui-kit'],
// Exclude a dep you want served as native ESM (e.g. it self-optimizes).
exclude: ['@my/esm-only-lib'],
},
build: {
// Raise the warning threshold once you have deliberately sized chunks.
chunkSizeWarningLimit: 800,
rollupOptions: {
output: {
// Split vendor code so Rollup frees chunk memory incrementally
// and the browser caches stable vendor separately from app code.
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('react')) return 'react-vendor';
return 'vendor';
}
},
},
},
},
});
# Vite 5.4.x — measure and reset
vite build --profile # writes a V8 CPU profile of the build
vite --force # discard node_modules/.vite and re-pre-bundle
rm -rf node_modules/.vite # nuke the pre-bundle cache entirely
A few notes on the options above that are easy to get wrong. optimizeDeps.include takes resolved import specifiers, not package names, so a deep import like lodash-es/debounce is a different entry than lodash-es and both may need listing if your code uses both forms — Vite optimizes on the specifier it actually sees. optimizeDeps.exclude is a sharp tool: it tells Vite to serve the dependency as native ESM without the esbuild interop wrapper, which is correct only for packages that already ship clean, browser-ready ESM. Excluding a CommonJS package removes the very wrapper that made its default export work, so reach for exclude only when you can point to a specific reason the dependency should bypass pre-bundling.
The manualChunks function runs once per module during Rollup’s chunk-assignment phase, and its return value names the chunk that module lands in; returning undefined leaves the module to Rollup’s default splitting. Because it is called for every module in the graph, keep it cheap — string checks on id, not filesystem calls or regex backtracking — or you turn a per-module hook into a measurable build cost. The function form is preferred over the object form once you need any conditional logic, because the object form ({ 'react-vendor': ['react', 'react-dom'] }) can only match by package name and cannot express “everything in node_modules except X”.
For a build that is memory-bound rather than latency-bound, the object form is often enough and is easier to reason about. Here is the minimal split that isolates the framework runtime from the rest of the vendor tree, which is usually the first thing to try before writing a function:
// vite.config.ts — Vite 5.4.x — minimal object-form vendor split
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
// React and its runtime rarely change, so isolating them
// gives the browser a long-lived, separately-cacheable chunk
// and lets Rollup flush their modules early during render.
'react-vendor': ['react', 'react-dom', 'react-dom/client'],
},
},
},
},
});
The trade-off with any manual split is cache granularity against request count. Every named chunk is a separate HTTP request and a separate entry in the browser cache, so over-splitting — a chunk per npm package — trades one big download for a waterfall of small ones and can regress cold-load time even as it improves build memory. The sweet spot is a handful of chunks drawn along change-frequency lines: a rarely-changing framework chunk, a broader vendor chunk, and your application code, so a routine app deploy invalidates only the app chunk and leaves the vendor chunks warm in users’ caches.
Step-by-step workflow
- Baseline.
vite build --profileand record wall-clock plus peak RSS; separately time a coldvite dev(afterrm -rf node_modules/.vite) versus a warm one. Do this on the machine whose numbers you actually care about — a developer laptop and a CI runner have different core counts and heap ceilings, and a build that is transform-bound on eight cores can be memory-bound on a two-core runner with 4 GB. The baseline is not a one-time ritual; re-run it whenever the dependency tree changes materially, because a single heavy new dependency can shift which phase dominates. - Fix pre-bundling if dev startup dominates. Add heavy CJS deps to
optimizeDeps.includeso they are cached rather than re-optimized mid-session. The candidates are dependencies that ship many internal modules or are only reachable behind lazy routes — the two cases the startup scan handles poorly. After adding them, delete the cache once and start dev; the target state is that nooptimizing dependenciesline appears after the server is listening, which means every dependency was discovered up front. - Fix chunking if build memory dominates. Split vendors with
manualChunksso Rollup frees memory incrementally. Start with the object-form framework split above, measure, and only move to the function form if you need to carvenode_modulesmore finely. Resist splitting for its own sake: each new chunk is a request the browser must make, so stop as soon as peak RSS is under your runner’s ceiling with headroom, not when the chunk list looks tidy. - Restore caches in CI so fresh runners inherit a warm pre-bundle keyed on the lockfile hash. The cache key must hash the lockfile and nothing volatile; a key that includes a timestamp or the commit SHA never hits. Cache both the install output and
node_modules/.viteso the runner skips install and pre-bundle in one restore, and confirm the hit by the absence of the pre-bundle log line on a subsequent run. - Re-measure after each change and keep only the lever that moved the dominant number. A change that improves a phase you were not bottlenecked on is not a win; it is added configuration surface with no payoff, and manual chunk maps in particular are a maintenance cost that must earn its place. Revert anything the numbers do not justify.
Debugging & failure modes
The pre-bundle cache re-optimizes mid-session
Symptom: [vite] new dependencies optimized appears in the terminal seconds or minutes after startup, and the browser does a full-page reload that wipes in-memory component state and any open route you were debugging. Root cause: a dependency was not statically reachable from the entry at startup — typically because it is imported inside a lazily-loaded route, behind a runtime import(), or only from a file that itself loads conditionally — so the startup scan missed it, and Vite optimized it the moment a request finally reached it. Fix: add the offending specifier to optimizeDeps.include so it is forced into the startup set and bundled before the first request. Confirm: delete node_modules/.vite, start dev, navigate the app including the lazy routes, and verify the optimizing dependencies line only ever appears during startup, never after the server is already listening. If it still reappears, the specifier you added does not match the one your code imports — check for a deep-import versus package-root mismatch.
Build runs out of memory
Symptom: FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory during vite build, often only on CI where the runner’s heap ceiling is lower than your laptop’s. Root cause: a single oversized chunk keeps the entire parsed module graph resident because Rollup cannot flush any module until every chunk referencing it has rendered, and one giant chunk defers that to the end. Fix: introduce a manualChunks vendor split so Rollup can render and free the vendor modules early, dropping the peak. Raising --max-old-space-size is a legitimate stopgap to unblock a release, but it only moves the ceiling — it does not lower the high-water mark, so a growing app will hit the new ceiling too. Confirm: run /usr/bin/time -v vite build before and after the split and check that Maximum resident set size dropped; the byte output should be unchanged, which is how you know you fixed memory and not the bundle.
CI never gets a warm cache
Symptom: every CI build prints optimizing dependencies and pays the full pre-bundle cost, even though the lockfile has not changed between runs. Root cause: either node_modules/.vite is never restored (the cache step only caches the install, not the Vite cache), or the cache key hashes something volatile — a timestamp, the run number, the commit SHA — so it never matches a previous run’s key. Fix: cache the node_modules/.vite directory with a key derived solely from the lockfile hash, so an unchanged lockfile restores the exact cache a previous run wrote. Confirm: on the second run after the fix, the cache step should report a hit and the build log should omit the optimizing dependencies line entirely. If it reports a hit but still re-optimizes, the cache was written by a different Vite version than the one restoring it — align the pinned version and bump the key.
optimizeDeps.exclude breaks a CJS dependency
Symptom: SyntaxError: The requested module '...' does not provide an export named 'default' at load time, appearing right after you added a package to optimizeDeps.exclude. Root cause: the excluded dependency is CommonJS, and pre-bundling is what wraps CommonJS into ESM with a synthesized default export; excluding it serves the raw CommonJS to the browser, which cannot interpret module.exports as an ESM binding. Fix: remove the package from exclude so it goes back through pre-bundling and regains its interop wrapper. Confirm: reload the page and check the failing import resolves; if you genuinely need this package excluded for another reason, the correct path is to find or request an ESM build of it rather than to strip the wrapper it depends on.
A stale cache survives a change it should have invalidated
Symptom: you edited optimizeDeps.include or upgraded a transitive dependency, but dev still serves the old pre-bundled output and your change appears to do nothing. Root cause: the metadata hash did not see your change — most often because the dependency version was bumped in node_modules without the lockfile being updated (a manual edit, a --no-save install, or a linked local package), so the hash Vite computes is unchanged and it restores the stale cache. Fix: run vite --force to discard the cache and re-bundle unconditionally, and make sure the real change is reflected in the lockfile so future runs invalidate correctly. Confirm: after --force, the new dependency version should appear in the served bundle; if you rely on a linked package during development, prefer optimizeDeps.exclude for it or expect to --force after each change, because linked packages sidestep the lockfile the hash is keyed on.
Performance impact & measurement
The single most useful measurement is the cold-versus-warm delta for both phases. For dev, time vite dev after rm -rf node_modules/.vite (cold) against a second start (warm) — the gap is the pre-bundle cost the cache saves. For build, compare peak RSS with and without your manualChunks split using /usr/bin/time -v vite build and read Maximum resident set size. A good vendor split typically cuts peak RSS by 30–50% on a large app at identical output bytes, and a warm pre-bundle cache turns a multi-second cold start into a sub-second one. In CI, the metric is cache-hit rate: a restored node_modules/.vite should let the first build skip pre-bundling, visible as the absence of the optimizing dependencies log line. Feed the profile from vite build --profile into a Chrome DevTools performance panel to see whether transform or chunk-generation dominates before deciding which lever to pull.
Read the profile with a specific question, not a general one. The --profile flag writes a .cpuprofile you load into the DevTools Performance panel; the two frames worth finding are the transform pass (plugin transform hooks, including the TypeScript and JSX loaders) and the chunk-generation pass (renderChunk, generateBundle). If transform dominates, the lever is fewer or cheaper transforms — trimming plugins, narrowing include globs, or moving heavy codegen out of the hot path — not chunking. If chunk generation dominates, the memory levers in this guide apply. A common misread is to see high total time and immediately split chunks, when the profile plainly shows the time is in a slow Babel-based plugin that a manualChunks change cannot touch.
Guard the numbers against noise. Wall-clock on a shared CI runner varies with neighbor load, so take the median of three runs rather than a single measurement, and pin the comparison to the same runner class. Peak RSS is far more stable than wall-clock because it is a property of the graph rather than the scheduler, which is why it is the metric to trust when you are proving a manualChunks change worked — a 40% RSS drop reproduces run to run, whereas a wall-clock delta under 10% is inside the noise floor and proves nothing.
CI integration
CI is where local tuning either pays off or evaporates, and the mechanism is restoration, not computation. A fresh runner has an empty node_modules and an empty node_modules/.vite, so unless the pipeline restores both, every push pays the full install-and-pre-bundle cost regardless of how well the config is tuned. The restore step is ordinary artifact caching keyed on the lockfile hash; the only Vite-specific part is remembering to include node_modules/.vite alongside the dependency install, since the two caches invalidate together but many pipelines cache only the install.
# .github/workflows/build.yml — GitHub Actions, verified 2026-07
# Restores the dependency install and Vite's pre-bundle cache together,
# both keyed on the lockfile hash so an unchanged lockfile is a full hit.
name: build
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # caches the npm download cache, keyed on the lockfile
- name: Restore Vite pre-bundle cache
uses: actions/cache@v4
with:
path: node_modules/.vite
# Key on the lockfile only — nothing volatile — so the cache
# written by a previous run with the same deps is a clean hit.
key: vite-deps-${{ hashFiles('package-lock.json') }}
restore-keys: |
vite-deps-
- run: npm ci # deterministic install from the lockfile
- run: npx vite build # inherits the warm .vite/deps cache
The restore-keys fallback deserves a comment. On a lockfile change the exact key misses, but restore-keys: vite-deps- restores the most recent prior cache as a starting point, so Vite re-optimizes only the dependencies that actually changed against a mostly-warm directory rather than rebuilding all of them from empty. That turns a lockfile bump from a full cold pre-bundle into an incremental one. The one failure mode to watch is a Vite version bump that is not reflected in the key: because the metadata hash includes the Vite version, a cache restored across a version boundary is discarded internally even though the CI cache step reports a hit. When you upgrade Vite, add the Vite version to the cache key (for example vite-deps-${{ hashFiles('package-lock.json') }} already moves because the lockfile records the new version) so the first post-upgrade run writes cleanly.
Monorepo parallelism
In a monorepo, the failure is different: not one slow build but many builds contending for the same finite CPU and memory. Running every package’s vite build in parallel with an unbounded task runner multiplies the peak-RSS problem — if a single package’s build peaks at 2 GB and eight run at once, the machine needs 16 GB it does not have, and the OOM killer takes builds down non-deterministically. The fix is to bound concurrency at the task-runner level so total peak memory stays under the machine’s ceiling, and to let each package keep its own node_modules/.vite cache so an unchanged package skips pre-bundling entirely on the next run.
# Turborepo / npm workspaces — verified 2026-07
# Bound build concurrency so aggregate peak RSS stays under the host ceiling.
# --concurrency caps how many package builds run at once; size it to
# (host memory / per-package peak RSS), not to core count.
turbo run build --concurrency=3
The instinct to set concurrency to the core count is wrong here because these builds are memory-bound before they are CPU-bound. Size concurrency to host memory / per-package peak RSS, measured with the same /usr/bin/time -v approach on one representative package, and only raise it if headroom remains. A monorepo also multiplies the value of the CI cache: with per-package .vite caches keyed on each package’s own dependency closure, a change to one package leaves every other package’s pre-bundle cache warm, so the pipeline re-optimizes only what changed rather than the whole workspace.
When not to reach for manualChunks
A manual chunk map is a maintenance liability, and on most projects it should stay empty. Rollup’s default splitting already isolates shared dependencies into sensible chunks based on the import graph, and for a small or medium app that default produces a cache layout no worse than a hand-written one. Reach for manualChunks only when a measurement forces you to: the build is hitting a memory ceiling, or a specific stable dependency is being invalidated on every deploy because it shares a default chunk with fast-changing code. Absent one of those signals, a manual map adds a hook that runs per module, a config surface to keep current as dependencies come and go, and a real risk of over-splitting into a request waterfall — all in exchange for nothing measurable. The discipline is the same as the rest of this guide: the lever earns its place by moving a number, or it comes back out.
Compatibility matrix
manualChunks config carries forward — the pre-bundler underneath is what changes with rolldown.| Vite | Rollup | Pre-bundler | manualChunks | Notes |
|---|---|---|---|---|
| 3.x | Rollup 2 | esbuild | function or object | Older cache layout under .vite. |
| 4.x | Rollup 3 | esbuild | function or object | optimizeDeps stabilized. |
| 5.x | Rollup 4 | esbuild | function or object | Current baseline; .vite/deps cache. |
| 6.x | Rollup 4 | esbuild/rolldown (opt-in) | function or object | Rolldown pre-bundling experiments begin. |
The line to read out of this table is that manualChunks is stable across every row, so a chunk map written for Vite 4 carries forward unchanged, while the pre-bundler underneath is the part in motion. The practical upgrade risk is therefore concentrated in the cache: because the metadata hash and on-disk layout are tied to the Vite version, treat every Vite minor bump as a forced cache invalidation and expect the first build after an upgrade to run cold. That is not a regression to debug; it is the hash correctly refusing a cache written by a different binary.
Comparison with webpack’s persistent cache and Turbopack
It is worth situating Vite’s model against the alternatives, because the caching contract differs in kind, not just degree. webpack 5’s cache: { type: 'filesystem' } persists the entire module-and-chunk graph to disk and restores it across builds, so a warm webpack build can skip re-parsing unchanged modules on the production build itself — something Vite’s production path does not do, because Rollup rebuilds the graph each time and Vite’s persistent cache covers only the dev pre-bundle. The upside of Vite’s narrower cache is that it is far harder to corrupt: there is one hash over the dependency set, not a graph serialization that can desync from source. Turbopack takes the opposite bet, with a function-level incremental cache designed to make even the production build incremental, which is where the esbuild and Turbopack workflows diverge most from Vite. For a Vite project the takeaway is bounded expectations: the pre-bundle cache makes dev startup cheap and CI installs cheap, but the production build is not incremental, so the lever for a slow production build is chunk memory and transform cost, never a hope that yesterday’s build will make today’s faster.
Related
- Vite Configuration Ecosystem — the parent overview of Vite’s build and dev pipeline.
- Optimizing the Vite dev server and HMR — the latency side that complements build throughput.
- Fixing slow Vite HMR in large monorepos — a related monorepo performance deep-dive.
- Code-splitting strategies for large applications — the chunking theory behind
manualChunks.