Best Practices for Static Asset Naming Conventions

When a deployment ships and users immediately report stale JavaScript or broken CSS, the first question to ask is: does the filename change when and only when the file’s content changes? That single constraint separates naming conventions that work reliably from ones that fail in unpredictable ways on CDN edges.

This page covers the decision logic for choosing a naming strategy, how that choice couples directly to your HTTP caching headers, practical Webpack 5 and Vite 5 configurations, an Nginx location block that detects hashed vs non-hashed assets, and a shell one-liner to verify your build output in CI. For the broader relationship between filenames and HTTP headers, see Fingerprinting in HTTP Headers, the parent of this page.

The Core Decision: What Should Change the Filename?

There are four broad naming strategies in use across the ecosystem. They differ in what triggers a filename change and, as a consequence, how edge caches behave.

Strategy Example Filename changes when… CDN behaviour Rollback risk immutable compatible?
Content hash app.a3f8c1d2.js File bytes change Cache entry replaced only for changed files; unchanged files retain warm cache indefinitely Low — old hashes stay valid while HTML switches over Yes
Semantic version app.v2.4.1.js Maintainer increments version All assets named with the same version string expire simultaneously; multi-region races are common High — bumping the version invalidates the entire edge cache for that version string, even unchanged files No — version can be reused
Timestamp app.1718870400.js Build runs A new cache entry is created per build regardless of whether source changed High — unchanged files lose warm cache on every deploy No — timestamp is not content-derived
Bare name app.js Never CDN must revalidate on every request via ETag or Last-Modified; cache hit rate collapses without careful must-revalidate tuning Critical — silent stale delivery when CDN caches the old response without revalidation No

Query strings (e.g. app.js?v=2.4.1) look like a fifth option but are not shown as a distinct strategy here because they are functionally broken at the infrastructure layer. Cloudflare, Akamai, and Fastly all strip or ignore query strings from their cache key by default. What appears to be cache-busting in development silently degrades to stale serving on shared infrastructure.

Content hashing is the only strategy that satisfies all three requirements simultaneously: filenames are stable across identical builds, they change exactly when content changes, and they are safe to pair with Cache-Control: public, max-age=31536000, immutable. The content hashing vs semantic versioning comparison digs into the tradeoffs in more detail.

How Naming Conventions Couple to Cache-Control Headers

The immutable directive on a Cache-Control header tells browsers and shared caches that a resource will never change at this URL — revalidation requests are unnecessary for the lifetime of the max-age. This is a significant performance optimisation: it eliminates conditional GET requests for returning visitors and removes load from your CDN origin.

The directive is only safe when the filename is guaranteed to change whenever the content changes. If you serve app.v2.js with immutable and then re-deploy with different content under the same filename, every cached copy becomes permanently stale until its max-age expires — typically a year later. There is no mechanism for the server to push a correction; the client simply trusts the cache unconditionally.

Content-hashed filenames make immutable safe. Non-content-derived filenames (semantic versions, timestamps, bare names) are incompatible with it. The practical consequence is that those strategies require a validation round-trip on every request once the browser cache entry needs revalidation — or they require manual CDN purges after every deploy, which is operationally expensive and error-prone. See ETag vs immutable Cache-Control for assets for how to choose between those two for assets that cannot use content hashing.

HTML entrypoints (index.html, 200.html, etc.) must never receive immutable. They reference hashed assets by filename, so they must always return the latest version. Serve HTML with Cache-Control: no-cache or Cache-Control: no-store — browsers will revalidate or bypass the cache, pick up the latest asset references, and then cache the hashed assets themselves with full immutable headers.

Naming Strategy → Cache-Control Recommendation Content Hash app.a3f8c1d2.js Semantic Version app.v2.4.1.js Timestamp app.1718870400.js Bare Name app.js public, max-age=31536000, immutable public, max-age=86400, must-revalidate public, max-age=3600, must-revalidate no-cache (revalidate every request) Optimal — zero round-trips Revalidates on version bump Warm cache lost every build Stale risk without ETag
Each naming convention constrains which Cache-Control directives are safe. Only content-hashed filenames permit the immutable directive, which eliminates revalidation round-trips for returning visitors.

Hash Position and Format in the Filename

The position of the hash segment matters for CDN regex routing and for preserving file extension semantics.

Suffix before extension (recommended): app.[contenthash:8].js

This format keeps the base name readable, groups the hash visually before the extension, and allows CDN location blocks to detect hashed assets with a simple regex on the URL path: \.[a-f0-9]{8,16}\.. The extension is unambiguous as the last segment after the final dot.

