Reducing Vite Build Memory with manualChunks
A large Vite app can hit JavaScript heap out of memory during vite build not because the output is huge but because a single oversized chunk keeps the entire module graph resident while Rollup renders it. This guide splits the graph with manualChunks so Rollup finalizes and frees chunks incrementally, and shows how to measure the peak-memory win. It applies the chunking half of Vite build performance and caching; the chunking theory itself lives in code-splitting strategies for large applications.
The failure surfaces most often in CI, where the container has a hard memory ceiling and no swap, so the V8 heap simply runs into the wall and the process aborts mid-render. The seductive-but-wrong fix is to raise the ceiling: NODE_OPTIONS=--max-old-space-size=8192 makes the red text go away, but it treats the symptom. You are now paying for a larger runner, the build still holds the whole graph in memory, and the next dependency you add pushes you back over the new limit. Raising the heap buys time; it does not change the shape of the memory curve. manualChunks changes the shape.
To see why, it helps to know where in the pipeline the spike happens. Vite’s production build is Rollup underneath. Rollup runs in two broad phases: a build phase that resolves, loads, transforms, and parses every module into an AST held in the module graph, and a generate (render) phase that walks that graph, tree-shakes, and serializes each output chunk to a string. Memory climbs across the build phase as ASTs accumulate, but the sharpest peak is during generate: rendering a chunk means holding its rendered source, its source-map segments, and all the still-live module ASTs it depends on simultaneously. If one chunk contains nearly every module, that peak is the entire application at once. Split the same modules across several chunks and Rollup renders them in sequence — once a chunk is serialized and written, the strings and per-chunk scratch state for it become collectable, so the live set never has to hold everything at the same instant. The total bytes rendered are identical; the concurrent bytes are not.
How chunk assignment works under the hood
manualChunks is a Rollup output option, and Vite passes it straight through build.rollupOptions.output. When you give it a function, Rollup calls that function once per module id — the absolute, resolved path of the module — during the generate phase, before it lays out chunks. The string you return is a chunk name; every module that returns the same name is pooled into one chunk, and returning undefined (or nothing) leaves the module to Rollup’s automatic chunking, which places it with the entry or dynamic-import boundary that first pulled it in. That is the whole contract: a pure function from module id to chunk name.
The two properties that trip people up both follow from that contract. First, assignment is by id string matching, not by package metadata — id.includes('react') is a substring test against a filesystem path, so it will also match a path like node_modules/react-datepicker/... or a local file at src/react-helpers/x.ts. There is no dependency-tree awareness in a naive includes check; you are pattern-matching paths. Second, a module can only live in one chunk. If two of your if branches would both match a module, the first branch wins because you return on it, so branch order is load-bearing. Put the most specific matches first — @emotion before a broad react check, for instance, or you may capture emotion’s React-adjacent internals into the wrong pool.
Rollup resolves the assignment, then builds a chunk dependency graph: chunk A depends on chunk B if any module in A imports a module in B. That graph must stay acyclic, because chunks are emitted as ES modules and a cycle across chunk boundaries produces top-level import cycles that can dereference a binding before it is initialized. This is why splitting is not free-form: a shared low-level utility that both vendor-charts and vendor-mui import cannot be torn between them without risking a cycle, which is the mechanism behind the circular-chunk gotcha later on. The hashing that names the final files (vendor-react-a1b2c3d4.js) runs after this, over each chunk’s rendered content, so a chunk’s hash — and therefore its cache key in the browser — changes only when the code routed into it changes.
Prerequisites & reproducible setup
# Vite 5.4.x, Node 20+
npm create vite@latest mem-demo -- --template react-ts
cd mem-demo && npm install
npm install @mui/material @emotion/react @emotion/styled recharts three
The four extra dependencies above are deliberate: MUI plus Emotion, Recharts plus its transitive d3 modules, and Three.js are each large enough that a default single-vendor chunk in a real app pulls thousands of modules into one output. That is what reproduces the memory curve this guide flattens; a bare create-vite template will not OOM and gives you nothing to measure.
Measure peak memory of the current build so you have a number to beat:
# GNU time reports Maximum resident set size in KB.
/usr/bin/time -v npx vite build 2>&1 | grep "Maximum resident"
Use /usr/bin/time, not the shell built-in time — the built-in reports only wall-clock and CPU, while the GNU binary reports the maximum resident set size (peak RSS), which is the metric that actually predicts an OOM. On macOS the flag differs; use /usr/bin/time -l npx vite build and read the maximum resident set size line, which is reported in bytes rather than kilobytes. Note that peak RSS includes the whole Node process — V8 heap, native buffers, and mapped code — so it will always be somewhat higher than the --max-old-space-size heap figure; that is expected, and RSS is the number your CI runner is actually killed on, so it is the right one to track.
Diagnosis workflow
- Confirm memory, not output size, is the problem. A build that fails with
heap out of memorybut produces reasonable bytes when it does finish is a peak-memory issue, not a bloat issue. - Find the fat chunk. Read the build summary or the visualizer output — a single chunk holding most of
node_modulesis the culprit. - Decide the split axis. Split by vendor (all
node_modules), by library family (react, charts, three), or by route; the goal is several medium chunks Rollup can free in sequence.
Step one matters because the two failure modes have opposite fixes and splitting only helps one of them. If the build sometimes finishes and, when it does, emits an output whose byte total is in line with what you ship, the failure is transient memory pressure — the peak briefly crossed the ceiling. Splitting flattens that peak. But if the output itself is enormous — a 6 MB single JS bundle, a duplicated copy of a library, a moment.js locale bundle you never trimmed — then splitting only reshuffles the same bytes into more files and the build still strains; that is a tree-shaking or dependency-hygiene problem and belongs to a different guide. Confirm which one you have before touching manualChunks.
For step two, the fat chunk rarely announces itself in the default terminal summary, which truncates and rounds. Add rollup-plugin-visualizer in template: 'raw-data' or 'treemap' mode and open the report: the culprit is almost always one chunk whose treemap is dominated by node_modules, frequently a chart or 3D library that drags in a wide transitive tree. Record which top-level packages sit inside it — those package names become the branches of your manualChunks function. Do not guess at the split axis; read it off the treemap.
Step three is a judgement call between three axes. Splitting by vendor (one rule: everything under node_modules to a single vendor chunk) is the smallest change but often insufficient, because it just renames the fat chunk. Splitting by library family — React, charts, 3D, the rest — is the sweet spot for most apps: a handful of medium chunks that Rollup frees in turn and that also cache independently in the browser. Splitting by route pairs well with dynamic import() boundaries you already have, and is the strongest lever when one route (an admin dashboard, a data-viz page) is the sole reason a heavy library is in the graph at all. You can combine axes; the family split below is the most broadly useful starting point.
Annotated solution config
// vite.config.ts — Vite 5.4.x
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
// Split by library family so each chunk is medium-sized and Rollup
// can render and free them in sequence instead of holding one giant graph.
manualChunks(id) {
if (!id.includes('node_modules')) return;
if (id.includes('react') || id.includes('scheduler')) return 'vendor-react';
if (id.includes('@mui') || id.includes('@emotion')) return 'vendor-mui';
if (id.includes('recharts') || id.includes('d3')) return 'vendor-charts';
if (id.includes('three')) return 'vendor-three';
return 'vendor'; // everything else
},
},
},
},
});
Read the function top to bottom, because Rollup does too. The guard on line one — return early for anything not under node_modules — is what keeps your own source out of these vendor chunks and leaves Vite’s entry and route splitting untouched; without it you would be reassigning application modules and could collide with the entry chunk. After the guard, the branches are ordered specific-to-general on purpose. @emotion is matched inside the MUI branch rather than the React branch, even though Emotion leans on React, so its runtime lands with the components that use it. The final return 'vendor' is the catch-all that keeps stray dependencies — polyfills, small utilities — from falling back into automatic chunking where they might re-inflate another chunk. Each returned name is arbitrary; vendor-react is a label, not a package, and you could call it a and get the same memory behaviour with worse debuggability.
There is a second form of manualChunks worth knowing, because it is safer against the substring-matching trap. Instead of a function you can pass an object mapping chunk name to an array of module ids, and Rollup will pull those modules and everything only they import into the named chunk. It reads more declaratively and avoids accidental includes matches, at the cost of not being able to express “everything else”:
// vite.config.ts — Vite 5.4.x — object form of manualChunks
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
// Each key is a chunk; the array lists the packages that seed it.
// Rollup adds each package's private dependencies to the same chunk.
manualChunks: {
'vendor-react': ['react', 'react-dom', 'scheduler'],
'vendor-mui': ['@mui/material', '@emotion/react', '@emotion/styled'],
'vendor-charts': ['recharts'],
'vendor-three': ['three'],
},
},
},
},
});
Prefer the object form when your split axis is a fixed, known list of top-level packages; prefer the function form when you need a catch-all bucket or route-aware logic. The function form gives you the return 'vendor' fallback the object form cannot express, which is why the annotated example above uses it — an unlisted transitive dependency should land somewhere deterministic, not in whatever chunk automatic layout happens to pick.
Verification
# Vite 5.4.x — compare peak RSS before and after the split.
/usr/bin/time -v npx vite build 2>&1 | grep "Maximum resident"
The build now completes without raising --max-old-space-size, and Maximum resident set size drops meaningfully versus the baseline. The output byte total should be essentially unchanged — you split the same code, you did not remove any.
Read the two numbers together, because either one alone can mislead. Peak RSS down but output bytes also sharply down means you did not just split — you accidentally changed what ships, usually by dropping a module out of the graph through a mis-scoped guard; investigate before celebrating. Output bytes flat but peak RSS unchanged means the split did not actually break up the fat chunk — check the build summary and confirm several vendor-* files now exist with roughly balanced sizes, because a rule that routes everything to one name still produces one chunk. The signature of a correct memory fix is specifically the pair: RSS meaningfully lower, total bytes within a percent or two of baseline (a small increase is normal, since each extra chunk carries a little module-wrapper and import boilerplate).
To make the win legible in CI rather than eyeballing terminal output, capture the number to a file and assert on it. A crude guard is enough to catch a regression when someone later adds a heavy dependency:
# Vite 5.4.x — fail CI if peak RSS regresses past a budget (KB).
BUDGET_KB=2500000
PEAK_KB=$(/usr/bin/time -v npx vite build 2>&1 \
| awk '/Maximum resident/ {print $NF}')
echo "peak RSS: ${PEAK_KB} KB (budget ${BUDGET_KB} KB)"
test "$PEAK_KB" -le "$BUDGET_KB"
Gotchas & edge cases
-
Circular chunk imports. The symptom is a build that succeeds but a page that dies at load with
Cannot access '<X>' before initialization, often only in production and only sometimes, depending on evaluation order. The root cause is a shared low-level module that two of your chunks both import: your split tore it so that chunk A imports chunk B and chunk B imports chunk A, and ES module cyclic evaluation dereferences a binding that has not been assigned yet. The fix is to stop splitting on that boundary — pull the shared dependency into a single chunk (its own, or the one that owns it most naturally) so it is imported one direction only. Confirm by grepping the emitted chunks’importstatements, or runvite buildwith--debugand look for Rollup’s circular-dependency warning, which names the exact modules in the cycle. -
Over-splitting. The symptom is that peak RSS is fine but the network waterfall in the browser grows a long trail of tiny requests and your cache invalidation gets noisier. The root cause is a
manualChunksfunction granular enough to emit dozens of sub-100-line chunks; each is a separate HTTP request and a separate hashed cache entry, and under HTTP/2 the per-request overhead and head-of-line effects start to outweigh the parallelism. The fix is to coarsen — collapse per-package chunks back into family chunks so you land on a handful of medium files rather than a swarm. Confirm by counting theassets/*.jsoutputs and checking their size distribution; if the median chunk is a few kilobytes, you have gone too far. -
Duplicated singletons. The symptom is subtle: hooks throw “invalid hook call”, a global store loses state between components, or two
instanceofchecks disagree. The root cause is a module that must be a single instance — React itself, a Zustand/Redux store, an Emotion cache — being pulled into more than one chunk, so the bundle ships two copies and two module-level states. The fix is to isolate that module in exactly one chunk and make sure no rule routes any of its files elsewhere; for React specifically, keepreact,react-dom, andschedulertogether in onevendor-reactchunk. Confirm by searching the output for the package’s fingerprint (for example, more than one file containingreact-dominternals) — a correctly deduplicated split has exactly one. -
Entry modules. The symptom is a warning that a chunk name conflicts with an entry, or your own application code unexpectedly appearing inside a
vendor-*chunk. The root cause is amanualChunksfunction that returns a name for modules outsidenode_modules, colliding with the entry and dynamic-import chunks Vite manages itself. The fix is the guard clauseif (!id.includes('node_modules')) returnas the very first line, so application source falls through to Vite’s own layout. Confirm by checking that every chunk you named contains only third-party code and that your entry chunk still exists with the expected name.
When not to use this
manualChunks is the wrong tool when the problem is not concurrent-render memory. If your build OOMs because a single source module is pathological — a generated 30 MB translations file, a data blob inlined as a JS array, an SVG-to-component step that emits one enormous module — no chunk boundary helps, because that one module still has to be parsed and held whole; the answer is to load it as an asset or split it at the source. Likewise, if the peak comes from a plugin doing expensive per-module work (a source-map-heavy transform, an over-eager Babel pass), the memory lives in the build phase before chunking even runs, and reshaping the output does nothing.
It is also the wrong reach when a one-line config change already fits your constraints. A small app that OOMs only on an undersized CI runner is often better served by right-sizing the runner or setting a modest --max-old-space-size than by hand-maintaining a chunk map you will have to revisit every time you add a dependency. Manual chunking is config you own forever; reach for it when the graph is genuinely large enough that Rollup’s automatic layout produces a chunk you can point at, not as a reflex for any memory warning. And if you are on a framework preset (SvelteKit, Nuxt, Astro) that already imposes its own chunking strategy, override it deliberately and test, because your rules and the framework’s can fight.
Performance considerations
The memory win is real but it is not the only axis the split moves, and the others can regress if you are careless. Rendering more chunks costs a little more CPU and wall-clock time — Rollup does per-chunk work (scope hoisting, source-map assembly, hashing) that scales with chunk count — so an over-eager split can trade a memory improvement for a slower build. In practice the family split adds low single-digit percent to build time while cutting peak RSS substantially; that is a good trade. Watch build duration alongside RSS so you notice if a future refinement inverts it.
On the runtime side, family chunks improve cache economics. Because each chunk’s content hash changes only when the code routed into it changes, a bump to your chart library invalidates vendor-charts and leaves vendor-react — the largest and most stable chunk — cached in every returning user’s browser. A single fat vendor chunk throws that away: any dependency bump rewrites the whole thing and every user re-downloads all of it. The counter-force is over-splitting’s request overhead, discussed in the gotchas; the balance point for most apps is a handful of chunks aligned to how often each library actually changes, keeping the stable core (React) apart from the churny leaves (charts, feature libs).
CI integration
The whole point of this fix is usually to unstick a pipeline, so wire the measurement into CI rather than trusting a local run. Keep the peak-RSS budget check from the verification section in the build job so a future heavy dependency fails loudly with a number instead of mysteriously OOM-ing weeks later. Set the budget a comfortable margin below the runner’s actual memory limit — the process is killed at the container ceiling, not at the V8 heap size, so leave headroom for native buffers and the OS. If you also pin --max-old-space-size, set it below the container limit as well, or V8 will happily grow past what the runner can give it and get OOM-killed before its own heap guard trips.
One caching subtlety matters in CI specifically: the same content-hashed chunk names that help browsers also help your build cache and CDN. Because a stable vendor-react chunk keeps its hash across builds where React did not change, a content-addressed deploy step can skip re-uploading it, and downstream caches keep serving the old file. A single monolithic vendor chunk defeats that on every dependency bump. So the family split pays off three times over — lower peak memory in the build, better browser caching for users, and less deploy-time churn for your CDN — from one config change.
Related
- Vite build performance and caching — the parent cluster on pre-bundling and CI caching.
- Code-splitting strategies for large applications — the chunking theory behind these splits.
- Fixing vendor chunk duplication with manualChunks — the deduplication companion to this memory fix.