Rollup Asset Optimization
Configure Rollup 4’s output naming system to embed content hashes into every emitted file, then wire the plugin ecosystem to produce a deployment-ready manifest and enforce immutable cache headers end to end.
When to use Rollup directly (vs. a meta-framework)
Rollup is the right choice for library authors, design-system packages, and applications where you control the full build graph without a framework wrapper like Vite. Vite itself delegates its production bundle step to Rollup, but it exposes only a subset of the knobs documented here. If you are using Vite, the Vite asset pipeline configuration guide covers what Vite surfaces; come back to this page when you need to reach past those abstractions into raw Rollup output options.
Use Rollup directly when:
- You publish a component library or utility package and need deterministic chunk names for consumers’ CDN deploys.
- Your application build pipeline predates Vite and migrating is not yet feasible.
- You need fine-grained control over
manualChunks, hash character set, or customgenerateBundle/writeBundlehooks that framework wrappers do not expose.
The nearest alternative for application code is esbuild fingerprinting plugins, which trades Rollup’s plugin flexibility for compilation speed. Rollup wins when correctness and manifest control matter more than raw build speed.
Prerequisites
| Requirement | Minimum version | Notes |
|---|---|---|
| Rollup | 4.0.0 | output.hashCharacters added in 4.x |
| Node.js | 18.17 LTS | Required for crypto.createHash in plugins |
@rollup/plugin-node-resolve |
15.x | Consistent module resolution |
@rollup/plugin-commonjs |
25.x | CJS interop for vendor bundles |
Install:
npm install --save-dev rollup@4 @rollup/plugin-node-resolve@15 @rollup/plugin-commonjs@25
Output options reference
| Option | Type | Default | Effect |
|---|---|---|---|
output.entryFileNames |
string | function |
"[name].js" |
Names emitted entry chunks |
output.chunkFileNames |
string | function |
"[name]-[hash].js" |
Names emitted code-split chunks |
output.assetFileNames |
string | function |
"assets/[name]-[hash][extname]" |
Names CSS, images, fonts, and other non-JS assets |
output.hashCharacters |
"base64" | "base36" | "hex" |
"base64" |
Character set used when rendering [hash] |
output.compact |
boolean |
false |
Minifies output wrapper code |
The [hash] token
[hash] in any of the three file name templates derives from the content of that specific chunk or asset after all transforms are complete. Two builds from identical source produce the same hash — this is the deterministic build output property that makes immutable caching safe.
Rollup accepts an inline length suffix: [hash:8] truncates to 8 characters. Eight hex characters give 4 billion possible values, which is sufficient for projects with up to a few thousand assets. For monorepos or large applications generating thousands of chunks, use 12–16 characters to reduce collision probability below one in a trillion.
The default character set is base64 (URL-safe alphabet), which produces shorter hashes for the same uniqueness budget. Switching to hex makes the hash look like a traditional content hash and is more readable in CDN logs:
output: {
hashCharacters: 'hex'
}
When hashCharacters is hex you can compare the hash in a filename directly against sha256sum output truncated to the same length, which simplifies incident debugging.
Step-by-step implementation
1. Baseline configuration
// rollup.config.mjs
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
export default {
input: 'src/main.js',
output: {
dir: 'dist',
format: 'es',
hashCharacters: 'hex',
entryFileNames: 'assets/[name]-[hash:8].js',
chunkFileNames: 'assets/[name]-[hash:8].js',
assetFileNames: 'assets/[name]-[hash:8][extname]'
},
plugins: [
resolve({ browser: true }),
commonjs()
]
};
All three file name patterns use [hash:8]. The entryFileNames pattern is often left at default ([name].js) but that omits the hash, producing an unstable filename that busts immutable cache headers on every deploy. Always include [hash] in entryFileNames for production.
2. Code splitting with manualChunks
Code splitting assigns modules to named chunks before the hash is computed. The hash still reflects content, so a chunk that has not changed between releases keeps its hash.
// rollup.config.mjs
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
export default {
input: {
app: 'src/main.js',
admin: 'src/admin.js'
},
output: {
dir: 'dist',
format: 'es',
hashCharacters: 'hex',
entryFileNames: 'assets/[name]-[hash:8].js',
chunkFileNames: 'assets/[name]-[hash:8].js',
assetFileNames: 'assets/[name]-[hash:8][extname]',
manualChunks(id) {
if (id.includes('node_modules/react') || id.includes('node_modules/react-dom')) {
return 'vendor-react';
}
if (id.includes('node_modules/')) {
return 'vendor';
}
}
},
plugins: [
resolve({ browser: true }),
commonjs()
]
};
manualChunks accepts a function that receives the full module path and returns a chunk name string, or undefined to let Rollup decide. Grouping react and react-dom into a single vendor-react chunk means that upgrading an unrelated dependency does not change the React bundle hash — a browser that already cached vendor-react-a1b2c3d4.js keeps serving it from cache.
3. Manifest generation plugin
The bundle object passed to generateBundle is a complete record of every emitted file. Writing a manifest inside this hook guarantees the JSON is produced before the build exits.
// rollup.config.mjs
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
function manifestPlugin() {
return {
name: 'asset-manifest',
generateBundle(options, bundle) {
const manifest = {};
for (const [fileName, chunk] of Object.entries(bundle)) {
if (chunk.type === 'chunk') {
// Use the chunk's logical name as the key when available
const key = chunk.name ? `${chunk.name}.js` : fileName;
manifest[key] = fileName;
} else if (chunk.type === 'asset') {
const key = chunk.name || fileName;
manifest[key] = fileName;
}
}
this.emitFile({
type: 'asset',
fileName: 'manifest.json',
source: JSON.stringify(manifest, null, 2)
});
}
};
}
export default {
input: {
app: 'src/main.js',
admin: 'src/admin.js'
},
output: {
dir: 'dist',
format: 'es',
hashCharacters: 'hex',
entryFileNames: 'assets/[name]-[hash:8].js',
chunkFileNames: 'assets/[name]-[hash:8].js',
assetFileNames: 'assets/[name]-[hash:8][extname]',
manualChunks(id) {
if (id.includes('node_modules/react') || id.includes('node_modules/react-dom')) {
return 'vendor-react';
}
if (id.includes('node_modules/')) {
return 'vendor';
}
}
},
plugins: [
resolve({ browser: true }),
commonjs(),
manifestPlugin()
]
};
The emitted manifest.json ends up in dist/manifest.json. For the full walk-through of consuming this file in HTML templates and CDN deploy scripts, see the Rollup manifest generation guide for CDN deploys.
4. Running the build
# Single production build
npx rollup -c
# Verify determinism: run twice, compare hashes
npx rollup -c && find dist -name '*.js' -exec sha256sum {} \; | sort > run1.txt
rm -rf dist
npx rollup -c && find dist -name '*.js' -exec sha256sum {} \; | sort > run2.txt
diff run1.txt run2.txt
# Empty diff means fully deterministic output
A non-empty diff after two consecutive builds from the same source almost always means a plugin is injecting a timestamp, a random value, or an absolute filesystem path into the bundle. See the failure modes section for how to find the culprit.
Plugin ecosystem
Rollup’s core handles hashing and naming. A small set of plugins fills the gaps: CSS extraction, minification, TypeScript transpilation, and image asset processing. Each needs to be verified for this.emitFile compatibility before the manifest plugin will see its output.
@rollup/plugin-terser
Minifies JavaScript output. Runs as a renderChunk transform, so the hash is computed on the minified source. Add it after all transform plugins and before the manifest plugin to ensure the hash reflects what users actually download.
import terser from '@rollup/plugin-terser';
// In plugins array:
terser({
compress: { passes: 2 },
mangle: { toplevel: true },
format: { comments: false }
})
Setting format: { comments: false } strips all comments before hashing. This means adding or removing comments never produces a new hash — correct for a deterministic build output that users depend on.
rollup-plugin-css-only
Extracts import './styles.css' statements into a separate CSS file. It calls this.emitFile internally when configured correctly, which makes the CSS file appear in the bundle object. Verify the CSS filename obeys output.assetFileNames:
import css from 'rollup-plugin-css-only';
// In plugins array:
css({ output: false })
// Passing output:false tells the plugin to use this.emitFile
// rather than writing directly to disk with its own filename.
If the CSS file does not appear in the manifest, the plugin is writing to disk independently. Switch to a plugin that calls this.emitFile or add a writeBundle hook to scan the output directory and patch the manifest after the fact.
@rollup/plugin-image
Imports image files as base64 data URLs by default. If you want hashed image files on disk instead, use a custom load hook that calls this.emitFile:
// plugins/image-asset.js
import { createHash } from 'crypto';
import { readFileSync } from 'fs';
import { extname } from 'path';
export function imageAssetPlugin() {
return {
name: 'image-asset',
load(id) {
const ext = extname(id).slice(1).toLowerCase();
if (!['png', 'jpg', 'jpeg', 'svg', 'gif', 'webp'].includes(ext)) {
return null;
}
const source = readFileSync(id);
const hash = createHash('sha256').update(source).digest('hex').slice(0, 8);
const fileName = `assets/${id.split('/').pop().replace(`.${ext}`, '')}-${hash}.${ext}`;
const referenceId = this.emitFile({ type: 'asset', fileName, source });
return `export default import.meta.ROLLUP_FILE_URL_${referenceId};`;
}
};
}
This plugin hashes the image content, emits the file through Rollup’s normal asset pipeline (so it lands in output.assetFileNames territory and appears in the bundle), and exports the hashed URL as a string.
@rollup/plugin-alias
Replaces import paths at build time. No interaction with hashing — but alias resolution must be stable across environments. If an alias resolves to different absolute paths on CI versus local, the module graph differs and hashes diverge. Set aliases relative to fileURLToPath(import.meta.url):
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
import alias from '@rollup/plugin-alias';
const __dirname = dirname(fileURLToPath(import.meta.url));
alias({
entries: [
{ find: '@', replacement: resolve(__dirname, 'src') }
]
})
CI/CD integration
Running Rollup in a CI pipeline requires the same Node.js version and the same environment variables as local builds, plus a mechanism to upload the dist/ directory to an object store and notify the CDN.
GitHub Actions example
# .github/workflows/build-deploy.yml
name: Build and deploy
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- name: Build
run: npx rollup -c
env:
NODE_ENV: production
- name: Verify manifest
run: |
node -e "
const m = JSON.parse(require('fs').readFileSync('dist/manifest.json', 'utf8'));
const missing = Object.values(m).filter(f => !require('fs').existsSync('dist/' + f));
if (missing.length) { console.error('Missing:', missing); process.exit(1); }
console.log('Manifest OK:', Object.keys(m).length, 'entries');
"
- name: Upload hashed assets to S3
run: |
aws s3 sync dist/assets/ s3://${{ vars.S3_BUCKET }}/assets/ \
--cache-control "public, max-age=31536000, immutable" \
--metadata-directive REPLACE
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: us-east-1
- name: Upload manifest
run: |
aws s3 cp dist/manifest.json s3://${{ vars.S3_BUCKET }}/manifest.json \
--cache-control "no-cache, no-store, must-revalidate"
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: us-east-1
- name: Invalidate HTML on CloudFront
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ vars.CF_DISTRIBUTION_ID }} \
--paths "/index.html" "/manifest.json"
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: us-east-1
This workflow uploads hashed assets with an immutable Cache-Control header before touching the manifest. If the upload of hashed assets fails partway through, the old manifest still references the old filenames — users see no breakage. Only after all hashed assets are confirmed on S3 does the job upload the new manifest and invalidate HTML. This ordering is the atomic deploy guarantee.
For a more complete treatment of ordering constraints and rollback procedures, see the CI/CD asset pipeline integration reference.
Caching build inputs
Cache the node_modules directory and the Rollup transform cache between pipeline runs. The actions/setup-node cache handles node_modules. Rollup does not have a persistent transform cache in Rollup 4 (unlike webpack’s cache option), but most of the build time is in @rollup/plugin-terser, which can be parallelized:
terser({
maxWorkers: 4
})
Detecting non-determinism in CI
If two pipeline runs from the same commit produce different hashes, the builds are not deterministic. Add a step that reruns the build and compares manifests:
# After the first build:
cp dist/manifest.json /tmp/manifest-first.json
rm -rf dist
# Rebuild:
npx rollup -c
# Compare:
diff /tmp/manifest-first.json dist/manifest.json
if [ $? -ne 0 ]; then
echo "BUILD IS NOT DETERMINISTIC"
exit 1
fi
The most common cause on CI is a plugin that injects process.env.BUILD_ID or a timestamp into a comment or banner. Audit each plugin’s banner and footer options and strip anything non-deterministic before the hash step.
Verification shell commands
After a production build, confirm that every output file carries a hash and that the manifest maps all entries correctly.
# List emitted assets with their sizes
ls -lh dist/assets/
# Confirm hash appears in every JS filename (8 hex chars)
find dist/assets -name '*.js' | grep -E '[a-f0-9]{8}\.' || echo "MISSING HASH"
# Pretty-print the manifest
cat dist/manifest.json | python3 -m json.tool
# Verify immutable header on the CDN (replace URL with your actual asset URL)
curl -sI https://cdn.example.com/assets/app-a1b2c3d4.js \
| grep -i 'cache-control'
# Expected: cache-control: public, max-age=31536000, immutable
# Check that two sequential builds produce the same manifest
npx rollup -c && cp dist/manifest.json /tmp/manifest-run1.json
rm -rf dist && npx rollup -c
diff /tmp/manifest-run1.json dist/manifest.json
# Inspect CDN response headers across two regions
for ip in 104.16.0.0 104.17.0.0; do
curl -sI --resolve cdn.example.com:443:$ip https://cdn.example.com/assets/app-a1b2c3d4.js \
| grep -E 'cache-control|cf-cache-status|x-cache'
done
Failure modes and debugging
| Issue | Symptom | Root cause | Fix |
|---|---|---|---|
| Non-deterministic hashes between CI runs | diff run1.txt run2.txt is non-empty |
Plugin injects timestamp, random value, or absolute path | Audit banner/footer plugin options; strip non-deterministic values |
| Entry chunk has no hash | dist/assets/app.js instead of dist/assets/app-XXXXXXXX.js |
output.file used instead of output.dir, or entryFileNames missing [hash] |
Switch to output.dir; set entryFileNames: 'assets/[name]-[hash:8].js' |
| CSS missing from manifest | Manifest contains only JS entries | CSS plugin writes directly to disk, bypassing this.emitFile |
Switch to a plugin that uses this.emitFile or add a writeBundle hook to merge the CSS entry |
| Dynamic import 404 on CDN | Browser fetches assets/chunk-abc.js but gets 404 |
Hashed chunk filename changed between deploy and HTML reference | Deploy assets first, then deploy HTML/manifest; use atomic S3 sync |
| Hash changes when only a comment changes | Vendor chunk re-downloads after doc comment edit | Terser is not stripping comments before hashing | Add format: { comments: false } to @rollup/plugin-terser options |
| Manifest keys collide | Two assets produce the same key | Multiple chunks share a name property |
Use the isEntry flag to distinguish entries from generated chunks |
[hash] token in output.file throws |
Rollup config error at startup | output.file is a single-file mode; it does not support templates |
Use output.dir for multi-chunk builds |
Diagnosing non-determinism step by step
-
Run two builds and capture all output filenames:
npx rollup -c && find dist -type f | sort > files-run1.txt rm -rf dist && npx rollup -c && find dist -type f | sort > files-run2.txt diff files-run1.txt files-run2.txt -
If the diff is non-empty, identify which chunk changed. Compute the SHA-256 of the chunk content (not the filename):
sha256sum dist/assets/*.js | sort > hashes-run2.txt -
Compare the content hashes against run 1. The chunk whose content hash changed is the culprit.
-
Disassemble that chunk and look for strings that embed build metadata. Common offenders:
/* build: 2024-06-20T10:00:00Z */— a banner injected by a plugin’sbanneroption__BUILD_HASH__ = "abc123"— an environment variable injected via@rollup/plugin-replace- Absolute filesystem paths leaking through
sourceMappingURLcomments in non-production builds
-
Fix the non-determinism, then validate:
for i in 1 2 3; do rm -rf dist && npx rollup -c sha256sum dist/assets/*.js | sort >> all-runs.txt done sort all-runs.txt | uniq -d | wc -l # Should equal the total number of unique hash lines (all runs identical)
The hash cascade explained
Rollup computes hashes bottom-up through the dependency graph. If module A imports module B, and B changes, B’s chunk gets a new hash. Any chunk that statically imports B must embed a reference to the new filename, so the parent chunk’s content also changes, giving it a new hash. This cascades up to the entry point.
The cascade is why manualChunks matters for cache stability. An application entry chunk that statically imports a vendor bundle will get a new hash every time the vendor chunk changes — even if the application code did not change. Isolating vendors into their own manualChunks group breaks the cascade: the vendor chunk’s hash changes only when vendor code changes, and the application chunk only references the vendor chunk’s URL at runtime (via dynamic import), which does not contribute to the hash.
This is also why you should prefer import() (dynamic import) over static import for large optional features. Dynamic imports create a runtime fetch, not a compile-time embedding, so the parent chunk’s hash is independent of the lazy chunk’s content.
Edge cases and known issues
CSS assets emitted by @rollup/plugin-css-only
The assetFileNames pattern applies to all non-JS assets including CSS. If you use @rollup/plugin-css-only or a similar plugin, confirm it respects output.assetFileNames rather than writing directly to disk via fs.writeFile. Plugins that bypass this.emitFile will not appear in the bundle object and therefore won’t be captured by a manifest plugin.
Dynamic import chunks and the [hash] cascade
When a chunk imports a dynamically loaded chunk, Rollup computes the parent’s hash after the child’s. A change to a leaf module triggers hash changes up the dependency graph. This is correct and expected: if chunk B imports chunk A, and A changes, both A and B get new hashes. The manifest records the new filename for both. If your server inlines <script> tags from the manifest at request time, users get consistent references automatically.
output.format: 'cjs' and named exports
When targeting cjs format, Rollup wraps ES modules. The wrapper code itself contributes to the hash, so switching format between es and cjs changes hashes even for identical source. Pin the output format in your config and do not let it vary between CI runners.
Hash collisions at scale
The chance of a collision across N files with an 8-hex-character hash (32 bits) is approximately N²/2³³. For a project with 500 chunks, the birthday-paradox probability is around 3%. Use 12 characters (48 bits) for large monorepos. The [hash:12] suffix is a one-character change. The tradeoffs of truncation are covered in the safely truncating content hash length guide.
Performance impact
Hashing adds a small post-transform step for each chunk. Profiling on a 200-module application shows that hashCharacters: 'hex' is marginally slower than 'base64' (SHA-256 is SHA-256; the difference is only string encoding). Build time impact is under 1% in practice.
The manualChunks function runs once per module during the chunk assignment phase. Keep it fast: avoid synchronous filesystem calls or regex-heavy patterns on id strings that match thousands of node_modules paths.
Compared with esbuild fingerprinting, Rollup builds are 5–30x slower for large applications because Rollup performs full tree-shaking and scope analysis on every module. For library builds where final bundle size matters more than build speed, Rollup’s deeper analysis is worth the cost. For application builds with hundreds of modules, Vite’s hybrid approach (Rollup for production, esbuild for development) is often a better fit.
FAQ
Does [hash] in Rollup use SHA-256?
Rollup derives the hash from the content of the emitted chunk using an internal hashing step that is SHA-256 based. The 8-character output is a truncation of that hash, not a separate algorithm. See the page on safely truncating content hash length for the collision math behind choosing a length.
Why does my entry chunk have no hash even though I set entryFileNames?
entryFileNames defaults to [name].js — no [hash] token — when using Rollup’s output.file option instead of output.dir. The output.file option targets a single output file and does not support the [hash] template. Switch to output.dir with entryFileNames set explicitly.
Can I use output.assetFileNames as a function to apply different patterns for CSS vs images?
Yes. The function receives an AssetInfo object with a name field. Return different template strings based on the file extension:
assetFileNames(assetInfo) {
if (/\.(css)$/.test(assetInfo.name)) {
return 'styles/[name]-[hash:8][extname]';
}
return 'assets/[name]-[hash:8][extname]';
}
What happens to the hash when I only change a comment?
Rollup computes the hash after tree-shaking and minification. If you use @rollup/plugin-terser, comments are stripped before hashing. A comment-only change produces an identical hash after minification, which means no cache bust — correct behavior for a change that has no runtime effect.
Does manualChunks affect hash stability for unchanged vendor code?
Yes, in the intended direction. Without manualChunks, every time any application code changes, the single bundle chunk changes hash and every user must re-download all vendor code. With manualChunks isolating vendor modules, the vendor chunk’s hash stays stable across application-code-only releases. This is the primary cache-key architecture reason to use code splitting.
Pre-deploy checklist
entryFileNames,chunkFileNames, andassetFileNamesall include[hash:8](or longer)hashCharacters: 'hex'is set if you need human-readable hash comparisonmanualChunksisolates stable vendor dependencies into their own chunksdiffonmanifest.jsonmanifest.jsonlists every entry point, every split chunk, and every CSS/image assetCache-Control: public, max-age=31536000, immutablefor all hashed pathsmanifest.json, not hardcoded filenames
Related
- Generating a Rollup asset manifest for CDN deploys — full plugin implementation and CDN deploy integration
- Build tool & framework asset pipeline integration — parent reference covering all build tools
- Content hashing vs semantic versioning — when
[hash]beats[version]for cache correctness - Cache-key architecture — how hashed filenames map to CDN cache keys
- Vite asset pipeline configuration — Rollup-backed configuration surfaced through the Vite API