Hashing Images, Fonts, and Media

JavaScript and CSS get content hashes for free from every modern bundler, so the stale assets that actually reach production are almost always images, fonts, icons, and video — the files nobody configured a hashing rule for.

The failure is quiet. A designer swaps hero.jpg for a re-cropped version, the deploy succeeds, and half your users keep seeing the old crop for a year because the URL never changed and the CDN was told the file was immutable. Nothing in the build log flags it. This guide covers which non-code assets a bundler can hash on its own, which ones it never sees, how the inline threshold silently couples an image’s bytes to a JavaScript chunk’s hash, and what to do with the files that must keep a fixed URL.

When to Use This Approach

Bundler-driven hashing is not the only option for binary assets. The alternative — hashing at upload time and storing the resulting URL in a database or CMS — is genuinely better for some asset classes. Pick per asset class, not per project.

Asset class Bundler-driven hashing Upload-time hashing Why
Icons, logos, UI sprites Preferred Overkill Small, versioned with the code, referenced from components
Web fonts Preferred Rarely @font-face lives in CSS the bundler already rewrites
Hero and marketing images Preferred Acceptable Only if the image ships in the repo; CMS images cannot be imported
Responsive variant sets Preferred, with a generator plugin Acceptable Every candidate needs its own hash either way
User-uploaded images Not possible Required Content is unknown at build time
Video and audio over ~10 MB Avoid Required Bundlers read the whole file into memory to hash it
HLS/DASH segments Never Required Segment naming is fixed by the packager
og:image, favicon.ico, robots.txt Not possible Not applicable Third parties cache the literal URL; it must not move

The rule of thumb: if the reference to the asset is a JavaScript import or a url() in CSS, the bundler can hash it. If the reference is a string in HTML, a database row, or an external crawler’s memory, it cannot, and you need a different mechanism.

Routing an asset to the right pipeline A root question about how an asset is referenced branches to three destinations: the public directory with a short TTL, hashed asset-module output, or object storage with an upload-time hash. Routing an asset to the right pipeline How is the asset referenced, and how big? literal URL string import or url() streams, over 10 MB public/ escape hatch stable URL, no hash max-age=300, revalidate Asset module output hashed filename immutable, one year Object storage hash computed at upload playlist stays mutable og:image, favicon, robots icons, fonts, hero images video, audio, HLS segments
Three destinations, decided by how the asset is referenced and how large it is — not by its file extension.

Prerequisites

  • Webpack 5.0+ for asset modules (type: 'asset/resource'). Webpack 4’s file-loader and url-loader are deprecated and produce different hash semantics; the examples below assume 5.90 or newer.
  • Vite 5.0+ / Rollup 4.0+ for the [hash:8] length specifier in assetFileNames. Rollup 3 accepted [hash] only at full length.
  • Node 18.17+, because crypto.hash fast paths used by both bundlers for large binaries changed behaviour in earlier 18.x patch releases.
  • sharp 0.33+ if you are generating derived image variants at build time. It links against libvips; pin the exact version so re-encoding is byte-reproducible across CI runners.
  • A CDN or origin that can apply different Cache-Control values by path prefix. Hashed and unhashed assets must not share a caching rule.

All examples use 8 hex characters of hash. That is 32 bits, comfortable up to roughly 9,000 emitted files. A monorepo emitting thousands of image variants across several apps into one bucket should move to 12 or 16 characters; every example marks where to change the length.

What the Bundler Actually Sees

A bundler hashes what is in its module graph and nothing else. There are exactly two intake paths, and the difference between them is the single most common source of stale binary assets.

Two ways a non-code asset reaches dist Assets imported by code pass through an asset module and receive a content hash. Assets referenced only by a literal URL are copied from the public directory byte for byte and keep a fixed name. Two ways an asset reaches dist/ Path A — inside the module graph Imported by code import u from ./hero.png or url() inside CSS Asset module inline or emit, by size content hash applied Hashed output img/hero.9f2a71c4.png immutable, one year Path B — the public/ escape hatch Referenced by URL src=/img/og.png never imported Verbatim copy byte-for-byte passthrough no hash, no rewrite Fixed URL /img/og.png must stay revalidated Only Path A earns immutable caching. Path B keeps a stable name and needs a short TTL.
Path A is hashed and cached forever; Path B keeps its name forever and must therefore be revalidated.

Path A — the module graph

