esbuild Fingerprinting Plugins: Configuration & CDN Invalidation Workflows
esbuild’s native [hash] placeholders give you content-addressed filenames instantly, but without a manifest your servers have no way to resolve which hashed filename maps to which logical asset — this guide shows you how to wire both together for a production CDN pipeline.
When to Use esbuild for Asset Fingerprinting
esbuild’s built-in hashing covers most single-page application needs out of the box, but understanding the decision boundary saves you from over-engineering or under-engineering the pipeline.
Choose esbuild’s native [hash] placeholders when:
- You have a simple SPA with JS and CSS entry points and no server-side template rendering
- Build speed is a primary constraint (esbuild is 10–50x faster than JavaScript-based bundlers for equivalent workloads)
- You do not need a manifest file — for example, an Nginx static file server that simply serves whatever is in
dist/ - You are already using esbuild as the underlying bundler inside Vite and want to drop down to the raw API for a specific micro-frontend or worker bundle
Choose esbuild with a custom onEnd plugin when:
- A server-side framework (Express, Django, Rails, Next.js custom server) must resolve asset paths at request time and needs a
manifest.jsonlookup table - You need deterministic build outputs reproducible across CI machines and operating systems
- You are deploying to a CDN and want targeted cache purge rather than a full zone flush
- You need
publicPathto point assets at a separate CDN origin (e.g.,https://assets.example.com/) - You are migrating from Webpack’s contenthash and need manifest parity for a phased cutover
Choose a different bundler when:
- Your team already has deep Vite asset pipeline investment and native
manifest.jsonoutput is sufficient — adding a raw esbuild layer duplicates complexity - You need Webpack-style module federation across micro-frontends — esbuild has no equivalent today
- Your build is dominated by TypeScript type-checking time, which esbuild skips entirely (a separate
tsc --noEmitstep is required)
Prerequisites
| Requirement | Minimum Version | Notes |
|---|---|---|
| Node.js | 18.0.0 | Required for native fs.promises.rename and crypto.subtle |
| esbuild | 0.20.0 | entryNames/assetNames/chunkNames with [hash] stabilised |
| npm / pnpm / yarn | any current LTS | Build script invoked as node build.mjs — no bundler CLI flags |
| jq | 1.6+ | Required only for the CDN diff/purge shell script |
| Cloudflare API token | — | Required only for the Cloudflare cache-purge step; needs Cache Purge permission |
esbuild does not have a --config flag. All configuration is expressed as a JavaScript (or TypeScript) build script that you run with node build.mjs. If you use TypeScript for the build script itself, install tsx and invoke with npx tsx build.ts.
Configuration Reference
The table below covers every esbuild option relevant to fingerprinting. Options marked plugin-only are not native esbuild flags — they are parameters you pass to the custom plugin defined later in this guide.
| Option | Type | Default | Effect |
|---|---|---|---|
entryNames |
string | [name] |
Template for entry point output filenames. Use [dir]/[name]-[hash] for fingerprinted entries. |
assetNames |
string | [name]-[hash] |
Template for static assets imported via new URL(…, import.meta.url) or loaders. Already includes [hash] by default. |
chunkNames |
string | [name]-[hash] |
Template for code-split chunks. Already includes [hash] by default. |
metafile |
boolean | false |
Emit a JSON metafile with input/output dependency graph. Required for manifest generation. |
publicPath |
string | "" |
Prepend a URL prefix to all asset references inside bundles. Set to your CDN origin, e.g. https://assets.example.com/. |
outdir |
string | — | Output directory. Mutually exclusive with outfile. Required for multi-entry or code-split builds. |
splitting |
boolean | false |
Enable code splitting. Requires format: "esm". Chunks are named via chunkNames. |
write |
boolean | true |
Set to false to suppress disk writes and receive result.outputFiles in memory for custom processing. |
minify |
boolean | false |
Minify output. Hash reflects minified content — always enable in production so the hash is stable against accidental unminified deploys. |
manifestPath (plugin-only) |
string | "dist/manifest.json" |
Destination path for the emitted manifest.json. |
hashLength (plugin-only) |
number | 8 |
Number of hex characters to retain from SHA-256. Use 12–16 for monorepos with thousands of chunks where 8-char collision probability becomes non-negligible. |
How esbuild Fingerprinting Works
esbuild resolves the full dependency graph, bundles and optionally minifies, then applies your naming templates to every output file before writing to disk. The [hash] token in entryNames, assetNames, and chunkNames is replaced with a content-derived fingerprint computed by esbuild itself. The metafile option adds a second JSON output that maps every input file to the output file it contributed to — this is the raw material your manifest plugin reads to build the manifest.json lookup table your server uses.
The distinction between content hashing and semantic versioning matters here: esbuild’s [hash] token is always content-derived, meaning two identical source trees always produce identical hashes regardless of when or where the build runs, as long as the esbuild version and options are fixed. This is a prerequisite for trustworthy CDN immutable caching.
Step-by-Step Implementation
Step 1 — Install esbuild
npm install --save-dev esbuild
# Verify version — must be 0.20.0 or later
npx esbuild --version
Step 2 — Create the build script
Create build.mjs at the project root. This script uses ESM syntax and runs directly with node build.mjs.
// build.mjs
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { build } from 'esbuild';
/**
* Emit a manifest.json that maps logical names to fingerprinted output paths.
*
* @param {object} options
* @param {string} options.manifestPath - Destination for manifest.json (default: dist/manifest.json)
* @param {number} options.hashLength - Hex chars to keep from SHA-256 (default: 8; use 12-16 for large monorepos)
*/
function fingerprintManifestPlugin({ manifestPath = 'dist/manifest.json', hashLength = 8 } = {}) {
return {
name: 'fingerprint-manifest',
setup(build) {
build.onEnd(async (result) => {
if (result.errors.length > 0) return;
const outdir = build.initialOptions.outdir ?? 'dist';
const manifest = {};
// result.outputFiles is populated when write: false.
// When write: true, read from disk using result.metafile.
const files = result.outputFiles ?? [];
for (const file of files) {
const hash = crypto
.createHash('sha256')
.update(file.contents)
.digest('hex')
.slice(0, hashLength);
const ext = path.extname(file.path);
const base = path.basename(file.path, ext);
const rel = path.relative(outdir, file.path);
const hashedRel = rel.replace(base + ext, `${base}-${hash}${ext}`);
const hashedAbs = path.join(outdir, hashedRel);
// Logical name → fingerprinted relative path
manifest[rel] = hashedRel;
// Write the hashed file to disk
fs.mkdirSync(path.dirname(hashedAbs), { recursive: true });
fs.writeFileSync(hashedAbs, file.contents);
}
// Atomic write: tmp file then rename to avoid partial reads
const tmp = manifestPath + '.tmp';
fs.mkdirSync(path.dirname(tmp), { recursive: true });
fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2) + '\n');
fs.renameSync(tmp, manifestPath);
console.log(`[fingerprint] manifest written → ${manifestPath}`);
});
},
};
}
await build({
entryPoints: ['src/index.js', 'src/admin.js'],
bundle: true,
splitting: true,
format: 'esm',
// esbuild 0.20+ [hash] in entryNames does NOT include a hash by default —
// you must add it explicitly with the [hash] token.
entryNames: '[dir]/[name]-[hash]',
assetNames: 'assets/[name]-[hash]',
chunkNames: 'chunks/[name]-[hash]',
publicPath: process.env.CDN_ORIGIN ?? '/',
outdir: 'dist',
write: false, // Hand files to onEnd instead of writing them ourselves
metafile: true, // Needed if you also want to inspect the dependency graph
minify: true,
sourcemap: 'linked',
plugins: [
fingerprintManifestPlugin({
manifestPath: 'dist/manifest.json',
hashLength: 8, // Increase to 12-16 for monorepos with thousands of chunks
}),
],
});
Run the build:
node build.mjs
Step 3 — Understand the manifest output
After a successful build, dist/manifest.json will resemble:
{
"index.js": "index-a1b2c3d4.js",
"admin.js": "admin-e5f6a7b8.js",
"chunks/vendor-runtime.js": "chunks/vendor-runtime-9c0d1e2f.js",
"assets/logo.png": "assets/logo-3a4b5c6d.png",
"assets/main.css": "assets/main-7e8f9a0b.css"
}
The key is the logical relative path (what your source code imports), the value is the fingerprinted relative path (what the CDN serves). This flat key-value schema gives O(1) lookups in any server-side language.
Step 4 — Consume the manifest in your server
Node.js / Express:
// server/assets.js
import { readFileSync } from 'node:fs';
const manifest = JSON.parse(readFileSync('dist/manifest.json', 'utf8'));
/**
* Resolve a logical asset name to its fingerprinted URL.
* Falls back to the logical name so development builds without hashing still work.
*/
export function assetUrl(logical) {
return '/' + (manifest[logical] ?? logical);
}
// Express route
import { assetUrl } from './server/assets.js';
app.locals.asset = assetUrl;
// In Pug/EJS template: script(src=asset('index.js'))
Python / Django:
# myapp/templatetags/assets.py
import json
from pathlib import Path
from django import template
register = template.Library()
_manifest = json.loads((Path(__file__).parent.parent / 'dist/manifest.json').read_text())
@register.simple_tag
def asset(logical):
return '/' + _manifest.get(logical, logical)
Step 5 — Configure CDN caching headers
Fingerprinted assets should be served with immutable cache directives. Since the filename changes whenever content changes, clients never need to revalidate.
Nginx example:
# Serve hashed assets with 1-year immutable cache
location ~* \.(js|css|png|woff2|svg)$ {
root /var/www/dist;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Vary "Accept-Encoding";
gzip_static on;
}
Cloudflare Pages _headers file:
/assets/*
Cache-Control: public, max-age=31536000, immutable
The cache key architecture for immutable assets is simple: the full URL path is the cache key, and because the path changes on every content change, you never need to purge existing assets — only upload the new ones. The old hashed filenames remain valid until you delete them from storage.
Step 6 — Set publicPath for CDN origins
If your assets live on a separate CDN subdomain rather than the same origin as your HTML, set publicPath so that esbuild rewrites all internal asset references (CSS url(), dynamic import(), worker URLs) to point at the CDN:
// In build.mjs — replace '/' with your CDN origin
publicPath: 'https://assets.example.com/',
With publicPath set, a CSS file containing background: url(./logo.png) will be rewritten to background: url(https://assets.example.com/assets/logo-3a4b5c6d.png) in the final bundle. Without it, browsers load from the same origin as the HTML page, which may not be where you uploaded the assets. For a deep dive into CDN integration, see integrating esbuild with CDN fingerprinting workflows.
Using the esbuild Metafile for Advanced Manifest Strategies
The metafile: true option causes esbuild to emit a structured JSON file (accessible as result.metafile in the onEnd callback and writable to disk with esbuild.analyzeMetafile()) that maps every input module to the outputs it contributed to. This is more powerful than simply iterating result.outputFiles when you need:
- Chunk membership — knowing which source modules ended up in which shared chunk
- Import graph visualization — feed
result.metafiletoesbuild.analyzeMetafile(result.metafile)for a human-readable tree - Source-map-aware manifests — pair each output file with its corresponding
.mapfile
// Write metafile to disk for debugging or CI artifact storage
import { analyzeMetafile, build } from 'esbuild';
const result = await build({ /* ... */ metafile: true, write: false });
// Human-readable dependency tree (safe to log in CI)
const analysis = await analyzeMetafile(result.metafile, { verbose: false });
console.log(analysis);
// Machine-readable JSON for artifact storage
fs.writeFileSync('dist/metafile.json', JSON.stringify(result.metafile, null, 2));
The metafile outputs map contains each output file as a key with a hash property — this is esbuild’s own computed hash for that file, which you can use instead of re-hashing with crypto:
// Alternative: use esbuild's own hash from the metafile
for (const [outputPath, outputMeta] of Object.entries(result.metafile.outputs)) {
const esbuildHash = outputMeta.hash; // 8-character hex string by default
// ... build manifest entry
}
This avoids the double-hashing overhead in the onEnd plugin. The trade-off is that esbuild’s hash algorithm is not publicly documented and may change across versions — if you need algorithm stability (e.g., to match hashes computed by another tool), stick with SHA-256.
Verification
After running node build.mjs, confirm the pipeline is correct:
# 1. Confirm hashed files exist in dist/
ls dist/
# Expected: index-a1b2c3d4.js admin-e5f6a7b8.js manifest.json
# 2. Verify manifest maps every entry point
jq 'keys' dist/manifest.json
# 3. Confirm hashes are stable across two identical builds
node build.mjs && cp dist/manifest.json /tmp/manifest-first.json
node build.mjs && diff /tmp/manifest-first.json dist/manifest.json
# diff should be empty — identical content produces identical hashes
# 4. Confirm immutable headers are set (requires server running on :3000)
curl -sI http://localhost:3000/$(jq -r '."index.js"' dist/manifest.json) \
| grep Cache-Control
# Expected: Cache-Control: public, max-age=31536000, immutable
# 5. Confirm publicPath is baked into CSS asset references
grep -o 'url(https://assets\.example\.com[^)"]*)' dist/*.css | head -5
CDN Invalidation Workflow
Because fingerprinted filenames never collide across deploys, most assets need no CDN purge at all — new files have new names and old names simply age out of your storage lifecycle policy. The only assets that need explicit purging are non-fingerprinted files: manifest.json, index.html, and any robots/sitemap files.
#!/usr/bin/env bash
# scripts/purge-cdn.sh — purge only the non-hashed files after each deploy
set -euo pipefail
CF_ZONE_ID="${CF_ZONE_ID:?required}"
CF_API_TOKEN="${CF_API_TOKEN:?required}"
CDN_BASE="https://assets.example.com"
# These files are not fingerprinted and must be purged on every deploy
PURGE_URLS=$(printf '"%s/%s"\n' \
"$CDN_BASE" "manifest.json" \
"$CDN_BASE" "index.html" \
| paste -sd,)
curl -s -X POST \
"https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/purge_cache" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"files\": [$PURGE_URLS]}" \
| jq '.success'
For teams that do serve some assets without fingerprinting (for example, a legacy endpoint that cannot be changed), you can diff manifests across deploys to find only the changed logical names and purge their old hashed URLs — see Cloudflare cache rules and purge for the Cloudflare-specific API details.
esbuild vs Webpack vs Vite: Fingerprinting Feature Comparison
| Feature | esbuild 0.20+ | Webpack 5 | Vite 5 |
|---|---|---|---|
| Native hash placeholder | [hash] in entryNames/assetNames/chunkNames |
[contenthash] in output.filename |
[hash] via Rollup (used internally) |
| Manifest generation | Custom onEnd plugin required |
webpack-manifest-plugin or WebpackManifestPlugin |
Built-in .vite/manifest.json with build.manifest: true |
| Hash algorithm | Internal (undocumented, fast) | MD4 by default; configurable via hashFunction |
SHA-256 via Rollup |
| publicPath support | Yes — publicPath option |
Yes — output.publicPath |
Yes — base config option |
| Build speed (1000-module app) | ~0.5s | ~8–15s | ~2–4s (esbuild pre-bundling + Rollup) |
| Code splitting | Yes (ESM only, splitting: true) |
Yes (CommonJS + ESM) | Yes (Rollup-based) |
| Module federation | No | Yes (Webpack 5 native) | Via @originjs/vite-plugin-federation |
| Config file | None — use build.mjs |
webpack.config.js |
vite.config.js |
| Type-checking | Strips types, no checking | Via ts-loader or babel-loader |
Via vite-plugin-checker |
The key limitation versus Webpack’s output hashing is that esbuild has no concept of long-term chunk caching across builds — it rehashes everything from scratch on each invocation, which is fast enough that incremental caching is rarely needed. The key limitation versus Vite is that Vite’s manifest includes CSS chunk dependencies for each entry point (the css array in each manifest entry), which esbuild’s custom plugin would need to reconstruct from the metafile. For most use cases neither limitation matters in practice.
Edge Cases & Known Issues
CSS url() references break after hashing when write: false is not set. When you use write: true (the default), esbuild writes files to disk before your onEnd hook runs. Your plugin then writes a second set of hashed files. The original unhashed files remain in outdir and your server may accidentally serve them. Set write: false so all file I/O goes through your plugin’s explicit fs.writeFileSync calls.
entryNames with [hash] can cause issues with HTML script tags in development. During development with esbuild’s --watch or context.watch(), the hash changes on every save, making it impossible to hardcode the script src in index.html. Use a development-time plugin that writes an index.html from the manifest, or skip [hash] in entryNames during development and only apply it in production builds controlled by NODE_ENV.
Code splitting requires format: 'esm'. If you use splitting: true without format: 'esm', esbuild throws an error. CommonJS output does not support dynamic import() chunk splitting. This is intentional — there is no esbuild workaround. If you need CommonJS chunks, use Rollup instead.
publicPath is baked into the bundle at build time. If your CDN origin changes after deployment, you must rebuild. There is no runtime override. For environments where the CDN origin varies (e.g., staging vs. production pointing at different S3 buckets), pass publicPath via an environment variable: publicPath: process.env.CDN_ORIGIN.
Hashes are not stable across esbuild versions. esbuild’s internal hash algorithm is not part of its public API contract. Upgrading esbuild may produce different hashes for identical source content, triggering unnecessary CDN cache misses on the first deploy after an esbuild upgrade. This is expected behavior — treat an esbuild version upgrade as a full cache invalidation event.
fs.renameSync is not atomic across filesystems. The atomic-write pattern (write to tmp, rename to final) is only atomic when the source and destination are on the same filesystem. On Docker volumes or network mounts, tmp and outdir may be on different mounts. Ensure manifestPath is inside outdir or on the same volume as outdir.
Performance Impact
esbuild is written in Go and parallelises all CPU-bound work across available cores. Content hashing in the onEnd plugin adds overhead in Node.js, outside esbuild’s Go runtime. Benchmarks on a 200-module, 1.2 MB minified bundle:
| Operation | Time |
|---|---|
| esbuild bundle + minify | ~120ms |
SHA-256 hash of all output files in onEnd |
~8ms |
| Manifest JSON serialisation + atomic write | ~2ms |
| Total build time | ~130ms |
The Node.js hashing step adds roughly 8–10% overhead relative to esbuild alone. For monorepos with thousands of output chunks, this scales linearly: 1000 chunks of 50 KB each add approximately 40ms. If this matters, switch to esbuild’s own metafile.outputs[path].hash (available in 0.20+) which is computed for free inside Go during the build — your onEnd plugin reads it from result.metafile without any additional crypto work.
The write: false approach used in this guide avoids double-writing files to disk (once by esbuild, once by your plugin), which saves meaningful I/O on large asset sets with many image files.
FAQ
Can I use esbuild’s native [hash] placeholder instead of writing a custom plugin?
Yes, for simple cases. Setting entryNames: '[dir]/[name]-[hash]' in your build() call produces fingerprinted filenames without any plugin. The limitation is that esbuild does not emit a manifest.json — you get hashed files on disk but no machine-readable mapping from logical names to fingerprinted names. If your server simply serves whatever is in dist/ (for example, a Cloudflare Pages static deployment), you do not need a manifest and the native placeholder is sufficient. If your server renders HTML with <script src="..."> tags that must reference the correct hashed filename, you need a manifest and therefore a custom plugin as shown in this guide.
How do I keep hashes deterministic across different CI machines and operating systems?
esbuild produces deterministic output when the input files, the esbuild version, and the build options are identical. Three common sources of non-determinism are: (1) absolute file paths included in source maps — use sourceRoot or strip the absolute prefix; (2) OS-specific line endings in source files — enforce LF with a .gitattributes rule (* text=auto eol=lf); (3) different Node.js versions affecting the crypto module output — this does not apply when using SHA-256 but can affect other algorithms. Lock your esbuild version in package.json with an exact specifier ("esbuild": "0.24.2" rather than "^0.24.2") to prevent silent version drift across CI runner images. For a thorough treatment of deterministic build outputs, including how to audit and reproduce a prior build’s hash, see the dedicated guide.
How long should the hash be — 8, 12, or 16 characters?
8 hex characters (32 bits of entropy) is the safe default for projects with fewer than a few hundred output files: the probability of a collision between any two files is roughly 1 in 4 billion, which is negligible. In a monorepo generating thousands of chunks per build, the birthday problem becomes relevant — with 10,000 files, the collision probability with 8-char hashes rises to around 1 in 400,000. Use 12 characters (48 bits) for monorepos with 1,000–10,000 output files, or 16 characters (64 bits) to reduce collision probability to effectively zero at any practical scale. Set the hashLength parameter in the plugin options rather than slicing differently in different places.
What happens to old hashed files in dist/ across builds?
By default this guide’s plugin only writes new hashed files — it does not delete old ones. Stale hashed files accumulate in dist/ across builds. This is intentional: any in-flight requests or browser tabs still holding a previous HTML page reference the old hashed filenames, which remain valid as long as the files are present. Delete dist/ at the start of each build only if you are confident no requests will hit old hashes during the deployment window. A common pattern is to retain the two most recent builds’ worth of hashed files and delete older ones with a cleanup step. For CDN-hosted assets on Cloudflare R2 or S3, set a lifecycle policy to expire objects older than 30 days rather than deleting them synchronously. For a safe rollback strategy if a build introduces a regression, see rolling back esbuild fingerprinted assets after a bad deploy.
Related
- Build Tool & Framework Asset Pipeline Integration — parent overview covering all bundler fingerprinting approaches
- Integrating esbuild with CDN Fingerprinting Workflows — deployment automation, upload scripts, and CDN provider specifics
- Rolling Back esbuild Fingerprinted Assets After a Bad Deploy — safe rollback strategies without invalidating healthy cached assets
- Webpack Output Hashing Setup — contenthash configuration and migration mapping for teams moving from Webpack
- Vite Asset Pipeline Configuration — built-in manifest and hash options when esbuild is used inside Vite