Monorepo and Micro-Frontend Asset Hashing
One repository, a dozen independently deployed applications, and a single CDN origin is the configuration where naive content hashing quietly breaks: two packages emit assets/main-a1b2c3d4.js, both upload to /assets/, and whichever deploy ran last wins. This guide covers namespacing hashed output per package, keeping hash algorithms consistent across a workspace, tracing how an internal dependency change propagates into consumer hashes, wiring Turborepo or Nx task graphs so cached builds still produce correct filenames, and deciding how long old assets must survive when apps deploy on independent schedules.
When to Use This Approach vs. Alternatives
There are three ways to give a monorepo’s applications distinct asset URLs. Pick one deliberately — mixing them is how collisions appear.
| Approach | Origin layout | Deploy independence | Cross-app chunk sharing | Operational cost |
|---|---|---|---|---|
| Namespaced prefixes on one origin | cdn.example.com/assets/<package>/… |
Full — each app writes only its own prefix | Possible via a shared vendor prefix | Low: one bucket, one distribution, one cache rule |
| One origin per application | checkout-cdn.example.com/assets/… |
Full, with hard isolation | None without cross-origin CORS work | High: N distributions, N certificates, N cache rules |
| Flat shared namespace | cdn.example.com/assets/… for every app |
None — deploys must be lockstep | Automatic | Low config, high blast radius |
Namespaced prefixes on one origin is the default recommendation and the configuration this guide builds. It gives every package a private write target while keeping one TLS certificate, one Cache-Control policy, and one purge surface.
Choose one origin per application when packages belong to different security domains (a customer-facing storefront and an internal admin console with different WAF rules), when an app must be servable from a different region, or when regulatory separation demands distinct log streams.
Choose a flat shared namespace only when every package ships in a single atomic release. It is the correct answer for a server-rendered monolith whose frontend happens to live in several workspace packages, and the wrong answer the moment one team wants to ship on Tuesday while another ships on Thursday.
Notice that checkout and account both emitted main-a1b2c3d4.js. That is not a bug and not a collision — two different applications can legitimately produce byte-identical entry chunks early in their life, and the prefix keeps their URLs distinct. Only a flat namespace turns that coincidence into an outage.
Prerequisites
| Requirement | Version / detail |
|---|---|
| Node.js | 20.11 or newer, pinned identically for every package |
| Package manager | pnpm 9.x workspaces, npm 10.x workspaces, or Yarn 4.x |
| Vite | 5.2+ (Rollup 4 output options) |
| Webpack | 5.90+ for output.uniqueName and stable realContentHash |
| Turborepo | 2.0+ (tasks key; the legacy pipeline key is removed) |
| Nx | 18+ for the project-crystal inferred task graph |
| Object store | S3, R2, or GCS with per-prefix write credentials |
Every package must resolve the same major version of the bundler. A workspace where apps/checkout uses Vite 5.2 and apps/account uses Vite 4.5 will produce chunks hashed by two different algorithms, which makes cross-package deduplication impossible and makes hash-length policy meaningless. Pin the bundler in the workspace root and let packages inherit it. The same reasoning applies to the runtime: see pinning lockfiles and Node versions for stable hashes for the exact pinning mechanics.
Configuration Reference
| Key | Type | Default | Effect in a workspace |
|---|---|---|---|
base (Vite) |
string | / |
Public base path baked into emitted URLs; set to /assets/<package>/ per app |
build.outDir (Vite) |
string | dist |
Local output directory; keep it package-relative so Turborepo can cache it |
build.assetsDir (Vite) |
string | assets |
Subdirectory inside outDir; set to . when base already carries the namespace |
rollupOptions.output.entryFileNames |
string | function | [name]-[hash].js |
Template for entry chunks; [hash] length is controlled by :8 |
output.publicPath (Webpack) |
string | auto |
Runtime URL prefix for lazy chunks; must be the package’s CDN prefix |
output.uniqueName (Webpack) |
string | package name |
Namespaces the runtime globals so two bundles on one page do not clobber each other |
output.hashFunction (Webpack) |
string | md4 (wasm) |
Set to xxhash64 for speed or sha256 for a cryptographic digest |
output.hashDigestLength (Webpack) |
number | 20 |
Truncation length when the template omits :n; 8 is the site-wide default |
optimization.realContentHash |
boolean | true in production |
Re-hashes after minification so post-processing does not desync filenames |
tasks.<task>.outputs (turbo.json) |
string[] | [] |
Glob of files restored on a cache hit; must include the manifest |
tasks.<task>.dependsOn (turbo.json) |
string[] | [] |
^build forces internal dependencies to build first |
globalDependencies (turbo.json) |
string[] | [] |
Files outside any package that invalidate every task hash |
remoteCache.signature (turbo.json) |
boolean | false |
HMAC-signs cache artifacts so a poisoned remote entry is rejected |
namedInputs (nx.json) |
object | {} |
Reusable input sets; production excludes test files from the hash |
targetDefaults.build.cache |
boolean | false |
Enables Nx computation caching for the build target |
Hash length deserves an explicit policy. Eight hex characters — 32 bits — is the default across this site and is comfortable for a single application. A monorepo is the one case where 12 to 16 characters is genuinely worth the extra bytes: a workspace with a dozen apps and 400 chunks each has roughly 4,800 live filenames, and if you ever flatten them into one namespace the birthday bound starts to matter. The arithmetic is worked through in preventing hash collisions in large frontend projects.
Step-by-Step Implementation
Step 1 — Fix the namespace scheme before writing any config
Decide the URL shape once and encode it as a pure function of the package name. Three rules keep it stable:
- The namespace segment is derived from the package’s directory name, not hand-written per app. Hand-written prefixes drift.
- The namespace lives in the public base path, not in the filename template. Putting it in the filename (
checkout-main-a1b2c3d4.js) works but ruins prefix-scoped cache rules and prefix-scoped IAM policies. - Nothing is ever written to the bare
/assets/root. Reserve it, and add a CI check that fails if any object lands there.
The resulting layout on the origin:
/assets/checkout/main-a1b2c3d4.js
/assets/checkout/vendor-9f3e77b1.js
/assets/checkout/manifest.json
/assets/account/main-a1b2c3d4.js
/assets/account/vendor-9f3e77b1.js
/assets/account/manifest.json
/assets/shared/react-4c81ee02.js
Step 2 — A workspace-aware Vite output configuration
Put the shared factory in an internal package (packages/build-config) so every app imports the same logic. The factory reads the consuming package’s own package.json, derives the namespace, and returns a complete Vite config.
// packages/build-config/vite.js
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { defineConfig } from 'vite';
const CDN_ORIGIN = process.env.CDN_ORIGIN ?? '';
const HASH_LENGTH = Number(process.env.ASSET_HASH_LENGTH ?? 8);
function namespaceFor(packageRoot) {
const pkg = JSON.parse(
readFileSync(resolve(packageRoot, 'package.json'), 'utf8'),
);
// "@acme/checkout" -> "checkout"; "checkout" -> "checkout"
const short = pkg.name.includes('/') ? pkg.name.split('/')[1] : pkg.name;
if (!/^[a-z0-9][a-z0-9-]*$/.test(short)) {
throw new Error(`Package name "${pkg.name}" is not URL-safe as a namespace`);
}
return short;
}
export function createAppConfig(packageRoot, overrides = {}) {
const ns = namespaceFor(packageRoot);
const h = `[hash:${HASH_LENGTH}]`;
return defineConfig({
// Every emitted URL is absolute and prefixed with the package namespace.
base: `${CDN_ORIGIN}/assets/${ns}/`,
build: {
outDir: 'dist',
// The namespace already lives in `base`, so keep the local tree flat.
assetsDir: '.',
manifest: 'manifest.json',
sourcemap: 'hidden',
rollupOptions: {
output: {
entryFileNames: `${'[name]'}-${h}.js`,
chunkFileNames: `${'[name]'}-${h}.js`,
assetFileNames: `${'[name]'}-${h}[extname]`,
// Deterministic chunk ordering: without this, Rollup may reorder
// modules between machines and shift every downstream hash.
hoistTransitiveImports: false,
},
},
},
...overrides,
});
}
Each application then reduces to three lines:
// apps/checkout/vite.config.js
import { createAppConfig } from '@acme/build-config/vite';
export default createAppConfig(import.meta.dirname);
The namespaceFor guard is worth more than it looks. It turns “someone renamed a package to @acme/Checkout_v2” from a silent production 404 into a build failure.
Step 3 — The Webpack equivalent
Webpack needs two extra settings that Vite handles implicitly: uniqueName, which namespaces the runtime’s global chunk-loading callback, and an explicit publicPath, which tells the runtime where to fetch lazy chunks from. Without uniqueName, two bundles rendered on the same page overwrite each other’s webpackChunk global and lazy loading breaks in ways that look like a CDN problem.
// packages/build-config/webpack.js
const path = require('node:path');
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
const CDN_ORIGIN = process.env.CDN_ORIGIN || '';
const HASH_LENGTH = Number(process.env.ASSET_HASH_LENGTH || 8);
function createAppConfig(packageRoot) {
const pkg = require(path.join(packageRoot, 'package.json'));
const ns = pkg.name.includes('/') ? pkg.name.split('/')[1] : pkg.name;
const h = `[contenthash:${HASH_LENGTH}]`;
return {
mode: 'production',
context: packageRoot,
entry: { main: './src/index.js' },
output: {
path: path.join(packageRoot, 'dist'),
publicPath: `${CDN_ORIGIN}/assets/${ns}/`,
// Namespaces webpackChunk<uniqueName> so two bundles can share a page.
uniqueName: `acme_${ns}`,
filename: `[name]-${h}.js`,
chunkFilename: `[name]-${h}.js`,
assetModuleFilename: `[name]-${h}[ext]`,
// Identical algorithm and length in every package of the workspace.
hashFunction: 'xxhash64',
hashDigestLength: HASH_LENGTH,
clean: true,
},
optimization: {
realContentHash: true,
moduleIds: 'deterministic',
chunkIds: 'deterministic',
runtimeChunk: 'single',
splitChunks: {
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
chunks: 'all',
},
},
},
},
plugins: [
new WebpackManifestPlugin({
fileName: 'manifest.json',
publicPath: `/assets/${ns}/`,
}),
],
};
}
module.exports = { createAppConfig };
moduleIds: 'deterministic' and chunkIds: 'deterministic' are mandatory in a workspace, not optional tuning. The default natural ids are assigned in resolution order, and resolution order in a monorepo changes whenever a sibling package is added — which would re-hash every chunk in every app for no content reason. The Webpack output hashing setup reference covers the id-generation modes in detail.
Step 4 — One algorithm, one length, workspace-wide
Both configs above read ASSET_HASH_LENGTH from the environment and default to 8. Centralising it matters for three reasons:
- Manifest tooling. Scripts that parse hashed filenames with a regex (
/-([0-9a-f]{8})\./) silently stop matching a package that ships 16-character hashes. - Integrity policy. If you generate SRI digests, mixed hash functions across packages complicate the audit trail even though SRI itself is independent of the filename hash.
- Deduplication. Two packages can only share a hashed vendor chunk if they hash it identically. A
xxhash64:8copy and anmd4:20copy of the same React build are two different URLs and two downloads.
Set it once in the workspace root .npmrc-adjacent env file or, better, in turbo.json’s globalEnv so changing it invalidates every task hash:
{
"$schema": "https://turbo.build/schema.json",
"globalEnv": ["CDN_ORIGIN", "ASSET_HASH_LENGTH", "NODE_ENV"]
}
Bumping ASSET_HASH_LENGTH from 8 to 12 is a full-workspace re-hash. Do it in a dedicated release, not alongside feature work, and keep the old files on the origin through the retention window described in Step 8.
Step 5 — What an internal dependency change does to consumer hashes
This is the mechanic that surprises people. In a workspace, @acme/ui is not an external dependency resolved from a registry — it is source code compiled into every consumer’s bundle. Editing one line in packages/ui/src/Button.tsx changes the bytes of every app that imports Button, and therefore changes those apps’ content hashes. Apps that do not import it are untouched.
Three practical consequences follow.
A shared package with a wide import surface is a hash amplifier. If @acme/ui re-exports everything from one barrel file and each app imports the barrel, a change to any component re-hashes every app’s vendor and entry chunks even when tree shaking would have dropped it. Export granular entry points (@acme/ui/button) so the blast radius matches the actual dependency.
Version numbers in package.json are irrelevant to the hash. Workspace protocol dependencies ("@acme/ui": "workspace:*") resolve to the local source. Bumping the version field changes nothing in the bundle; editing the source changes everything. Do not use version bumps as a proxy for “did the asset change”.
The propagation is deterministic, so it is testable. A CI job that builds the workspace twice from a clean checkout and diffs the manifests should produce an empty diff. Any non-empty diff means an input outside the dependency graph — a timestamp, a random build id, an unpinned transitive dependency — leaked into the bundle.
Step 6 — Turborepo task graph and remote caching
Turborepo’s job here is to build packages in dependency order and to skip work whose inputs have not changed. The critical correctness requirement is that outputs covers everything the deploy step reads — most importantly the manifest. A cache hit that restores dist/*.js but not dist/manifest.json leaves the deploy pointing at files it cannot name.
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": [
"pnpm-lock.yaml",
".nvmrc",
"tsconfig.base.json",
"packages/build-config/**"
],
"globalEnv": ["CDN_ORIGIN", "ASSET_HASH_LENGTH", "NODE_ENV"],
"remoteCache": {
"enabled": true,
"signature": true
},
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": [
"$TURBO_DEFAULT$",
"!**/*.test.ts",
"!**/*.stories.tsx"
],
"outputs": [
"dist/**",
"dist/.vite/**",
"!dist/**/*.map"
],
"env": ["VITE_API_BASE", "VITE_SENTRY_DSN"],
"outputLogs": "new-only"
},
"deploy": {
"dependsOn": ["build"],
"cache": false,
"persistent": false
}
}
}
Four details in that file carry real weight:
dependsOn: ["^build"]makes the caret mean “the same task in every internal dependency”. Without it,@acme/checkoutcan build against a stalepackages/ui/dist."dist/.vite/**"is listed explicitly because Vite 5 writesmanifest.jsoninto a dot-directory and many glob implementations skip dotfiles underdist/**.envdeclares the variables that get inlined into the bundle. AnyVITE_-prefixed value that reachesimport.meta.envbecomes bundle bytes and therefore changes the content hash; if it is not in the task hash, a cache hit will hand you a bundle built with the wrong API base. The full failure taxonomy is in stopping hash drift between Turborepo cache hits.remoteCache.signature: truerequiresTURBO_REMOTE_CACHE_SIGNATURE_KEYand makes the cache reject artifacts it cannot authenticate. Treat an unsigned shared remote cache as an unsigned artifact registry, because that is exactly what it is.
The equivalent Nx configuration expresses the same three ideas — dependency-ordered tasks, declared inputs, declared outputs — with different names:
{
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"production": [
"default",
"!{projectRoot}/**/*.spec.ts",
"!{projectRoot}/**/*.stories.tsx"
],
"sharedGlobals": [
"{workspaceRoot}/pnpm-lock.yaml",
"{workspaceRoot}/.nvmrc",
"{workspaceRoot}/tsconfig.base.json",
{ "env": "CDN_ORIGIN" },
{ "env": "ASSET_HASH_LENGTH" }
]
},
"targetDefaults": {
"build": {
"cache": true,
"dependsOn": ["^build"],
"inputs": ["production", "^production"],
"outputs": ["{projectRoot}/dist"]
}
}
}
Nx’s { "env": "CDN_ORIGIN" } input entries are the direct analogue of Turborepo’s globalEnv. Both tools default to ignoring environment variables, which is the single most common source of a cache hit that produces the wrong hashes.
Step 7 — Build and upload each package to its own prefix
Keep the deploy script per-package and prefix-scoped. It should never be able to write outside its namespace, which is enforceable with an IAM policy on the upload credential rather than trusted to the script.
#!/usr/bin/env bash
# scripts/deploy-package.sh <package-dir>
set -euo pipefail
PKG_DIR="$1"
NS=$(node -p "require('$PKG_DIR/package.json').name.split('/').pop()")
DIST="$PKG_DIR/dist"
test -f "$DIST/manifest.json" || { echo "no manifest in $DIST"; exit 1; }
# 1. Hashed assets first, immutable, never deleted in this pass.
aws s3 sync "$DIST" "s3://acme-cdn/assets/$NS/" \
--exclude "manifest.json" \
--exclude "*.map" \
--cache-control "public, max-age=31536000, immutable" \
--metadata-directive REPLACE
# 2. Manifest last, revalidated on every request. It is the pointer that
# flips the app onto the new hashes, so it must land after the assets.
aws s3 cp "$DIST/manifest.json" "s3://acme-cdn/assets/$NS/manifest.json" \
--cache-control "public, max-age=30, must-revalidate" \
--metadata-directive REPLACE
echo "deployed $NS"
Note the absence of --delete. In a monorepo with independent deploys, deleting unreferenced objects during the deploy is the fastest way to break a user who is mid-session on the previous release. Retention is a separate, delayed job — Step 8.
Step 8 — Independent deploys and asset retention
Under lockstep deploys, “how long do I keep old assets” has an easy answer: until the previous release’s HTML has aged out of every cache. Under independent deploys, each namespace has its own clock, and the retention window is set by the slowest-moving consumer of that namespace, not by the package’s own deploy cadence.
A workable retention policy for the namespaced layout:
- Never delete during a deploy. Deletion runs as a scheduled sweep.
- The sweep keeps every object referenced by the last N manifests of its own namespace, where N covers your rollback horizon (5 is typical for daily deploys).
- The sweep additionally keeps anything modified in the last 30 days regardless of manifest references, which covers users on very stale HTML and any long-lived service worker precache.
- Objects under
/assets/shared/are kept if any namespace’s retained manifests reference them.
That last rule is the one people get wrong: a shared vendor chunk can be unreferenced by the app that deployed it and still be live for a different app. Sweep shared prefixes with a union of all manifests, never per-app.
Step 9 — One copy of React, or one per app?
The shared-dependency question has a clean decision rule, and it hinges on whether the apps are ever on screen at the same time.
| Situation | Ship one shared copy | Ship one copy per app |
|---|---|---|
| Apps are separate page loads (navigating between them) | Warm cache hit on second app | Second download of near-identical bytes |
| Apps composed on one page (micro-frontends) | Required for React context/hooks to work | Two React runtimes; hooks throw |
| Apps upgrade React independently | Blocked — upgrade is a coordinated release | Free, each app moves on its own |
| Rollback of one app | May force a shared-chunk rollback | Fully isolated |
| Total bytes across four apps | ~51 KB gzip | ~180 KB gzip |
If you share, publish the shared runtime to its own namespace and treat it as an independently versioned artifact:
// packages/build-config/shared-vendor.js
// Emit React into /assets/shared/ so every app resolves the same hashed URL.
export const sharedVendorOutput = {
manualChunks(id) {
if (id.includes('node_modules/react-dom') || id.includes('node_modules/react/')) {
return 'react-runtime';
}
return null;
},
chunkFileNames(chunkInfo) {
return chunkInfo.name === 'react-runtime'
? '../shared/[name]-[hash:8].js'
: '[name]-[hash:8].js';
},
};
The shared chunk only deduplicates if every app produces the same hash for it, which requires the same React version, the same bundler version, and the same minifier settings. Any drift and you have two shared chunks with different hashes and no sharing at all. When apps are composed at runtime rather than merely linked, the negotiation moves into the loader — see sharing hashed chunks across Module Federation remotes.
Verification
Run these after a workspace build and before the first deploy.
# 1. Every package emitted a manifest, and nothing landed in the bare root.
find apps packages -maxdepth 3 -name manifest.json -path '*/dist/*' -print
find apps -path '*/dist/*' -name '*.js' -newer package.json | head
# 2. No two packages emit the same *full* asset path.
find apps -path '*/dist/*' -name '*.js' \
| sed -E 's#^apps/([^/]+)/dist/#\1/#' \
| sort | uniq -d
# Expected: empty output.
# 3. Confirm every filename carries an 8-hex-char hash.
find apps -path '*/dist/*' -name '*.js' ! -name '*-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].js' -print
# Expected: empty output.
# 4. Prove the build is reproducible across two clean runs.
pnpm turbo run build --force
find apps -path '*/dist/*' -type f -exec sha256sum {} + | sort -k2 > /tmp/build-a.txt
pnpm turbo run build --force
find apps -path '*/dist/*' -type f -exec sha256sum {} + | sort -k2 > /tmp/build-b.txt
diff /tmp/build-a.txt /tmp/build-b.txt && echo "reproducible"
# 5. Inspect what actually went into each task hash.
pnpm turbo run build --dry=json | jq '.tasks[] | {package, task, hash, envMode}'
# 6. After deploy: headers must differ for assets and manifest.
curl -sI https://cdn.example.com/assets/checkout/main-a1b2c3d4.js | grep -i cache-control
# Expected: cache-control: public, max-age=31536000, immutable
curl -sI https://cdn.example.com/assets/checkout/manifest.json | grep -i cache-control
# Expected: cache-control: public, max-age=30, must-revalidate
Step 4 is the one to wire into CI permanently. A workspace that stops building reproducibly usually did so because of a change nobody associated with hashing — a new plugin that stamps a build date, a dependency that reads Date.now() at module scope, or a lockfile that resolved differently on the CI runner.
Edge Cases and Known Issues
A package renamed, and its old prefix is orphaned. Renaming @acme/docs to @acme/handbook moves output to /assets/handbook/. The old prefix still serves the previous release to anyone holding stale HTML. Keep /assets/docs/ alive for a full retention window, then delete the prefix in one operation. Do not add a redirect from the old prefix to the new one — the hashed filenames differ, so the redirect targets do not exist.
Two packages with the same directory basename. apps/admin/dashboard and apps/support/dashboard both derive the namespace dashboard if you key off the directory instead of the package name. Always derive from package.json’s name field, and make the derivation throw on a duplicate rather than silently overwrite.
Turborepo restored a cache hit built with a different CDN_ORIGIN. The origin is baked into every emitted URL, so a build cached from a staging run will serve staging URLs in production. This is why CDN_ORIGIN belongs in globalEnv. Symptoms and diagnosis are covered in stopping hash drift between Turborepo cache hits.
Source maps leak the workspace layout. sourcemap: 'hidden' emits .map files without the //# sourceMappingURL comment, so they are uploaded for error reporting but not fetched by browsers. If you upload maps to the public prefix, they expose absolute paths from the CI runner. Upload them to a private bucket instead, and exclude them from the CDN sync as the deploy script above does.
A shared chunk changed but only one app redeployed. Under the /assets/shared/ scheme, the app that redeploys writes a new hashed shared chunk and points its own manifest at it. The other apps keep referencing the old hash, which is still on the origin. This is correct behaviour, not a bug — but it means two versions of the shared runtime are live simultaneously, which is fine for separate page loads and broken for runtime composition on one page.
CSS code splitting multiplies the chunk count. Vite emits one CSS file per async chunk by default. In a workspace of a dozen apps this can push the flat chunk count past 5,000, which is the point where 8-character hashes stop being obviously safe and 12 becomes the better default.
Per-prefix IAM is easy to get subtly wrong. An S3 policy granting s3:PutObject on arn:aws:s3:::acme-cdn/assets/checkout* also grants writes to /assets/checkout-staging/. Use the trailing slash: arn:aws:s3:::acme-cdn/assets/checkout/*.
Nx and Turborepo disagree about what a “project” is. Nx infers targets from plugins and can consider files outside the project root part of a project’s inputs. If you migrate between the tools, re-verify the reproducibility check in the Verification section rather than assuming the input sets are equivalent.
Performance Impact
| Operation | Typical cost | Notes |
|---|---|---|
| Cold full-workspace build (12 apps) | 6–14 min | Dominated by per-app Rollup passes; scales with app count, not repo size |
| Warm Turborepo build, all cache hits | 8–25 s | Almost entirely artifact download and extraction |
| Remote cache artifact download | 0.5–3 s per package | Proportional to dist/ size; signature verification adds under 50 ms |
Namespaced s3 sync per package |
4–20 s | Only changed hashes upload; unchanged immutable objects are skipped |
| Retention sweep across 12 namespaces | 20–90 s | LIST-bound; use S3 Inventory instead of LIST above ~500k objects |
| Adding 4 hex chars to every hash | +4 bytes per filename | Negligible on the wire; the real cost is a one-time full re-hash |
The dominant win is task-graph caching, not upload optimisation. A workspace where only apps/checkout changed should rebuild exactly one package and restore eleven from cache — turning a 10-minute pipeline into a 40-second one. That win evaporates the moment an input is over-declared: putting the whole workspace root in globalDependencies means every lockfile touch busts every package’s cache.
The second-order win is CDN cache retention. Because hashed URLs are immutable and namespaced, deploying checkout does not evict account’s objects from any edge cache. Under a flat namespace with purges, every deploy cold-starts the edge for every app. Getting the Cache-Control policy right per prefix is covered in the CI/CD asset pipeline integration guide.
FAQ
Should every package use the same hash length, or can a large app use longer hashes?
Use the same length everywhere. Mixed lengths break filename-parsing tooling, prevent shared chunks from deduplicating, and make it impossible to state a single collision bound for the origin. If one app genuinely needs 16 characters because it emits thousands of chunks, raise the whole workspace to 16 — the cost is four bytes per filename, which is noise next to the operational cost of a mixed policy.
If two packages emit a file with the same hash, is that a collision?
Only if they share a namespace. Under /assets/<package>/, identical hashes in different prefixes are different URLs and cache entries, and the coincidence simply means the two packages produced byte-identical output. Under a flat /assets/, it is a genuine collision risk and the reason the flat layout requires lockstep deploys.
Does bumping an internal package’s version number change consumer hashes?
No. Workspace dependencies resolve to local source, so the version field never reaches the bundle. Only the package’s compiled bytes matter. This is also why you cannot use “the version didn’t change” as evidence that an app’s assets are unchanged — check the manifest, not the changelog.
Do I need a merged manifest at the repo root, or is one per package enough?
One per package is the default and keeps deploys independent, because a merged root manifest is a single file that concurrently deploying apps race to overwrite. A small root index that maps package name to manifest URL gives you a discovery point without the contention. The full trade-off — lookup cost, rollback granularity, collision risk — is worked through in per-package vs global asset manifests in a monorepo, and the general manifest format question in asset manifest generation.
Related
- Build Tool & Framework Asset Pipeline Integration — parent overview of build-time hashing across every major bundler
- Module Federation remote chunk sharing — runtime composition, singleton negotiation, and why
remoteEntrymust not be immutable - Turborepo cache-hit hash drift — task-hash inputs, output globbing, and proving a restored artifact is byte-identical
- Manifest scope in a workspace — per-package versus merged root manifests and their rollback granularity
- Lockfile and Node version pinning — the prerequisite for identical hashes on every machine in the workspace