Vite CSS Code-Split Hash Churn

Every deploy ships a new index-*.css filename. Nobody touched a stylesheet. The diff for the release contains three TypeScript files and a package-lock.json bump, and yet the CDN is handing every returning visitor a fresh stylesheet download, and the cache hit ratio graph for /assets/*.css has a step in it.

This is not a caching bug and it is not a Vite bug. It is the expected consequence of how build.cssCodeSplit attributes CSS to chunks, combined with the fact that a content hash is taken over the emitted bytes rather than over the source rules. Rearrange which chunk owns a stylesheet, or change the order in which rules are concatenated into it, and the bytes move even though the declarations are identical. The Vite asset pipeline configuration guide covers the naming templates; this page is about the specific case where the name changes and the styling does not.

First, Confirm the Rules Really Are Unchanged

Before changing configuration, prove the claim. Build the current commit and the previous one into separate directories, then compare the CSS after normalising whitespace and declaration order.

git stash --include-untracked
npx vite build --outDir dist_prev
git stash pop
npx vite build --outDir dist_curr

# Filenames differ; content may not.
find dist_prev -name '*.css' -exec sha256sum {} \; | sort
find dist_curr -name '*.css' -exec sha256sum {} \; | sort

# Compare the concatenated, sorted rule text rather than the raw file.
for d in dist_prev dist_curr; do
  cat "$d"/assets/*.css | tr '}' '}\n' | sed 's/^[[:space:]]*//' | sort > "/tmp/rules_${d}.txt"
done
diff /tmp/rules_dist_prev.txt /tmp/rules_dist_curr.txt && echo "SAME RULES, DIFFERENT BYTES"

If that last line prints, the declarations survived intact and only their arrangement changed. That narrows the cause to one of four things, all of which are addressable.

Cause, Symptom, Fix

Cause Symptom you observe Fix
CSS attributed to a different chunk after a refactor One stylesheet shrinks, another grows, both hashes rotate Pin the shared module with manualChunks so its CSS lands in a fixed chunk
Import order changed in an entry or barrel file Same rules, different order inside one file Import stylesheets in a fixed, alphabetised order from a single module
@import hoisting moved a block to the top A rule block jumps position; specificity may also change Replace @import with explicit module imports so ordering is explicit
PostCSS plugin order changed Prefixes or fallbacks appear in a new position Pin the plugin array order in postcss.config.js; never spread a config object
browserslist resolved to a different target set Autoprefixer emits more or fewer vendor prefixes Commit an explicit browserslist key and pin caniuse-lite via the lockfile
Minifier or cssMinify engine changed Every CSS hash rotates at once Pin the toolchain; use npm ci, never npm install, in CI
assetFileNames pattern references [name] of a renamed chunk Filename stem changes even though hash would not Route CSS through a fixed directory and stem, or accept the rename

The last row is worth reading twice. A rotating hash and a rotating name are different failures with the same visible effect, and the fix is different for each.

How cssCodeSplit Attributes CSS

build.cssCodeSplit defaults to true. Under that default Vite does not produce one global stylesheet. It walks the module graph, and for every chunk that transitively imports CSS it emits a companion stylesheet containing the rules reachable from that chunk. The manifest entry for the chunk lists the result in its css array.

The rule that surprises people: CSS is attached to the chunk that imports it, not to the file that declares it, and a stylesheet reachable from two chunks is emitted into both.

How CSS is attributed to chunks Home.tsx and Dashboard.tsx land in separate chunks; shared/Button.tsx is reachable from both, so its rules are emitted into both chunk stylesheets, which each carry their own content hash. How CSS is attributed to chunks Home.tsx imports home.css Dashboard.tsx imports dash.css shared/Button.tsx imports button.css Home chunk index-a1b2c3d4.js Dashboard chunk Dashboard-c9d0e1f2.js index-3a4b5c6d.css home + button rules Dashboard-7f10bb92.css dash + button rules A shared component's rules are emitted into every chunk that reaches it
CSS follows the importing chunk, so moving a shared import between routes rewrites two stylesheets at once.

