Splitting Vendor Code with Rollup's manualChunks: When It Helps and When It Just Adds Files
Rollup's manualChunks lets you split stable vendor dependencies from churning app code so browsers keep cached third-party code across deploys. Here's a working config, how to verify it, and when splitting backfires.
27 Sept 2025, 19:02 UTC

Every time you ship a one-line fix to your app, your users re-download React, your date library, and everything else that hasn't changed in months — because it all lives in one bundle with one content hash. Rollup's output.manualChunks option is the standard fix: pull the stable third-party code into its own chunk so it keeps its hash (and its cache entry) while your application code churns around it. Done well, it's a real caching win. Done carelessly, it's a pile of tiny files and a chunk graph nobody understands.
This post assumes Rollup 3/4-era behavior. The option shape has been stable for a while, but confirm against your installed version's docs before copying anything into production.
The problem: one hash invalidates everything
Rollup's default output for a single entry point is one bundle (plus chunks created by dynamic imports). If you name that bundle with a content hash — entryFileNames: '[name]-[hash].js' — you get long-lived caching, which is what you want. The catch: the hash covers the whole file. Change one line of your code and the hash changes, so the browser discards the cached copy and downloads the entire thing again, including every unchanged dependency.
The insight behind vendor splitting is that your dependencies and your code change on very different schedules. React might update quarterly; your feature code updates daily. If they share a file, they share the faster invalidation schedule.
How manualChunks actually works
manualChunks is an output option. Its most flexible form is a function that Rollup calls once per module with the module's id (an absolute file path). Return a string and every module that returns the same string is emitted into a chunk with that name. Return nothing and Rollup falls back to its normal splitting behavior for that module.
Two properties matter for correctness:
- Deterministic. The function may be called for hundreds or thousands of modules. It must return the same answer for the same id every time, or your output becomes unstable between builds.
- Cheap. It runs inside the build hot path. String checks on the id are fine; filesystem reads or regex-heavy logic are not.
A worked example: vendor chunk plus hashed filenames
A minimal, commonly used configuration in rollup.config.js:
export default {
input: 'src/main.js',
output: {
dir: 'dist',
format: 'es',
entryFileNames: '[name]-[hash].js',
chunkFileNames: 'chunks/[name]-[hash].js',
manualChunks(id) {
if (id.includes('node_modules')) {
return 'vendor';
}
},
},
};This sends every module resolved from node_modules into a single vendor-[hash].js chunk. Your entry and any dynamic-import chunks contain only application code. When you change app code, only the app chunk's hash changes; the vendor chunk keeps its filename and stays cached.
A refinement worth considering once the vendor chunk gets large: split by package so a bump to one library doesn't invalidate all of them.
manualChunks(id) {
if (!id.includes('node_modules')) return;
if (id.includes('/react/') || id.includes('/react-dom/')) return 'react';
return 'vendor';
}Run the build from your project root with whatever script wraps Rollup (e.g., npm run build). No special permissions are needed beyond a normal dev environment. The risk is low — this only changes build output — but it does change your deployed files, so treat it like any release.
Verify the output, don't trust the config
manualChunks interacts with Rollup's other splitting logic — dynamic imports, shared modules between entries, circular dependencies — so the emitted graph can surprise you. Check it:
- List
distand confirm separatevendor-*.jsand entry files exist with hashes. - Generate sourcemaps (
sourcemap: true) or run a bundle visualizer plugin and confirm the libraries you intended are in the vendor chunk — and, critically, not duplicated in app chunks. Duplication usually means a module got pulled into the graph through a path your function didn't classify. - Do a real cache test: deploy to hosting with production-like
Cache-Controlheaders, load the page, change one line of app code, rebuild, redeploy, and reload. In the browser's network panel, the vendor chunk should come from cache while the app chunk is fetched fresh.
The trade-off: chunks aren't free
Every chunk is a separate request. One vendor chunk is almost always a safe win; a dozen hand-carved chunks often isn't, especially if your users might be on HTTP/1.1 connections or your deployment pipeline has file-count constraints. Even on HTTP/2, more chunks mean more module-graph overhead and more chances for a shared dependency to get hoisted into a chunk you didn't expect.
There's also no guaranteed performance number here. Whether splitting helps depends on your protocol, cache headers, deploy frequency, and import graph — measure in your own environment rather than assuming.
Closing: start with one chunk, inspect, then refine
Start with the single node_modules → vendor rule and hashed filenames. Build, inspect the output with sourcemaps or a visualizer, and verify cache behavior in a production-like deploy. Only split further — per-package chunks, per-framework chunks — when the vendor file is large enough that partial invalidation is worth the extra requests. If the graph ever looks wrong, the visualizer is the source of truth, not your config.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.