angular-payload-analyzer
v0.0.1
Published
Visualises how an Angular build's initial request payload is composed — interactive standalone Sankey report, revision comparison via git worktree.
Maintainers
Readme
angular-payload-analyzer (apa)
What the first request of an Angular app costs, and why.
angular-payload-analyzer builds an Angular application with stats enabled and renders a
standalone, interactive HTML report: a Sankey diagram from the loading
phase (initial / lazy) over the emitted chunks to modules (workspace projects
and npm packages) down to single files. For every file it shows the import
chain that causes it to be loaded and concrete hints on what to change. With
git it compares two revisions in temporary worktrees.
Install
npm install --global angular-payload-analyzer # global `apa` and `angular-payload-analyzer`
apa info # run inside any Angular / Nx workspaceAlternatively without a global install:
npx angular-payload-analyzer analyze # one-off
npm install --save-dev angular-payload-analyzer # per workspace → `npx apa …`Requires Node ≥ 20.11. The CLI has no runtime dependencies; the viewer is a dependency-free SVG Sankey inlined into each report (no D3, no CDN, works offline, strict CSP without network access).
Usage
apa info # workspace kind + version, Angular version, package manager, git, apps
apa analyze # build the (only / chosen) app with --stats-json, write the HTML report
apa analyze shop -c staging # explicit project and configuration
apa analyze --skip-build # reuse the stats of the last build
apa analyze --stats dist/shop/stats.json
apa compare # main (else master/origin/HEAD) vs. current working tree incl. uncommitted changes
apa compare --base v1.4.0 --head HEAD --json cmp.json
apa --helpWith several applications and no project argument, apa asks interactively
(in a TTY) or lists the candidates.
Guard (apa check)
Snapshot testing for the payload, modeled on Jest snapshots:
apa check shop # first run: records .payload-snapshots/shop-production.json — commit it
apa check shop --ci # in CI: fails (exit 1) on changes, never writes a snapshot
apa check shop -u # accept an intended change: rewrite the snapshot, commit it with the change
apa check shop --ci --max-initial-growth 2kB # optionally also limit initial growth (bytes, kB, MB or %)The snapshot lists every module (workspace project, npm package) with its initial and lazy sizes — no timestamps, git revisions or hashed chunk names, so its git diff shows exactly what changed. The check fails when
- a module appears that is not in the snapshot (initial or lazy), or
- a known module becomes part of the initial payload (it was lazy only), or
- the initial total grows beyond
--max-initial-growth(when given), or - the snapshot belongs to another project, configuration or bundler, or
- gzip/brotli are compared while only one side measured compression (use
--metric rawor record again).
The accept hint repeats every option you passed (--snapshot, --stats,
--cwd, …) plus -u; -u also replaces a corrupt or outdated snapshot file.
--stats inside a workspace uses the target's default configuration, so it
checks against the same snapshot as a build would.
Size changes of known modules and modules that disappeared are printed as notes
(the latter with a hint to refresh the snapshot). Generated chunk overhead is
ignored. --metric raw|gzip|brotli selects the size shown and used for the
limit (default gzip). Example from the DDD scenario (snapshot at c0, check at c1):
FAIL flights (production) against .payload-snapshots/flights-production.json
initial 70.4 kB gzip (+672 B vs snapshot)
✕ @angular/forms is now part of the initial payload: 282 B (was lazy only)
✕ New module shared-ui-design-system (library): 118 B initial
• @angular/core initial +139 B (31.4 kB → 31.6 kB)
If this change is intended, update the snapshot: apa check flights -uWhat is detected
| Aspect | Source |
|------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Workspace kind | nx.json → NX, otherwise angular.json → Angular CLI (searched upwards from --cwd) |
| Versions | installed nx / @angular/cli and @angular/core from node_modules, declared ranges as fallback |
| Package manager | packageManager field, then lockfile |
| Applications | angular.json projects or NX project.json files; build target, builder, configurations, default configuration, output path |
| NX build targets | project.json first; when an application has no usable build target there (executor in nx.json targetDefaults, or a target inferred by a plugin), the NX project graph (nx graph --file, no daemon) supplies the resolved targets — only then, because NX takes seconds |
| Applications | only projects with an Angular builder (@angular/build, @angular-devkit/build-angular, @nx/angular, @angular-builders/*, @ngx-env/builder); Node apps such as @nx/esbuild:esbuild are listed as skipped |
| Stats file | stats.json (Angular ≤ 21), browser-stats.json (Angular 22); the newest wins. Only .js/.css outputs that were actually emitted count (component styles inlined into JS, the SSR server bundle and media/ assets are ignored) |
| Initial set | boot entries (main, polyfills, injected global styles/scripts) plus their static imports; index.html / index.csr.html decides whether a global bundle is injected (inject: false bundles are lazy). Webpack: the chunk's initial flag |
| Bundler | builder id (…:application / browser-esbuild → esbuild, …:browser / webpack-browser → webpack); the stats format itself is detected from content |
| Git | top level, revision, branch, uncommitted changes |
Build
- Angular CLI:
node_modules/.bin/ng build <project> --configuration=<c> --stats-json - NX:
node_modules/.bin/nx run <project>:build --configuration=<c> --stats-json --skip-nx-cache(NX_DAEMON=false;--nx-cacheallows cache replays)
The configuration defaults to the target's defaultConfiguration, then
production.
Compare
apa compare checks each revision other than the working tree out into a
private temporary folder ($TMPDIR/apa-worktree-*/<repo>-<sha>, created with
mkdtemp) via git worktree add --detach. Without --head, the head side is
your working tree and is built in place like apa analyze (its dist/ is
overwritten); with --head <rev> both sides are built in worktrees and your
checkout is not touched. The base defaults to main, then master, then
origin/HEAD. Repository and workspace paths are resolved to real paths, so a
symlinked --cwd can never point a revision build at your checkout.
Dependencies (--install):
auto(default): symlink the currentnode_moduleswhen the lockfile at that revision equals the current one, otherwise install from the lockfile (npm ci,pnpm install --frozen-lockfile,yarn install --immutable|--frozen-lockfile,bun install --frozen-lockfile)always: always install;never: always symlink
Worktrees are removed afterwards (--keep-worktrees keeps them, including the
node_modules link, so they can be rebuilt; remove them with
git worktree remove). Paths are
normalised (everything up to the last node_modules/ is dropped), so files
match across base and head even with symlinked installs.
Reading the report
- Tiles: initial and lazy totals in the selected metric, Δ vs. base when comparing.
- Controls: size metric (raw / gzip / brotli), load filter, level (modules ↔ files), colour by load or by change, free-text filter.
- Sankey: click Load to filter, Chunk to focus it, Module to expand its files, File for its cause chain, Other … to show more. All nodes are keyboard reachable (Tab, Enter).
- Details panel: sizes, why a chunk is loaded (boot entry, modulepreload or
import()site), through which import a module enters the initial payload, the full import chain of a file and hints (statically imported route target →loadComponent, barrel files, dependencies entering via a component →@defer/ lazy route). - Changes in the chart (compare): module, file and load nodes carry their delta (▲ grown, ▼ shrunk, + new, ⚠ lazy → initial) on the label, a coloured outline, the tooltip and the screen-reader label; the Modules tile counts new and gone modules (gone modules cannot appear in the head chart).
- Tables: module table (accessible alternative to the chart); when comparing, module and file changes with status and lazy → initial shifts. File changes are decided on exact raw bytes (per-file gzip/brotli are estimated shares and would flag every file of a changed chunk).
- Dark mode: follows the OS setting; the Theme button cycles auto → light → dark and remembers the choice.
Sizes: chunk sizes are exact (raw on disk, gzip level 9, brotli quality 11 — the brotli figure matches Angular's "estimated transfer size"). Per-file sizes are exact raw bytes for esbuild and proportional shares for gzip/brotli.
--json <file.json> additionally writes the raw report / comparison as JSON
(stable, versioned format, schemaVersion: 1).
Limitations
- Webpack stats hide most import chains (scope hoisting concatenates modules), so causes are partial there, and per-file sizes are scaled from pre-minification source sizes. Chunk totals and initial/lazy classification are exact.
- NX: build targets from
nx.jsontargetDefaultsare verified against a real workspace; targets inferred by plugins come from the same project graph but are covered by a unit test of the graph format only. - Only the browser bundle is analysed; the SSR server bundle (
.mjsoutputs) is ignored on purpose. - Two webpack builds of identical code can differ by a few bytes in the webpack runtime (chunk hash map); this shows up as tiny deltas.
- Per-file and per-module gzip/brotli values are shares of the chunk's measured compression (marked "≈" in deltas); chunk and load totals are measured.
- Windows: builds run the workspace CLI script with Node (no shell); installs in
worktrees run through
cmd.exewith fixed arguments. Not tested on a Windows host.