Prefix (less common): a3f8c1d2.app.js

Alphabetically sorts hashes first in directory listings. Some teams prefer this for build artifact storage. CDN regex detection is more complex because the extension must still be matched at the end.

Middle (hyphen-separated): app-a3f8c1d2.js

Avoids dot delimiters entirely. This is common in Ruby on Rails asset pipelines and some static site generators. The tradeoff is that a regex to detect the fingerprint must distinguish between a normal filename containing a hyphen and one containing a fingerprint: \-[a-f0-9]{8,16}\..

Dot vs hyphen delimiter: Dots are the conventional separator in webpack, Vite, Rollup, and esbuild output templates. Hyphens are common in Rails, Sprockets, and some Hugo pipelines. Whichever you choose, apply it consistently — mixing delimiters across asset types breaks CDN routing rules that apply the immutable header.

Hash length: 8 hex characters is the standard for single-project builds. The collision probability at 8 chars (hexadecimal space of ~4.3 billion values) is negligible for any realistic asset count. For monorepos with hundreds of packages sharing a build cache, 12–16 characters adds a meaningful margin of safety. Webpack’s [contenthash:8] and Vite’s assetsDir hash are both 8 chars by default; the length can be tuned in the build config.

File Extension Preservation

The hash must not be placed in a position that obscures the file extension from servers and CDNs that infer MIME type from the URL path. Three cases require care:

Source maps: A map file for app.a3f8c1d2.js should be app.a3f8c1d2.js.map — not app.a3f8c1d2.map.js. Webpack’s devtool option and Vite’s build.sourcemap both produce the correct double-extension automatically. Verify by checking that the sourceMappingURL comment at the end of the JS file references a .js.map path, not a .map path.

CSS and its assets: When CSS bundles contain url() references to fonts or images, those inner assets receive their own hashed filenames. The CSS file itself must also be hashed, but the hash must not disrupt the .css extension that triggers text/css MIME type inference. styles.a3f8c1d2.css is correct; styles.a3f8c1d2 (no extension) will cause Content-Type: application/octet-stream on some servers.

Web fonts: Fonts (.woff2, .woff, .ttf) benefit from hashing because they are typically cached for very long periods and are easy to forget to invalidate. The extension must be preserved exactly — Inter-a3f8c1d2.woff2 rather than Inter-a3f8c1d2 — because browsers use the extension to validate the font format before loading.

Asset Type Conventions

Not all asset types follow the same fingerprinting rules. Applying the content-hash strategy uniformly to every file in your project is simpler and correct, but there are practical nuances:

JavaScript and CSS bundles: Always fingerprint. These change frequently and must be matched exactly to the HTML that references them. Stale JS or CSS causes runtime errors.

Fonts and images referenced in CSS: Fingerprint. When a font file changes (e.g., a new variable axis is added), the CSS bundle’s hash will also change if it contains the font URL — but only if the font’s own filename changed first. Without font fingerprinting, a CSS bundle can remain stable while the font it references has changed content at the same URL.

HTML: Never fingerprint. HTML is the entrypoint that references all other assets by their hashed filenames. Fingerprinting HTML breaks the loading chain. Use Cache-Control: no-cache instead.

Service workers: Never fingerprint (follow the HTML pattern). Browsers enforce their own update check on service worker files via the Service-Worker fetch mode; a hashed URL would prevent the browser from finding the updated version.

JSON data files or API response mocks: Fingerprint only if they are bundled as static assets. If they are served by a dynamic application layer, fingerprinting at the filename level is not applicable.

Webpack 5 and Vite 5 Configuration

The following configurations implement the recommended pattern for JS/CSS output hashing. Both use 8-character content hashes and preserve file extensions. For deeper Webpack configuration covering runtime chunk behaviour, see Webpack output hashing setup. For Vite-specific asset pipeline options, see Vite asset pipeline configuration.

// webpack.config.js — Webpack 5
module.exports = {
  mode: 'production',
  output: {
    filename: '[name].[contenthash:8].js',
    chunkFilename: '[name].[contenthash:8].chunk.js',
    // Preserves original extension for images, fonts, etc.
    assetModuleFilename: 'assets/[name].[contenthash:8][ext][query]',
    clean: true,
  },
  optimization: {
    // Stable module and chunk IDs: prevents hash churn when modules are added
    moduleIds: 'deterministic',
    chunkIds: 'deterministic',
    // Extract the webpack runtime into its own chunk so vendor hashes stay stable
    runtimeChunk: 'single',
  },
};
// vite.config.js — Vite 5
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    // 8-char content hash in JS chunk filenames
    rollupOptions: {
      output: {
        entryFileNames: 'assets/[name].[hash:8].js',
        chunkFileNames: 'assets/[name].[hash:8].chunk.js',
        assetFileNames: 'assets/[name].[hash:8][extname]',
      },
    },
    // Vite defaults: sourcemap false in prod; set true if you ship source maps
    sourcemap: false,
  },
});

