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.
Prerequisites
- Webpack 5.0+ for asset modules (
type: 'asset/resource'). Webpack 4’sfile-loaderandurl-loaderare 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 inassetFileNames. Rollup 3 accepted[hash]only at full length. - Node 18.17+, because
crypto.hashfast paths used by both bundlers for large binaries changed behaviour in earlier 18.x patch releases. sharp0.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-Controlvalues 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.
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.
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.
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.
Related
- Static Asset Fingerprinting Fundamentals — parent overview of the whole fingerprinting model
- Responsive image srcsets — hashing every candidate in a variant set
- Web fonts and
@font-facerewriting — keeping font URLs valid after renaming - Video and audio cache busting — range requests, playlists, and upload-time hashing
- Cache key architecture — how the edge derives a cache key from a hashed path