Now picture the refactor that causes the churn. Someone moves shared/Button.tsx out of the dashboard route and into a layout component that only the home route renders. The dashboard stylesheet loses the button rules and shrinks; the home stylesheet keeps them but they now appear at a different offset because the layout module sorts before the page module in the graph. Two files, two new hashes, zero visual change.

Import Order and @import Hoisting

Within one emitted stylesheet, rule order is a direct function of module evaluation order. Vite concatenates each imported stylesheet’s rules in the order the bundler visits them, so changing which module imports first changes the byte layout of the output.

Same rules, different emitted byte order Build A concatenates button, layout and home rules in that order; Build B concatenates layout, button and home. The declarations are identical but the byte sequence differs, so the two stylesheets carry different content hashes. Same rules, different emitted byte order Build A output button.css rules layout.css rules home.css rules Build B output layout.css rules button.css rules home.css rules app-3a4b5c6d.css app-71c9d0aa.css Identical declarations, different order, different hash
The hash is over bytes, and byte order is decided by module evaluation order.

@import makes this worse because CSS semantics require @import statements to precede every other rule in a stylesheet. When Vite inlines an @imported file, the imported block is hoisted above the importing file’s own rules. Add one @import at the bottom of a file that already had two and the whole block above it shifts down. Nothing else moved; every byte after the insertion point did.

The stable pattern is to stop relying on @import for composition and to import stylesheets from JavaScript in a deliberate, fixed order — one module whose only job is ordering:

// src/styles/index.js — the single place stylesheet order is decided.
// Keep these lines alphabetised; never reorder to "fix" specificity.
import './reset.css';
import './tokens.css';
import './layout.css';
import './components.css';
import './utilities.css';

Import that module once from your entry and never import a bare .css file anywhere else. Specificity conflicts then get solved with selectors, not with ordering — which is what makes the order safe to freeze.

PostCSS Ordering and browserslist Drift

Vite runs PostCSS before it hashes anything, so any plugin that rewrites declarations is part of the hash input. Two failure modes recur.

The first is plugin order. PostCSS applies plugins as an ordered array. Autoprefixer inserts vendor-prefixed declarations immediately before the standard one it is prefixing; a nesting plugin that runs after autoprefixer will see and reflow those generated declarations, producing different output than if it had run before. Any config that builds the plugin list dynamically — spreading a shared object, conditionally pushing based on process.env — can produce a different array on different machines.

// postcss.config.js — an explicit, order-stable array.
// Do NOT spread a shared object here; object key order is not a contract.
import postcssNesting from 'postcss-nesting';
import autoprefixer from 'autoprefixer';

export default {
  plugins: [
    postcssNesting(),
    autoprefixer(),
  ],
};

The second is browserslist drift. Autoprefixer decides which prefixes to emit from the resolved browser list, and that list is computed from your query plus the installed caniuse-lite data. Leave the query implicit and it falls back to defaults; leave caniuse-lite floating and a npm install six weeks later resolves a newer dataset, drops two browsers out of the > 0.5% bucket, and removes a handful of -webkit- lines from your output. Every CSS hash rotates and no source file changed.

{
  "browserslist": [
    "chrome >= 111",
    "firefox >= 111",
    "safari >= 16.4",
    "edge >= 111"
  ]
}

Explicit version floors make the query independent of usage statistics. Combine that with npm ci so caniuse-lite comes from the lockfile, and the prefix set becomes a property of your repository rather than of the day you ran the build. This is the CSS-specific instance of a broader pattern — see debugging phantom hash changes in CI for the general diagnostic procedure.

assetFileNames and chunkFileNames Interaction

CSS is an asset, so its filename comes from assetFileNames. But the [name] token for a code-split stylesheet is inherited from the chunk that owns it, which comes from chunkFileNames and, upstream of that, from manualChunks. The two templates are therefore coupled: rename a chunk and you rename its stylesheet, even if the CSS bytes are byte-for-byte identical and the [hash] token does not move.