Key points in both configs: [contenthash] (Webpack) and [hash] (Vite/Rollup) are content-derived. The [ext] / [extname] placeholder preserves the original file extension. The Webpack runtimeChunk: 'single' extraction prevents the runtime manifest from dirtying vendor chunk hashes whenever the app code changes. For cache key architecture considerations that extend beyond build config, the runtime chunk split also matters at the CDN level.

Nginx Location Block for Hashed vs Non-Hashed Assets

A Nginx server block can apply different Cache-Control headers based on whether the URL path contains a fingerprint. The regex \.[a-f0-9]{8,16}\. matches a dot-delimited hex segment of 8 to 16 characters — the fingerprint — anywhere in the URL before the file extension.

server {
    # Hashed static assets: long-lived immutable cache
    # Matches: /assets/app.a3f8c1d2.js, /fonts/Inter.1a2b3c4d.woff2, etc.
    location ~* \.[a-f0-9]{8,16}\.(js|css|woff2?|ttf|png|jpg|webp|svg|gif|ico)$ {
        root /var/www/html;
        add_header Cache-Control "public, max-age=31536000, immutable";
        add_header Vary "Accept-Encoding";
        access_log off;
        gzip_static on;
    }

    # Non-hashed assets (images, favicons, etc.): shorter TTL with revalidation
    location ~* \.(js|css|woff2?|ttf|png|jpg|webp|svg|gif|ico)$ {
        root /var/www/html;
        add_header Cache-Control "public, max-age=3600, must-revalidate";
        add_header Vary "Accept-Encoding";
    }

    # HTML: never cache
    location ~* \.html$ {
        root /var/www/html;
        add_header Cache-Control "no-cache";
    }
}

Note that Nginx evaluates location ~* blocks by longest match. The hashed-asset block must be declared before the generic extension block, or use a more-specific prefix path (e.g., location ~* ^/assets/.*\.[a-f0-9]{8,16}\.) to guarantee it takes priority.

Verification: Checking Output Filenames in CI

After a build, confirm that all JS and CSS output files match the expected fingerprint pattern before deploying. This one-liner fails with a non-zero exit code if any file does not contain an 8–16 character hex segment, making it suitable as a CI gate:

# Run from the project root after `npm run build` or `vite build`
# Exits 0 if all .js and .css files are fingerprinted; exits 1 if any are not
find dist/assets -type f \( -name "*.js" -o -name "*.css" \) \
  | grep -vE '\.[a-f0-9]{8,16}\.' \
  | tee /dev/stderr \
  | grep -qc . && { echo "ERROR: unfingerprinted assets found"; exit 1; } || echo "All assets fingerprinted"

The grep -vE inverts the fingerprint match — it lists files that do not match. If any appear, grep -qc . counts them and the && branch executes the failure message. On a clean build the list is empty, grep -qc . exits 1, the || branch fires, and the script exits 0.

For monorepos with 12- or 16-character hashes, update the quantifier: {8,16}{12,16} to avoid false positives from unrelated numeric strings in filenames.

When to Reconsider Content Hashing

Content hashing is the correct default for nearly all static web projects. There are a narrow set of situations where a different approach is justified:

Static site generators that cannot reference hashed filenames from templates: If the templating layer has no mechanism for resolving hashed asset paths (no manifest.json, no asset pipeline), bare filenames with short-TTL headers and ETags are better than introducing a broken hash reference. Fix the build pipeline first; do not work around it with bare names in production.

Assets that must be addressable at a stable URL by third parties: Favicon files (/favicon.ico, /apple-touch-icon.png) are fetched by browsers and crawlers at well-known paths. These cannot be fingerprinted. They should use short max-age values and ETags.

Server-side rendered pages that regenerate on every request: When HTML is generated dynamically and each response is unique, fingerprinting at the filename level is irrelevant. Cache the response at the edge by response content or session state, not by filename.

Legacy CDN configurations that use only filename (no path) as the cache key: Some older proxy configurations strip the full path and cache by filename only. In that case, a hash in the filename can cause accidental cache collisions between files with the same base name. Audit the cache key configuration before enabling content hashing.