webpack-manifest-plugin vs webpack-assets-manifest
Three packages claim the same job in a Webpack 5 build: webpack-manifest-plugin, webpack-assets-manifest, and the zero-dependency option of parsing stats.json in a post-build script. They produce different JSON, cost different amounts of build time, and fail in different ways. This page picks between them on the axes that actually change a deployment: what shape lands on disk, whether integrity digests come for free, whether entry points arrive grouped, and how much the choice costs you in build seconds and maintenance.
The Decision in One Question
Ask what the manifest consumer needs beyond a name-to-URL lookup. If the answer is “nothing”, the lightest option wins and the rest is over-engineering. If the answer includes SRI digests or ordered per-entry file groups, one package gives them to you as booleans and the other makes you write a callback.
What Each One Writes to Disk
The two plugins disagree about the default shape, and that disagreement propagates into every helper you write against them.
The literal files, from the same compilation
Both of the following came out of one two-entry build with runtimeChunk: 'single' and splitChunks: { chunks: 'all' }. Nothing is elided.
{
"checkout.css": "/static/checkout.4d1f0a72.css",
"checkout.js": "/static/checkout.c0aa9163.js",
"runtime.js": "/static/runtime.31b7e5d4.js",
"storefront.css": "/static/storefront.9b4e7f02.css",
"storefront.js": "/static/storefront.3f8a2c1d.js",
"vendors.js": "/static/vendors.7f3e91c0.js"
}
That is webpack-manifest-plugin with defaults. Keys are the pre-hash names, values are publicPath plus the hashed name, and there is exactly one level. Now the same compilation through webpack-assets-manifest with entrypoints: true and integrity: true:
{
"version": "9f2c41ab",
"checkout.css": "/static/checkout.4d1f0a72.css",
"checkout.js": "/static/checkout.c0aa9163.js",
"runtime.js": "/static/runtime.31b7e5d4.js",
"storefront.css": "/static/storefront.9b4e7f02.css",
"storefront.js": "/static/storefront.3f8a2c1d.js",
"vendors.js": "/static/vendors.7f3e91c0.js",
"entrypoints": {
"checkout": {
"assets": {
"css": ["/static/checkout.4d1f0a72.css"],
"js": [
"/static/runtime.31b7e5d4.js",
"/static/vendors.7f3e91c0.js",
"/static/checkout.c0aa9163.js"
]
}
},
"storefront": {
"assets": {
"css": ["/static/storefront.9b4e7f02.css"],
"js": [
"/static/runtime.31b7e5d4.js",
"/static/vendors.7f3e91c0.js",
"/static/storefront.3f8a2c1d.js"
]
}
}
},
"integrity": {
"/static/runtime.31b7e5d4.js": "sha384-oUj9K1yQmT2xPd6bV0hR8sLwZ3nF4eC7aG5iX0pM9tYbQ2vN8kJ1dS6rH3uL7wA0",
"/static/vendors.7f3e91c0.js": "sha384-Bq7nR2sK9wX4cM1zP0vT6yL8hJ3fD5gU7aE2iO4bN6mQ8rV1tY9kC3xZ5sW0pG2d",
"/static/checkout.c0aa9163.js": "sha384-Tz3mV8pL1qX6cH0dN4rY7sK2fB9gJ5wU3aO1iE6bM8nQ4tR7vC2xZ0kS5yW9pD1e",
"/static/storefront.3f8a2c1d.js": "sha384-Xw5kM2nP8vT1cQ4dR6yH0sL9fJ3gB7aU2iN5bO1eE8mZ4tV6rC0xY7kS3pW9qD2f"
}
}
The flat pairs survive, so a helper written against the first file keeps working against the second. What arrives on top is the part you cannot reconstruct from a flat map: the load order.
Entry point grouping in a multi-entry app
A flat manifest cannot answer “which files does the checkout page need?” — vendors.js and runtime.js are not named after any entry, and nothing in the key tells you they belong to both. With one entry per application you get away with hard-coding the list. With five entries and shared chunks you cannot: splitChunks decides membership at build time and changes it whenever a shared import moves.
The assets.js array above is emitted in dependency order — runtime first, then shared chunks, then the entry’s own code — which is the order the tags must appear in. Emit it as-is:
// server/render-tags.js — turn one entrypoint group into ordered tags
const manifest = require('../dist/asset-manifest.json');
function tagsFor(entry) {
const group = manifest.entrypoints[entry].assets;
const css = (group.css || []).map((href) => `<link rel="stylesheet" href="${href}">`);
const js = (group.js || []).map((src) => {
const digest = manifest.integrity[src];
const sri = digest ? ` integrity="${digest}" crossorigin="anonymous"` : '';
return `<script src="${src}"${sri} defer></script>`;
});
return { head: css.join('\n'), body: js.join('\n') };
}
module.exports = { tagsFor };
webpack-manifest-plugin reaches the same result through the third argument to generate, shown further down. The difference is not capability — it is that one of them ships the ordering as data and the other makes you preserve it in your own code.
Comparison Matrix
| Axis | webpack-manifest-plugin | webpack-assets-manifest | Parse stats.json |
|---|---|---|---|
| Default output shape | Flat name → url |
Flat name → url |
Whatever you write |
| Entry point grouping | Third argument to generate |
entrypoints: true |
Read stats.entrypoints |
| Integrity digests | Write them in generate |
integrity: true |
Hash the files yourself |
| Byte sizes | Not exposed; stat the file |
assets records carry size |
stats.assets[].size |
| Custom shape | generate(seed, files, entrypoints) |
customize + transform hooks |
Total freedom |
| Public path handling | publicPath option |
publicPath option |
Prepend manually |
| Dev-server output | writeToFileEmit: true |
Writes by default | Not applicable |
| Sorting / stable key order | sort option |
sortManifest option |
Sort yourself |
| Merge across parallel builds | Manual | merge: true |
Manual |
| Build overhead | Lowest | Low; higher with integrity |
Highest — full stats emission |
| Artefact you must ship | Small JSON | Small JSON | Multi-megabyte intermediate |
| Maintenance | Stable, small API | Stable, wider API | Yours forever |
| Bus factor if it breaks | Swap packages | Swap packages | Debug your own script |
Two rows dominate the choice. Integrity is a one-word config change in webpack-assets-manifest and a dozen lines of crypto in the alternative. Build overhead is where stats.json disqualifies itself: serialising the full stats object on a large application is measured in seconds, not milliseconds, and it happens on every single CI run.
Configuration: the Flag-Driven Route
webpack-assets-manifest reaches the richer shape without a callback. This is the configuration to copy when your consumers need both grouping and digests.
npm install --save-dev webpack@5.89 webpack-assets-manifest@5
// webpack.config.js
const path = require('node:path');
const WebpackAssetsManifest = require('webpack-assets-manifest');
module.exports = {
mode: 'production',
entry: { storefront: './src/storefront.js', checkout: './src/checkout.js' },
output: {
path: path.resolve(__dirname, 'dist'),
publicPath: '/static/',
filename: '[name].[contenthash:8].js',
chunkFilename: '[name].[contenthash:8].chunk.js',
clean: true,
},
optimization: {
moduleIds: 'deterministic',
runtimeChunk: 'single',
splitChunks: { chunks: 'all' },
},
plugins: [
new WebpackAssetsManifest({
output: 'asset-manifest.json',
publicPath: true,
writeToDisk: true,
entrypoints: true,
entrypointsKey: 'entrypoints',
integrity: true,
integrityHashes: ['sha384'],
integrityPropertyName: 'integrity',
sortManifest: true,
transform(assets) {
return { version: process.env.GIT_SHA || 'local', ...assets };
},
}),
],
};
integrityHashes: ['sha384'] pins the digest algorithm rather than emitting all three that the SRI specification allows. One algorithm keeps the manifest small and avoids the browser choosing an algorithm you did not intend; the reasoning behind picking SHA-384 is in generating SRI hashes in your build pipeline.
sortManifest: true matters more than it looks. Without deterministic key order the manifest bytes change between builds even when every hash is identical, which makes the file useless for diffing and defeats content-addressed caching of the manifest itself.
What integrity: true actually produces
The digests land in a separate top-level object keyed by the emitted path, not by the logical name — so the lookup in a template helper is manifest.integrity[url], where url is the value you already read out of the flat map or the entrypoint group. Three properties of that object trip people up:
- The key includes
publicPath. If you later change the CDN base, every key changes even though no bytes did. Keep the digest lookup keyed off whatever the manifest emits rather than reconstructing the URL yourself. - Listing more than one algorithm is legal and usually wrong.
integrityHashes: ['sha256', 'sha384']emits a space-separated pair; the browser uses the strongest it recognises and ignores the rest, so the shorter one only adds bytes. - The digest covers the identity bytes, not the transfer bytes. A gzip or Brotli sibling produced by a compression plugin does not need its own digest, because the browser validates after decoding. A plugin that rewrites the asset — a licence banner, a source-map URL fixup — does invalidate it, which is the ordering problem covered below.
SRI also requires the request be made in CORS mode, so every hashed script gets crossorigin="anonymous" and the CDN must answer with a matching Access-Control-Allow-Origin; the header combinations are laid out in SRI with crossorigin and CDN CORS headers.
Configuration: the Callback Route
webpack-manifest-plugin keeps its core narrow and hands you generate for anything else. Use it when your consumer wants a bespoke schema — for example a manifest keyed by route rather than by file.
// webpack.config.js — route-keyed manifest via generate()
const path = require('node:path');
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
const ROUTE_FOR_ENTRY = {
storefront: '/',
checkout: '/checkout',
};
module.exports = {
mode: 'production',
entry: { storefront: './src/storefront.js', checkout: './src/checkout.js' },
output: {
path: path.resolve(__dirname, 'dist'),
publicPath: '/static/',
filename: '[name].[contenthash:8].js',
clean: true,
},
plugins: [
new WebpackManifestPlugin({
fileName: 'route-manifest.json',
publicPath: '/static/',
sort: (a, b) => a.name.localeCompare(b.name),
generate: (seed, files, entrypoints) => {
const routes = {};
for (const [entryName, assetNames] of Object.entries(entrypoints)) {
const route = ROUTE_FOR_ENTRY[entryName];
if (!route) continue;
routes[route] = {
js: assetNames.filter((n) => n.endsWith('.js')).map((n) => `/static/${n}`),
css: assetNames.filter((n) => n.endsWith('.css')).map((n) => `/static/${n}`),
};
}
return { routes, files: files.reduce((acc, f) => ({ ...acc, [f.name]: f.path }), seed) };
},
}),
],
};
The entrypoints argument arrives already ordered — runtime chunk first, then shared chunks, then the entry’s own code. Preserve that order when you emit tags; re-sorting it alphabetically is a classic way to produce a bundle that throws before the runtime has loaded.
The publicPath: 'auto' Trap
Webpack 5 defaults output.publicPath to 'auto', which is not a URL. It instructs the runtime to derive the base at execution time from document.currentScript, so dynamic imports resolve relative to wherever the entry script was loaded from. That is excellent for the runtime and useless for a manifest, because a manifest is written at build time and has no currentScript.
Both plugins default to reading compilation.options.output.publicPath, and neither special-cases the literal string. The result is a manifest full of values like auto/storefront.3f8a2c1d.js, which every consumer then requests as a relative path from whatever page it appears on. The failure is silent in a single-level site and obvious the moment a template at /checkout/ renders it.
Set the plugin’s own publicPath explicitly and leave the runtime on 'auto':
// Runtime keeps 'auto'; the manifest gets a real, absolute base.
const CDN_BASE = process.env.CDN_BASE || '';
new WebpackAssetsManifest({
output: 'asset-manifest.json',
entrypoints: true,
integrity: true,
publicPath: (filename) => `${CDN_BASE}/static/${filename}`,
});
// The equivalent for webpack-manifest-plugin:
new WebpackManifestPlugin({
fileName: 'manifest.json',
publicPath: `${CDN_BASE}/static/`,
});
publicPath: '' is the right value when the consumer prepends its own base — a server-side helper that already knows the CDN host, for example. Whichever you pick, pick one place: a manifest with absolute URLs consumed by a helper that also prepends a base produces https://cdn.example.com/https://cdn.example.com/static/..., and that bug survives code review surprisingly often.
Behaviour Under watch and the Dev Server
In watch mode both plugins regenerate the manifest on every rebuild, from that rebuild’s compilation. Under webpack-dev-server the output lives in memory, so a separate backend process reading dist/manifest.json from the filesystem sees a stale file — or no file at all on a clean checkout. That is what the write-to-disk options exist for:
// webpack-assets-manifest — force a real file even when serving from memory
new WebpackAssetsManifest({ output: 'asset-manifest.json', writeToDisk: true });
// webpack-manifest-plugin — same effect, different option name
new WebpackManifestPlugin({ fileName: 'manifest.json', writeToFileEmit: true });
Two follow-on issues are worth knowing before they bite. First, neither write is atomic: a backend that reads the file at the wrong millisecond gets a truncated document. In development, catch the parse error and retry once rather than crashing the server. Second, in a multi-compiler setup — a client build and an SSR build in one webpack.config.js array — each compilation only knows its own assets, so the second write overwrites the first. webpack-assets-manifest handles this with merge: true; webpack-manifest-plugin handles it by passing the same mutable object as seed to both plugin instances so they accumulate into one manifest.
Hash placeholders are usually disabled in development anyway, so watch-mode manifests map names to themselves. Do not let a consumer depend on the hash being present — read the value, never parse it.
Plugin Ordering: Whoever Writes Last Wins
Webpack 5 sequences asset work through compilation.hooks.processAssets, and each tap declares a stage. Minifiers run at the optimisation stages, compression plugins run at the transfer stage, and manifest plugins deliberately run at the reporting stage or later so they observe final bytes. A plugin that tampers with an asset after the manifest has hashed it produces a digest describing bytes nobody will ever download.
The other ordering symptom is cosmetic but noisy: a compression plugin adds app.3f8a2c1d.js.gz and app.3f8a2c1d.js.br as real assets, and both plugins happily record them. Source maps do the same. Filter them out at the manifest boundary rather than in every consumer:
// webpack-assets-manifest — return false to drop an entry
new WebpackAssetsManifest({
output: 'asset-manifest.json',
customize(entry) {
return /\.(gz|br|map)$/.test(entry.key) ? false : entry;
},
});
// webpack-manifest-plugin — filter operates on the file descriptor
new WebpackManifestPlugin({
fileName: 'manifest.json',
filter: (file) => !/\.(gz|br|map)$/.test(file.name),
});
Verification
One command tells you whether the plugin produced the shape your consumer expects, and whether its digests describe the bytes that were actually written:
npx webpack --mode production >/dev/null 2>&1
# Shape: does every entrypoint group resolve to files that exist?
jq -r '.entrypoints | to_entries[] | .value.js[]' dist/asset-manifest.json \
| sed 's|^/static/|dist/|' \
| xargs -I{} test -f {} \
&& echo "entrypoint files present"
# Digests: recompute one and compare against the manifest
FILE=$(jq -r '.entrypoints.storefront.js[-1]' dist/asset-manifest.json | sed 's|^/static/|dist/|')
KEY=$(basename "$FILE" | sed 's/\.[a-f0-9]\{8\}\././')
echo "expected: $(jq -r --arg k "$KEY" '.integrity[$k]' dist/asset-manifest.json)"
echo "actual: sha384-$(openssl dgst -sha384 -binary "$FILE" | openssl base64 -A)"
If the two digest lines differ, something rewrote the file after the plugin hashed it — a minifier running in a later plugin phase, a compression step that overwrote in place, or a post-build formatter. That ordering bug is invisible until a browser blocks the script, and it is the reason the digest check belongs in CI validation rather than in a developer’s memory.
FAQ
Can both plugins run in the same build?
Yes, and it is occasionally the pragmatic answer during a migration: point them at different fileName/output values so they do not overwrite each other, ship both files, and cut consumers over one at a time. Keep it temporary — two sources of truth for the same hashes is exactly the state a manifest exists to prevent.
Why does my manifest key still contain a hash?
Because the key is derived from the chunk name, and something in your config put the hash there — usually a [name] template that already interpolates [contenthash], or an asset module whose generator.filename sets the name. Fix the chunk naming rather than stripping the hash in a customize callback; a key that changes on every content change defeats the entire lookup.
Do I need sort if I already have sortManifest?
No — they are the same idea in the two packages, and you only ever set the one belonging to the plugin you use. What you should not do is skip both. Unsorted output makes the manifest diff between two identical builds non-empty, which turns every manifest review into noise and hides the one line that actually changed.
Is 8 hex characters of hash enough for the manifest to stay unambiguous?
For an application in the hundreds of files, yes; 8 characters is the default in every example here. Past a few thousand chunks — a large monorepo, or a build that emits per-locale bundles — move to 12 or 16 to keep the collision probability negligible. The manifest itself does not care about the length; the CDN cache does.
Which one should a brand-new project pick?
webpack-assets-manifest, unless you have a concrete reason not to. Entry grouping and integrity are the two things projects reach for six months in, and reaching for them later means rewriting a generate callback you had already tested. The migration cost in the other direction — dropping to the smaller plugin once you know you never needed grouping — is a five-line diff.
When to Reconsider the Choice
Prefer webpack-manifest-plugin when the manifest feeds exactly one consumer with an unusual schema, when you already have a hashing utility you want to reuse across bundlers, or when you want the smallest possible plugin surface in a config that other teams inherit.
Prefer webpack-assets-manifest when SRI is a requirement, when multiple parallel Webpack configurations must merge into one manifest (merge: true handles the client/server split in an SSR build), or when you would otherwise be reimplementing entry grouping and digest computation by hand.
Reach for stats.json only when you need information neither plugin surfaces — module-level reasons, chunk parent relationships, per-module sizes for a treemap. Even then, emit it behind a flag (webpack --json --profile) in a dedicated analysis job rather than in the deploy build, and keep the deploy build on a plugin. The multi-megabyte artefact is not something you want travelling through your release pipeline.
Reconsider all three if you are migrating off Webpack. Rollup and Vite expose the bundle object directly, so a fifteen-line plugin replaces the entire question — see the Rollup manifest recipe and the esbuild plugin section. Whichever you land on, keep the emitted shape identical across bundlers so the server-side helper never has to care which one built the release.
One shared failure applies to every option: a manifest is only as trustworthy as the hashes in it. If your build is not reproducible, all three approaches will faithfully record a different set of filenames on every run, and no amount of plugin configuration will fix it. Establish reproducibility first with the deterministic build outputs guide, then pick a plugin.
Related
- Asset manifest generation — parent guide covering manifest contents, bundler shapes and rollback
- Resolving hashed URLs at request time — what a template helper does with these shapes
- Failing CI on a broken manifest — asserting digests and orphan files before deploy
- Build-time SRI digest generation — choosing an algorithm and applying the attribute
- Pipeline wiring for asset builds — where manifest emission sits in a release job