That coupling is useful. If you give the chunk a stable name, its stylesheet gets a stable stem, and the only thing that can change is the hash — which is exactly the signal you want. If you leave chunk naming to Rollup’s inference, the stem tracks whatever module happened to be picked as the chunk’s facade, and that can change under a refactor.

// Stable stems: the CSS for the 'shared-ui' chunk is always shared-ui-<hash>.css
export default {
  build: {
    rollupOptions: {
      output: {
        chunkFileNames: 'assets/js/[name]-[hash:8].js',
        assetFileNames: 'assets/css/[name]-[hash:8][extname]',
      },
    },
  },
};

The same reasoning applies one level down in the bundler; Rollup asset optimization covers how chunk naming and asset emission are wired together underneath Vite.

The Blunt Fix: cssCodeSplit: false

Setting build.cssCodeSplit to false concatenates every stylesheet the build reaches into a single hashed file, injected as one <link> in the HTML. Import order still determines the byte layout, but there is now exactly one file, so cross-chunk reattribution cannot happen at all. The failure mode where moving a component rewrites two stylesheets simply disappears.

What you pay for it is granularity.

cssCodeSplit trade-off With cssCodeSplit true Vite emits one stylesheet per chunk, a route change invalidates only that route's CSS, but hashes are sensitive to import order. With it false a single stylesheet is emitted, any change invalidates all of it, but hashes are stable across reorders. cssCodeSplit trade-off cssCodeSplit: true cssCodeSplit: false Files emitted one per chunk one style.css Route change cost only that route's CSS entire stylesheet Hash stability sensitive to import order stable across reorders Splitting trades hash stability for smaller invalidations
One file cannot be reattributed — but one file also cannot be partially invalidated.

With a single stylesheet, a one-declaration change to a rarely-visited admin screen invalidates the stylesheet every visitor holds. On a site with 400 kB of CSS and weekly releases, that is 400 kB re-downloaded per user per week regardless of which route they use. The granularity argument is the same one that governs chunk splitting generally, and it is developed in cache key architecture.

Use cssCodeSplit: false when total CSS is small (roughly under 60 kB compressed), when the app is a single route, or when you need render-blocking CSS to be one request. Keep it true and fix the attribution otherwise.

The Precise Fix: manualChunks for Stable Attribution

The targeted answer is to stop letting the graph decide which chunk owns shared CSS. Assign shared modules to a named chunk explicitly, and their stylesheet is emitted into that named chunk’s companion file no matter which route imports them.

// vite.config.js — complete, runnable.
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  build: {
    outDir: 'dist',
    emptyOutDir: true,
    manifest: true,

    // Keep per-chunk stylesheets; the churn is fixed by attribution, not by
    // collapsing everything into one file.
    cssCodeSplit: true,

    // Pin the CSS minifier explicitly so a change to build.minify cannot
    // silently rewrite every stylesheet.
    cssMinify: 'esbuild',

    rollupOptions: {
      output: {
        entryFileNames: 'assets/js/[name]-[hash:8].js',
        chunkFileNames: 'assets/js/[name]-[hash:8].js',
        assetFileNames: 'assets/css/[name]-[hash:8][extname]',

        // Shared UI and its stylesheets always land in 'shared-ui'.
        // Third-party CSS always lands in 'vendor'.
        // Everything else follows the route that imports it.
        manualChunks(id) {
          if (id.includes('/node_modules/')) {
            return 'vendor';
          }
          if (id.includes('/src/shared/') || id.includes('/src/styles/')) {
            return 'shared-ui';
          }
        },
      },
    },
  },
});

Three things are now fixed by construction. Shared component styles are emitted once into shared-ui-<hash>.css rather than duplicated across route stylesheets. Moving a component between routes no longer moves its rules, because the chunk assignment is a path predicate, not a graph inference. And third-party CSS is isolated from application CSS, so a dependency bump cannot rotate the stylesheet your own edits live in.

The residual risk is ordering inside shared-ui. Keep the single ordering module described above and that risk goes away too.

Verification: Build Twice, Diff Only the CSS

The check that matters is not “does the build succeed” but “do two builds of the same commit produce the same stylesheets”. Run it in CI on every pull request.