Anything reachable from an entry point through import, require, new URL('./x.png', import.meta.url), a CSS url(), or a framework-specific asset import is a node in the graph. The bundler reads its bytes, computes a digest, renames the emitted file, and — critically — rewrites every reference to it. That rewrite is the whole value proposition: the import expression evaluates to the hashed URL string, and the CSS url() is replaced with the hashed path.

Path B — the escape hatch

Files placed in public/ (Vite, Next.js, Astro) or copied by CopyWebpackPlugin are not parsed. They are copied to the output root with their names intact. This is not a bug; it is the only way to guarantee a fixed URL for favicon.ico, og:image targets, site.webmanifest, ads.txt, and anything a third-party crawler has memorised. The cost is that these files can never be marked immutable — see immutable and TTL tuning for the TTL values that make an unhashed URL safe.

Asset Type Reference

Asset type Who applies the hash Where the reference lives Failure mode when it is missed
JS entry / chunk Bundler ([contenthash]) HTML <script src> Rare — hashing is on by default
CSS bundle Bundler ([contenthash]) HTML <link href> Rare — hashing is on by default
Imported raster image Bundler asset module JS variable or CSS url() Silent stale image for the full TTL
Image in public/ Nobody Literal src in HTML/JSX Stale forever if marked immutable
srcset variants Build-time generator plugin srcset attribute per candidate One candidate updates, others do not
@font-face file Bundler asset module via CSS src: url() in the stylesheet Old glyphs, or a 404 after a rename
Font in public/ Nobody Hand-written @font-face Font swap flashes on every deploy
SVG as CSS background Bundler asset module background-image: url() Stale icon inside a cached CSS file
SVG in <img src> Nobody unless imported HTML attribute Stale icon, no build warning
Favicon / manifest icons Nobody <link rel="icon">, manifest JSON Browsers and OS caches hold them for weeks
Video / audio in bundler Bundler asset module <source src> after import Build memory spike; hash cost per byte
Video in object storage Upload script Database row or CMS field Old cut plays after a re-encode
HLS / DASH playlist Nobody, deliberately <video src> or player config Player pins to old segment list
HLS / DASH segments Packager output name Playlist file Mid-stream 404s if renamed
Poster image Bundler if imported <video poster> Old thumbnail over new video

Two rows in that table deserve emphasis. The playlist row is the one people get backwards: a manifest that lists segments must stay revalidatable, while the segments it names are immutable — the same inversion described in cache key architecture. And the srcset row is where partial updates hide, because a srcset with five candidates is five independent cache entries.

Configuration Reference

Key Type Default Effect
output.assetModuleFilename (Webpack) Template string [hash][ext][query] Global naming for every emitted asset module
Rule.type (Webpack) asset / asset/resource / asset/inline / asset/source none asset picks inline vs emit by size; asset/resource always emits
Rule.parser.dataUrlCondition.maxSize Integer bytes 8192 Files at or below this become data URIs
Rule.generator.filename Template string inherits assetModuleFilename Per-rule naming; overrides the global template
Rule.generator.publicPath String or function inherits output.publicPath Serve one asset class from a different host
output.publicPath (Webpack) String or 'auto' 'auto' Prefix baked into every rewritten reference
assetsInclude (Vite) Glob array or picomatch image/media/font defaults Adds extensions Vite should treat as assets, not modules
build.assetsInlineLimit (Vite) Integer bytes or function 4096 Inline threshold; a function gives per-file control
build.assetsDir (Vite) String assets Subdirectory under outDir for emitted assets
rollupOptions.output.assetFileNames String or function [name].[hash][extname] Per-asset naming; where [hash:8] goes
publicDir (Vite) String or false public The unhashed passthrough directory
base (Vite) String / URL prefix written into rewritten references

Implementation

Step 1 — Route every binary extension through an asset module

Webpack 5 ships four asset module types. The only ones you want for binaries are asset (size-conditional) and asset/resource (always emit). Set a global template, then override per class so the output tree stays browsable.

// webpack.config.js — Webpack 5.90+
const path = require('path');

