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 custom generateBundle/writeBundle hooks 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.

Where each bundler puts the hashing controls A four-row matrix comparing Rollup 4, Vite 5 and esbuild 0.20 on hash template control, tree-shaking depth, build speed and the hook available for manifest generation. Where each bundler puts the hashing controls Capability Rollup 4 Vite 5 esbuild 0.20 Hash template control Full Subset Plugin-side Tree-shaking depth Deepest Deep Shallow Build speed Slowest Fast in dev Fastest Manifest hook generateBundle Plugin API metafile
Rollup exposes the whole naming and hashing surface directly; Vite re-exports a subset of it and esbuild pushes the work into plugin code.

The row that decides most migrations is the last one. Rollup hands a plugin the complete bundle object at generateBundle time, with every chunk, every asset, the module list per chunk and the final hashed filename already resolved. esbuild gives you a metafile written after the build, which is enough for a manifest but not enough to influence naming. Vite sits in between: you can reach Rollup’s output options through build.rollupOptions, but framework plugins may overwrite what you set there.

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.

The catch-all vendor branch in the example above is deliberately naive: it puts every remaining dependency in one file, so a patch release of any of them invalidates all of them. Sizing those groups properly, and the shared-module hoisting that quietly moves code between them, are covered in manualChunks and stable chunk hashes.

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.

Rollup output naming pipeline Source modules enter Rollup, are assigned to chunks by manualChunks, transformed and hashed, then written to disk with hashed filenames. A generateBundle hook reads the bundle object and emits manifest.json alongside the assets. Source src/*.js manualChunks group modules Content hash [hash:8] hex Hashed assets dist/assets/*.js manifest.json generateBundle CDN immutable
Rollup 4 output pipeline: source modules are grouped by manualChunks, content-hashed, written as immutable assets, and mapped in manifest.json via the generateBundle hook.

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.

That verification matters more than it sounds. Rollup’s bundle object is built exclusively from files emitted through its own API. A plugin that reaches for fs.writeFileSync produces a file that exists on disk, ships in your deploy, and is completely invisible to every later hook — including the one that writes the manifest and the one that uploads to the CDN.

Two ways a plugin can produce a file A plugin's output takes one of two paths. Calling this.emitFile puts the file in the bundle object so it is hashed, tracked and listed in the manifest. Writing with fs.writeFileSync bypasses the bundle, so the file is missing from the manifest and 404s after deploy. Plugin output CSS, images, fonts this.emitFile() enters bundle object fs.writeFileSync() bypasses the bundle In manifest.json hashed and tracked Missing from map 404 after deploy Only files emitted through this.emitFile appear in the bundle object that generateBundle receives.
Audit every asset-producing plugin for its write path: the emit path is hashed and tracked, the direct-write path silently escapes the manifest.

The symptom is always the same — a stylesheet or font that loads locally, is present in dist/, and 404s in production because the deploy script only uploads what the manifest lists.

@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

  1. 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
  2. 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
  3. Compare the content hashes against run 1. The chunk whose content hash changed is the culprit.

  4. 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’s banner option
    • __BUILD_HASH__ = "abc123" — an environment variable injected via @rollup/plugin-replace
    • Absolute filesystem paths leaking through sourceMappingURL comments in non-production builds
  5. 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.

Hash cascade with and without manualChunks Side by side, the same single application edit is applied. Without manualChunks the vendor code shares the one bundle, so the whole 512 kB file rehashes. With manualChunks the application chunk rehashes at 62 kB and the vendor chunk keeps its filename, so it is served from cache. Without manualChunks With manualChunks app code edit vendor code unchanged bundle-2a71.js — new hash 512 kB re-downloaded app code edit vendor code unchanged app-2a71.js 62 kB new vendor-0b5f.js cache hit one file, one hash, no cache reuse churn confined to the edited chunk
The same edit, two chunk layouts: sharing a chunk with vendor code turns a 62 kB change into a 512 kB download.

The cascade also runs the other way when the import is dynamic. If the entry chunk reaches the vendor chunk through import(), Rollup writes the vendor filename into the entry as a string literal, so the entry still rehashes when the vendor hash moves. Rollup 4 emits that literal through its import-map machinery rather than inlining it in most output formats, but the safe assumption is that any statically resolvable filename reference is part of the parent’s content. Keep the reference count low by routing all lazy loads through a single small loader module.

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