#!/usr/bin/env bash
set -euo pipefail

rm -rf dist_a dist_b
npx vite build --outDir dist_a
npx vite build --outDir dist_b

# 1. Filenames must match exactly.
find dist_a -name '*.css' -printf '%P\n' | sort > /tmp/css_a.txt
find dist_b -name '*.css' -printf '%P\n' | sort > /tmp/css_b.txt
if ! diff -u /tmp/css_a.txt /tmp/css_b.txt; then
  echo "FAIL: CSS filenames differ between two builds of the same commit"
  exit 1
fi

# 2. Bytes must match too, in case a name is stable but content is not.
( cd dist_a && find . -name '*.css' -exec sha256sum {} \; | sort ) > /tmp/css_sum_a.txt
( cd dist_b && find . -name '*.css' -exec sha256sum {} \; | sort ) > /tmp/css_sum_b.txt
if ! diff -u /tmp/css_sum_a.txt /tmp/css_sum_b.txt; then
  echo "FAIL: CSS bytes differ between two builds of the same commit"
  exit 1
fi

echo "PASS: CSS output is reproducible"
echo "Stylesheets emitted:"
jq -r '.[] | select(.css != null) | .file + " -> " + (.css | join(", "))' \
  dist_a/.vite/manifest.json

To catch churn between commits rather than within one, keep the previous release’s CSS filename list as a CI artifact and compare against it. A release that rotates a stylesheet hash should have a stylesheet change in its diff; if it does not, one of the causes in the table above is active.

# Compare this build's stylesheets against the previous release's list.
jq -r '.[].css[]?' dist_a/.vite/manifest.json | sort -u > /tmp/css_now.txt
comm -13 /tmp/css_prev_release.txt /tmp/css_now.txt \
  | sed 's/^/ROTATED: /'

Any line printed by that comm is a stylesheet whose URL changed since the last release. Cross-check each against git diff --stat HEAD~1 -- '*.css'.

When to Reconsider

Chasing perfect CSS hash stability is not always worth it. If your total stylesheet payload is under about 20 kB compressed, an occasional full re-download costs a returning user less than the engineering time spent pinning chunk boundaries — take cssCodeSplit: false and move on. If you ship several times a day, the stylesheet was going to be re-fetched frequently anyway, and stability buys little.

The calculation flips when CSS is large, releases are infrequent, or a meaningful share of your traffic is on slow connections. There, a stylesheet that rotates for no reason is pure waste, and the manualChunks pinning above pays for itself in the first week. It also flips when you generate integrity attributes: a stylesheet URL that changes without a content change forces a needless integrity recomputation across every template that references it.

One thing never to do: freeze the hash by removing the [hash] token from assetFileNames. A stable name over changing bytes is not a fix — it is a cache-poisoning bug, and it converts a harmless extra download into users running mismatched CSS for the length of your TTL.

Frequently Asked Questions

Does cssCodeSplit: false disable CSS minification or PostCSS?

No. It changes only how many files are emitted. PostCSS still runs, cssMinify still applies, and the single output file still carries a content hash from assetFileNames. The only behavioural differences are the number of <link> tags injected into the HTML and the fact that route-level lazy loading no longer applies to styles.

Why does my CSS hash change when I add a dynamic import that has no styles?

Adding a dynamic import creates a new chunk boundary, and Rollup may rebalance which modules land in which chunk to avoid duplication. If a module carrying CSS moves across that new boundary, its rules move with it. Pinning the CSS-carrying modules with manualChunks prevents the rebalancing from touching them.

Can I keep one stylesheet per route but stop duplicating shared rules into each one?

Yes — that is exactly what assigning shared modules to a named chunk does. Once shared-ui is a real chunk, its stylesheet is emitted once and linked by every route that imports the chunk, instead of being inlined into each route’s stylesheet. The cost is one extra request on the first route a user lands on.

Is [hash] on a CSS file computed before or after minification?

After. The hash is taken over the bytes Vite writes to disk, which are post-PostCSS and post-minification. That is why switching cssMinify between esbuild and lightningcss rotates every stylesheet hash at once even with no source change.