module.exports = {
  mode: 'production',
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/static/',
    filename: 'js/[name].[contenthash:8].js',
    // Fallback for any asset module without its own generator.filename.
    // Change :8 to :12 or :16 for a monorepo emitting thousands of files.
    assetModuleFilename: 'media/[name].[contenthash:8][ext][query]',
    clean: true,
  },
  module: {
    rules: [
      {
        // Size-conditional: tiny images inline, everything else gets a file.
        test: /\.(png|jpe?g|gif|webp|avif)$/i,
        type: 'asset',
        parser: { dataUrlCondition: { maxSize: 4 * 1024 } },
        generator: { filename: 'img/[name].[contenthash:8][ext]' },
      },
      {
        // SVG always emitted: inlining it as base64 defeats gzip badly.
        test: /\.svg$/i,
        type: 'asset/resource',
        generator: { filename: 'img/[name].[contenthash:8][ext]' },
      },
      {
        // Fonts must never inline — a data URI blocks the CSS parse.
        test: /\.(woff2?|ttf|otf|eot)$/i,
        type: 'asset/resource',
        generator: { filename: 'fonts/[name].[contenthash:8][ext]' },
      },
      {
        // Short media only. Anything over a few MB belongs in object storage.
        test: /\.(mp4|webm|mp3|m4a|ogg)$/i,
        type: 'asset/resource',
        generator: { filename: 'media/[name].[contenthash:8][ext]' },
      },
      {
        test: /\.css$/i,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
};

The equivalent in Vite 5 is a single assetFileNames function plus an explicit assetsInclude for anything outside the built-in list. The Vite defaults already cover common images, fonts, and media; assetsInclude is for rarer formats such as .lottie, .vtt, or .glb.

// vite.config.js — Vite 5.2+
import { defineConfig } from 'vite';
import path from 'node:path';

const FONTS = new Set(['woff', 'woff2', 'ttf', 'otf']);
const IMAGES = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg']);
const MEDIA = new Set(['mp4', 'webm', 'mp3', 'm4a', 'ogg', 'vtt']);

export default defineConfig({
  base: '/static/',
  publicDir: 'public',
  // Extensions Vite does not already treat as assets.
  assetsInclude: ['**/*.lottie', '**/*.glb', '**/*.vtt'],
  build: {
    outDir: 'dist',
    assetsDir: 'assets',
    // Per-file control: never inline a font, inline small images only.
    assetsInlineLimit: (filePath, content) => {
      const ext = path.extname(filePath).slice(1).toLowerCase();
      if (FONTS.has(ext) || MEDIA.has(ext)) return false;
      return content.length <= 4096;
    },
    rollupOptions: {
      output: {
        entryFileNames: 'js/[name].[hash:8].js',
        chunkFileNames: 'js/[name].[hash:8].js',
        assetFileNames: (assetInfo) => {
          const name = assetInfo.names?.[0] ?? assetInfo.name ?? '';
          const ext = path.extname(name).slice(1).toLowerCase();
          // Change :8 to :12 for monorepo-scale output.
          if (FONTS.has(ext)) return 'fonts/[name].[hash:8][extname]';
          if (IMAGES.has(ext)) return 'img/[name].[hash:8][extname]';
          if (MEDIA.has(ext)) return 'media/[name].[hash:8][extname]';
          return 'assets/[name].[hash:8][extname]';
        },
      },
    },
  },
});

Returning false from assetsInlineLimit forces emission; returning true forces inlining. Returning undefined falls back to Vite’s default size comparison. The broader Vite pipeline options are covered in the Vite asset pipeline guide, and the Webpack side in the Webpack output hashing guide.

Step 2 — Understand what the inline threshold does to the parent’s hash

The inline threshold is not a size optimisation with local consequences. When a file is inlined, its bytes are copied into the importing chunk or stylesheet. That chunk’s content hash now depends on the image’s bytes. Change one pixel in a 3 KB icon and the JavaScript chunk that imports it gets a new hash, invalidating a file that may be two orders of magnitude larger.

Inline threshold and parent hash coupling Below the inline limit an asset becomes a data URI inside its parent chunk and couples its bytes to the parent hash. Above the limit the asset is emitted as its own file and the parent stores only a URL string. Crossing the inline threshold assetsInlineLimit = 4096 bytes Inlined as a data: URI no separate file, no hash of its own Emitted as a file with its own hash the parent stores only a URL string Parent hash churns edit one pixel and the chunk that inlines it gets a new hash whole chunk re-downloaded Parent hash stable edit the image and only the image filename changes downstream the JS chunk keeps its hash
Below the limit, an image's bytes become part of a JavaScript chunk's identity; above it, the two are independent.

Base64 also inflates the payload by roughly 33% before compression, and inlined bytes cannot be cached separately or fetched in parallel. A defensible default is a 4 KB limit for images and hard false for fonts and media. For icons that appear on every page and never change, inlining is a genuine win — one fewer request, and the churn risk is zero because the file is frozen.

Step 3 — Give every derived variant its own hash

A resized, re-encoded, or re-compressed image is a different file. It must not inherit the source file’s hash, and it must not be named after the source’s dimensions alone — hero-480.webp collides with the next re-encode at a different quality setting.

Derived variants carry independent hashes A single source master image is re-encoded into four variants at different widths and formats; each variant is a distinct byte stream and receives its own eight-character content hash. One master, four independent hashes Source master hero.png, 3.2 MB Each re-encode is a different byte stream, so each carries its own content hash. hero-480.a91c3f04.avif 480w, AVIF quality 50, 41 KB hero-480.5b2e77a1.webp 480w, WebP quality 75, 63 KB hero-960.c40de8b2.avif 960w, AVIF quality 50, 118 KB hero-960.7f13a9dd.jpg 960w, JPEG quality 80, 214 KB
Four variants of one photograph: four files, four hashes, four independent cache entries.

Generate the variants inside the build so the hash is computed on the exact bytes you ship, not on the source. The script below writes each variant and emits a manifest fragment.

// scripts/build-image-variants.mjs — run before the bundler
import { createHash } from 'node:crypto';
import { mkdir, readdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';

const SRC = 'src/images';
const OUT = 'src/generated/images';
const WIDTHS = [480, 960, 1440];
const ENCODERS = [
  ['avif', (p) => p.avif({ quality: 50, effort: 4 })],
  ['webp', (p) => p.webp({ quality: 75 })],
  ['jpg', (p) => p.jpeg({ quality: 80, mozjpeg: true })],
];

// Change 8 to 12 or 16 for monorepo-scale variant counts.
const HASH_LEN = 8;
const digest = (buf) =>
  createHash('sha256').update(buf).digest('hex').slice(0, HASH_LEN);

await mkdir(OUT, { recursive: true });
const manifest = {};

for (const file of await readdir(SRC)) {
  if (!/\.(png|jpe?g|tiff?)$/i.test(file)) continue;
  const base = path.basename(file, path.extname(file));
  manifest[base] = [];

  for (const width of WIDTHS) {
    for (const [ext, encode] of ENCODERS) {
      const buf = await encode(
        sharp(path.join(SRC, file)).resize({ width, withoutEnlargement: true })
      ).toBuffer();

      const name = `${base}-${width}.${digest(buf)}.${ext}`;
      await writeFile(path.join(OUT, name), buf);
      manifest[base].push({ width, ext, file: name, bytes: buf.length });
    }
  }
}

await writeFile(
  path.join(OUT, 'variants.json'),
  JSON.stringify(manifest, null, 2)
);
console.log(`wrote variants for ${Object.keys(manifest).length} images`);

Because the hash is taken over the encoded output, changing quality: 75 to quality: 80 produces new filenames automatically. Consuming that manifest to build a correct srcset is covered in responsive image srcsets.

Step 4 — Let the CSS pipeline rewrite @font-face

Fonts are the one asset class where the reference lives inside another hashed file. Import the stylesheet from JavaScript (or let the framework do it) and the CSS loader rewrites each src: url() to the emitted, hashed font filename. Do not hand-write font URLs pointing at public/.

/* src/styles/fonts.css — the bundler rewrites both url() values */
@font-face {
  font-family: 'Inter Var';
  src: url('../fonts/inter-var.woff2') format('woff2-variations');
  font-weight: 100 900;
  font-display: swap;
  font-style: normal;
}

@font-face {
  font-family: 'Inter Var';
  src: url('../fonts/inter-var-italic.woff2') format('woff2-variations');
  font-weight: 100 900;
  font-display: swap;
  font-style: italic;
}

After the build, both URLs point at fonts/inter-var.<hash>.woff2. The details — relative versus absolute publicPath, the CORS requirement, and preload links that must name the hashed URL — are in hashing web fonts.

Step 5 — Keep the escape hatch small and short-lived

Everything left in public/ needs a deliberate caching rule. Serve it with a TTL measured in minutes, not years, and never let it inherit the immutable rule meant for hashed paths.

# Hashed output — safe to freeze
location ~* ^/static/(img|fonts|media|js)/.+\.[0-9a-f]{8}\.[a-z0-9]+$ {
    add_header Cache-Control "public, max-age=31536000, immutable" always;
    add_header Vary "Accept-Encoding" always;
    access_log off;
}

# public/ passthrough — fixed URLs, must stay revalidatable
location ~* ^/(favicon\.ico|og-image\.png|site\.webmanifest|apple-touch-icon\.png)$ {
    add_header Cache-Control "public, max-age=300, stale-while-revalidate=86400" always;
}

# Everything else under /static that is NOT hash-shaped
location /static/ {
    add_header Cache-Control "public, max-age=600, must-revalidate" always;
}

The regex \.[0-9a-f]{8}\. is doing real work here: it matches only hash-shaped filenames, so an unhashed file that accidentally lands in /static/img/ falls through to the 10-minute rule instead of being frozen for a year. Widen the {8} to {8,16} if you use longer hashes.

Verification

# 1. List every emitted asset that is NOT hash-shaped. Expect only public/ files.
find dist -type f \
  \( -name '*.png' -o -name '*.jpg' -o -name '*.webp' -o -name '*.avif' \
     -o -name '*.svg' -o -name '*.woff2' -o -name '*.mp4' \) \
  | grep -Ev '\.[0-9a-f]{8}\.' \
  | sort

# 2. Confirm the hash in each filename matches the file's actual content.
for f in dist/static/img/*.*.*; do
  claimed=$(basename "$f" | awk -F. '{print $(NF-1)}')
  actual=$(sha256sum "$f" | cut -c1-8)
  [ "$claimed" = "$actual" ] || echo "MISMATCH $f claimed=$claimed actual=$actual"
done

# 3. Prove a source edit moves only the image, not the JS chunk.
sha256sum dist/static/js/*.js > /tmp/js-before.txt
printf '\n/* touch */\n' >> src/images/hero-caption.txt
npm run build
sha256sum dist/static/js/*.js > /tmp/js-after.txt
diff /tmp/js-before.txt /tmp/js-after.txt && echo "JS unchanged — good"

# 4. Check the live headers on a hashed image and an unhashed one.
curl -sI https://example.com/static/img/hero.9f2a71c4.avif \
  | grep -Ei 'cache-control|content-type|vary'
curl -sI https://example.com/favicon.ico | grep -Ei 'cache-control'

# 5. Confirm nothing inlined that should not have. Data URIs in CSS are visible:
grep -o 'data:image/[a-z+]*;base64' dist/static/*.css | sort | uniq -c

# 6. Confirm range support on media before you mark it immutable.
curl -sI -H 'Range: bytes=0-1023' https://example.com/static/media/intro.4c8b1f92.mp4 \
  | grep -Ei 'http/|content-range|accept-ranges|cache-control'

Command 3 is the one worth wiring into CI. If touching an image changes a JavaScript hash, something is inlining that it should not be, and every deploy is invalidating more cache than it needs to.

Edge Cases and Known Issues

new URL() only works with a literal

Both Webpack 5 and Vite resolve new URL('./icon.png', import.meta.url) at build time by static analysis. A computed path built from a template literal or a variable cannot be resolved, so the file is never emitted and the URL resolves to a 404 at runtime. Use an explicit import map or import.meta.glob in Vite instead of string interpolation.

SVG sprite sheets defeat per-icon hashing

A concatenated SVG sprite is one file with one hash. Adding a single icon changes the sprite’s hash and invalidates the whole sheet for every user. That is usually still the right trade — one request beats forty — but it means icon changes cost the full sprite payload. Split rarely-changing icons into a separate stable sprite if the sheet exceeds ~40 KB.

Metadata makes image hashes non-deterministic

Encoders write timestamps, ICC profiles, and library version strings into output. Two CI runs on different libvips builds produce different bytes for identical input, and therefore different hashes, with no visual difference. Pin the encoder version and strip metadata explicitly (sharp(...).withMetadata(false)); the same class of problem is covered under deterministic build outputs.

Vary: Accept fragments the image cache

Serving AVIF or WebP based on the request’s Accept header requires Vary: Accept, which multiplies cache entries by the number of distinct Accept values the CDN sees — and browsers send dozens of variants of that header. Prefer explicit <picture> sources with hashed URLs so each format is its own cache key. The failure modes are enumerated in Vary header pitfalls.

Case-insensitive filesystems hide extension collisions

Logo.PNG and logo.png are the same file on macOS and Windows but different files on Linux. A build that works locally emits one asset; CI emits two, with different hashes, and the reference rewritten in CSS may point at whichever won the race. Normalise asset filenames to lowercase in a pre-build lint step.

Query strings survive into the emitted name

Webpack’s default assetModuleFilename ends with [ext][query]. If any import carries a query — import icon from './icon.svg?inline' — the query is appended to the emitted filename, producing files that a static file server may refuse to serve. Drop [query] from the template unless a loader depends on it.

Very large files stall the build

Both bundlers read an asset fully into a Buffer to hash it. A 400 MB video will spike Node’s heap and can trip the default heap limit on a small CI runner. Keep media out of the graph entirely; see cache-busting video and audio.

Performance Impact

Build time. Hashing is I/O-bound and cheap relative to encoding. On a 1,200-asset project, SHA-256 over every binary adds roughly 0.6–1.1 s to a cold build on a 4-core runner. Generating derived image variants dominates by an order of magnitude — 36 variants of a 12 MP photograph take 8–14 s with sharp — so cache the variant directory between CI runs keyed on the source file hashes.

Payload. Inlining below 4 KB typically removes 15–40 requests from a first load at a cost of 25–35% payload inflation on those bytes. Above roughly 8 KB the inflation outweighs the request savings on HTTP/2 and later, where request cost is small.

Cache hit ratio. Unhashed images served with a short TTL sit around 70–85% edge hit ratio, because every TTL expiry forces a revalidation round trip. The same images under hashed URLs with immutable settle above 99% once warm, and generate zero conditional requests. On an image-heavy site this is usually the single largest reduction in origin egress available.

Repeat-visit bytes. The measurable win is on the second page view. With unhashed images and a 10-minute TTL, a returning visitor after an hour revalidates every image — 40 conditional requests at 30–90 ms each, serialised across connection limits on constrained networks. With hashed URLs, all 40 are disk-cache hits.

Deploy cost. Because hashed assets never change at a given URL, deploys need no purge for them at all; only the HTML entry points and the unhashed public/ files require invalidation, which keeps purge API usage and CloudFront invalidation charges near zero. On a site with 40,000 emitted image variants, the purge list after a deploy is typically a single-digit number of paths rather than tens of thousands, which is the difference between an invalidation that completes in seconds and one that is rate-limited for an hour.

Storage. The one real cost of hashing binaries is that old versions accumulate in the bucket, since nothing overwrites them. Budget for it: a year of daily deploys on an image-heavy site adds tens of gigabytes of orphaned variants. Sweep objects whose keys no longer appear in any manifest from the last N releases, and keep at least two releases’ worth so in-flight HTML never 404s during a rollback.

FAQ

Do I need to hash images if they are already served through a CDN with a short TTL?

A short TTL bounds staleness but does not eliminate it, and it costs you a revalidation round trip per image per TTL window. With a 5-minute TTL, a user browsing for 30 minutes revalidates every image five times. Hashing converts that into a single fetch per unique file version, forever. The short-TTL approach is a reasonable fallback only for the handful of files that genuinely cannot move — favicons, og:image targets, and anything an external system has hard-coded.

Why did my JavaScript bundle hash change when I only edited a PNG?

The PNG is below your inline threshold, so its base64 payload is embedded in the chunk that imports it, making the image’s bytes part of the chunk’s content hash. Either raise the file above the threshold, or force emission per-rule — type: 'asset/resource' in Webpack, or a false return from Vite’s assetsInlineLimit function. Verify with grep -c 'data:image' dist/static/js/*.js before and after.

Should the hash go before or after the extension?

After the base name and before the extension: hero.9f2a71c4.png, not hero.png.9f2a71c4. Content negotiation, MIME sniffing, static file servers, and CDN path rules all key on the trailing extension. Putting the hash last breaks Content-Type detection on most origins and makes path-prefix cache rules impossible to write cleanly.

How do I hash an image whose URL is stored in a CMS?

You cannot do it at build time — the bundler never sees the file. Hash it at upload: compute SHA-256 of the uploaded bytes, store the object under uploads/<hash>.<ext>, and persist that key in the CMS record. Re-uploading identical bytes then resolves to the same key for free, and any edit produces a new key, so the stored URL is immutable by construction.