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.json lookup 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 publicPath to 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.json output 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 --noEmit step 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 Fingerprinting Pipeline A left-to-right flow diagram showing source files entering the esbuild build step, which applies entryNames, assetNames, and chunkNames with [hash] placeholders. The build emits hashed output files and a metafile. A manifest plugin reads the metafile and writes manifest.json. A deploy step uploads to CDN storage and optionally triggers a targeted cache purge. Source Files JS · CSS · PNG esbuild build() entryNames [hash] assetNames [hash] chunkNames [hash] Hashed Output index-a1b2c3d4.js metafile.json inputs → outputs onEnd plugin manifest.json CDN Deploy upload + purge purge trigger

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.metafile to esbuild.analyzeMetafile(result.metafile) for a human-readable tree
  • Source-map-aware manifests — pair each output file with its corresponding .map file
// 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.