Skip to content

Bundle Budget PluginΒ #1015

Open
Enhancement
@BioPhoton

Description

@BioPhoton

πŸ“¦ Bundle Budget Plugin

Bundle size tracking for your build artifacts
Track, compare, and prevent bundle size regressions to maintain web performance (e.g. LCP) across product areas.


πŸ§ͺ Reference PR

πŸ‘‰ #1024 – BundleBudget Plugin PoC Implementation


User story

As a developer, I want to track bundle size regressions per product area and route,
so that we can avoid performance regressions and optimize LCP over time.

The plugin should:

  • Analyze stats.json output from different bundler.
  • Identify and compare main, initial, and lazy chunks over glob matching
  • Use chunk input fingerprinting to map renamed chunk files.
  • Group chunk sizes by route/product (e.g., /route-1, /route-2).
  • give penalties for site and blacklisted imports
  • visualise inputs as well as static imports as they directly contribute to the real bundle size.
  • Store and compare bundle stats across versions/releases.

Metric

Bundle size in bytes.
Parsed from --stats-json output and grouped by file.

Property Value Description
value 132341 Total size of all chunks.
displayValue 13.4 MB / 13 Files Display value inc. number of files.

Integration Requirements

The plugin can be implemented in 2 ways:

  1. Using stats files
  2. Crawling the filesystem

As stats file serve significantly more details and are state of the art when debugging bundle size this issue favours this approach.

Using Stats Files

Bundle stats are detailed metadata about your build outputsβ€”listing each generated file, its original inputs, and any static importsβ€”exported via a metafile (e.g. from ESBuild or other bundlers).

Generate a stats file with ESBuild:

esbuild src/index.js --bundle  --outfile=dist/bundle.js  --metafile=stats.json

The resulting file maintains the following data structure:

EsbuildBundleStats                          # Root object containing all bundle stats
β”œβ”€β”€ inputs (Record<string, MetafileInput>)  # Map of each source input file to its metadata
β”‚   └── <inputPath>                         # File path of a specific input module
β”‚       β”œβ”€β”€ bytes: number                    # Size of this input module in bytes
β”‚       └── imports (MetafileImport[])      # List of static imports declared by this input
β”‚           └── [ ]                          # Array of import entries
β”‚               β”œβ”€β”€ path: string             # Resolved filesystem path of the imported module
β”‚               β”œβ”€β”€ kind: ImportKind         # Import type (e.g. "import", "require", "dynamic")
β”‚               β”œβ”€β”€ external?: boolean       # True if marked external (excluded from bundle)
β”‚               └── original?: string        # Original import specifier in source code
└── outputs (Record<string, MetafileOutput>)# Map of each generated output file to its metadata
    └── <outputPath>                        # File path of a specific output chunk
        β”œβ”€β”€ bytes: number                    # Total size of this output file in bytes
        β”œβ”€β”€ inputs (Record<string,MetafileOutputInput>) # Map of input modules contributing to this output
        β”‚   └── <inputPath>                 # Path of an input that fed into this output
        β”‚       └── bytesInOutput: number    # Number of bytes this input contributed to the output
        β”œβ”€β”€ imports (MetafileImport[])      # List of static imports found in this output
        β”‚   └── [ ]                          # Array of import entries
        β”‚       β”œβ”€β”€ path: string             # Resolved filesystem path of the imported module
        β”‚       β”œβ”€β”€ kind: ImportKind         # Import type (e.g. "import", "require", "dynamic")
        β”‚       β”œβ”€β”€ external?: boolean       # True if marked external (excluded from bundle)
        β”‚       └── original?: string        # Original import specifier in source code
        β”œβ”€β”€ exports: string[]                # List of named exports provided by this bundle
        └── entryPoint?: string              # Entry-point module path for this output chunk, if any

File Type Definitions

Type Structure Description
inputs Record<string, MetafileInput> Map of each source file path to its metadata (bytes and imports).
imports MetafileImport[] Array of import entries, each with path, kind, and optional flags.
πŸ“„ outputs Record<string, MetafileOutput> Map of each output file path to its metadata (bytes, inputs, imports, exports, entryPoint).
πŸ“ entryPoint string (optional) The entry-point module path for this output chunk, if present.

The plugin will use this information to gather the configured artefact groups.

Crawling the filesystem

Note

No research done as not scaleable

Setup and Requirements

πŸ“¦ Package Dependencies

  • Dev Dependencies:
    • None required, optional CLI runner for local debugging.
  • Optional Dependencies:

πŸ“ Configuration Files

  • angular.json / vite.config.ts or equivalent – for custom build config.
  • No required config file for the plugin itself.

Bundle Stats

The following is a minimal stats representation used to explain different features of the plugin. It will be referred to as Example Stats.

stats.json
└── outputs
    β”œβ”€β”€ dist/index.js                                    // entryPoint: src/index.ts
    β”‚   β”œβ”€β”€ inputs
    β”‚   β”‚   └── src/index.ts
    β”‚   β”‚       β”œβ”€β”€ src/lib/feature-1.ts                // import-statement
    β”‚   β”‚       β”‚   └── src/lib/utils/format.ts         // import-statement
    β”‚   β”‚       β”œβ”€β”€ src/lib/utils/math.ts               // import-statement
    β”‚   β”‚       └── src/lib/feature-2.ts                // dynamic-import
    β”‚   └── imports
    β”‚       β”œβ”€β”€ dist/chunks/chunk-U6O5K65G.js           // import-statement
    β”‚       └── dist/chunks/feature-2-X2YVDBQK.js       // dynamic-import
    β”œβ”€β”€ dist/bin.js                                      // entryPoint: src/bin.ts
    β”‚   β”œβ”€β”€ inputs
    β”‚   β”‚   └── src/bin.ts
    β”‚   β”‚       β”œβ”€β”€ src/lib/feature-1.ts                // import-statement
    β”‚   β”‚       β”‚   └── src/lib/utils/format.ts         // import-statement
    β”‚   β”‚       └── src/lib/utils/math.ts               // import-statement
    β”‚   └── imports
    β”‚       └── dist/chunks/chunk-U6O5K65G.js           // import-statement
    β”œβ”€β”€ dist/chunks/chunk-U6O5K65G.js
    β”‚   └── inputs
    β”‚       β”œβ”€β”€ src/lib/utils/format.ts
    β”‚       β”œβ”€β”€ src/lib/feature-1.ts
    β”‚       └── src/lib/utils/math.ts
    └── dist/chunks/feature-2-X2YVDBQK.js               // entryPoint: src/lib/feature-2.ts
        └── inputs
            └── src/lib/feature-2.ts

Features

@todo

General

The audit name is provided over the title property. Internally a audit slug is derived from the

  • title: A unique identifier for this group.
  • description: One two sentences explaining the purpose of the audit

Types

type Audit = {
  slug?: string;
  title: string;
  description?: string;
};

Example Configuration

const audit1: Audit = {
  title: 'Initial Bundles',
};

const audit2: Audit = {
  slug: 'app-core',
  title: '🎯 App Core',
  description: 'This audit checks the core functionality of the app.',
};

Every audit gets the merged configuration of the global and audit specific configuration listed in the description.

Configuration Example

const config: BundleStatsConfig = {
  title: 'Initial Bundles',
  description: 'This audit checks the initial bundles of the app.',
};

Report Output

This audit checks the initial bundles of the app.
<details>
<summary>βš™οΈ Config Summary</summary>

**Selection**
β€’ `includeOutputs`: `**/*`

**Scoring**
β€’ `totalSize`: `0 B – 97.66 kB`

**Insights**
β€’ πŸ”§ `**/math.ts`
β€’ πŸ”§ `**/format.ts`
β€’ 🧩 `**/*feature-2*`
β€’ 🏁 `src/index.ts, src/bin.ts`
β€’ 🀝 `dist/chunks/chunk-*.js`
β€’ πŸ“¦ `**/node_modules/**`
β€’ πŸ“¦ `dist/index.js, dist/bin.js`

</details>

Selection

To select files for an audit, glob patterns are used to include and exclude parts of the output files.
All options are provided as glob patterns matching either path, path in inputs or entryPoint.

Types

export interface SelectionOptions {
  // targeting output path of a `OutputNode`
  includeOutputs: string[];
  excludeOutputs: string[];

  // targeting input paths of a `OutputNode`
  includeInputs: string[];
  excludeInputs: string[];

  // targeting entryPoint path of a `OutputNode`
  includeEntryPoints: string[];
  excludeEntryPoints: string[];
}

Example Configuration

const selection: SelectionOptions = {
  includeOutputs: ['**/features/*'],
  excludeOutputs: ['**/features/legacy/**'],
  excludeInputs: ['**/ui/**'],
  includeEntryPoints: ['**/features/auth/*.ts'],
};

Selection Behaviour

  • Include patterns: If any include pattern is specified, only matching items are selected
  • Exclude patterns: Applied after include patterns to filter out unwanted items
  • Pattern matching: Uses standard glob patterns (*, **, ?, [...])
  • Multiple patterns: All patterns in an array are combined with OR logic
  • Precedence: Exclude patterns take precedence over include patterns

πŸ”— Dependency Preservation

Excluded files that are static imports (import-statement) cannot be fully excluded because they:

  • Contribute to bundle size: They are bundled with their importing file
  • Are always loaded: They load immediately when their importing file loads
  • Are required dependencies: Removing them would break the importing file

Example: When excluding chunk files:

{
  includeOutputs: ["**/*"],
  excludeOutputs: ["**/chunks/**"]  // Exclude chunk files
}

Static imports will still appear in the outputs section:

outputs
β”œβ”€β”€ dist/index.js
β”‚   └── imports
β”‚       β”œβ”€β”€ dist/chunks/chunk-U6O5K65G.js               // import-statement
β”‚       └── dist/chunks/feature-2-X2YVDBQK.js           // dynamic-import
└── πŸ”— dist/chunks/chunk-U6O5K65G.js                    // preserved as import-statement of dist/index.js

Import Type Behaviour:

  • import-statement: Always included (static imports are bundled dependencies)
  • dynamic-import: Excluded from selection as they do not contribute to the bundle size (runtime dependencies are required for functionality, but can load separately from the rest)
  • Chunks in outputs: Can be excluded if not imported by included files

This ensures the selection maintains functional integrity while respecting your filtering preferences.


All examples target the stats data under the Example Stats section above.


Include Specific Output

Selection Options

Select only dist/index.js and its dependencies:

{
  includeOutputs: ['**/dist/index.js'];
}

Selection Result:

stats.json
└── outputs
    β”œβ”€β”€ 🎯 dist/index.js                                    // entryPoint: src/index.ts
    β”‚   β”œβ”€β”€ inputs
    β”‚   └── imports
    β”‚       └── dist/chunks/chunk-U6O5K65G.js               // import-statement
    └── πŸ”— dist/chunks/chunk-U6O5K65G.js                    // imported by `dist/index.js`
        └── inputs

Include by Input Files

Selection Options

Select outputs that contain specific input files:

{
  includeInputs: ['**/feature-2.ts'];
}

Selection Result:

stats.json
└── outputs
    β”œβ”€β”€ dist/index.js                                    // entryPoint: src/index.ts
    β”‚   └── inputs
    β”‚       └── src/index.ts
    β”‚           └── 🎯 src/lib/feature-2.ts               // dynamic-import
    └── πŸ”— dist/chunks/chunk-U6O5K65G.js                  // imported by `dist/index.js`

Include by Entry Point

Selection Options

Select outputs that have src/bin.ts as their entry point:

{
  includeEntryPoints: ['**/bin.ts'];
}

Selection Result:

stats.json
└── outputs
    β”œβ”€β”€ dist/bin.js                                     // entryPoint: src/bin.ts 🎯
    └── πŸ”— dist/chunks/chunk-U6O5K65G.js                // imported by `dist/bin.js`

Exclude Outputs Patterns

Selection Options

Select all outputs except bin files:

{
  includeOutputs: ["**/*"],
  excludeOutputs: ["**/bin.js"]
}

Selection Result:

stats.json
└── outputs
    β”œβ”€β”€ 🎯 dist/index.js                                    // entryPoint: src/index.ts
    β”œβ”€β”€ πŸ”— dist/chunks/chunk-U6O5K65G.js                    // imported by `dist/index.js`
    β”œβ”€β”€ πŸ”— dist/chunks/feature-2-X2YVDBQK.js               // imported by `dist/index.js`
    └── // excluded: dist/bin.js

Exclude Inputs Patterns

Selection Options

Select all outputs but exclude those containing feature-2 files:

{
  includeOutputs: ["**/*"],
  excludeInputs: ["**/feature-2.ts"]
}

Selection Result:

stats.json
└── outputs
    β”œβ”€β”€ 🎯 dist/bin.js                                      // entryPoint: src/bin.ts
    β”œβ”€β”€ πŸ”— dist/chunks/chunk-U6O5K65G.js                    // imported by `dist/bin.js`
    β”œβ”€β”€ // excluded: dist/index.js (contains feature-2.ts)
    └── // excluded: dist/chunks/feature-2-X2YVDBQK.js (contains feature-2.ts)

Exclude Entry Points Patterns

Selection Options

Select all outputs except those with bin entry points:

{
  includeOutputs: ["**/*"],
  excludeEntryPoints: ["**/bin.ts"]
}

Selection Result:

stats.json
└── outputs
    β”œβ”€β”€ 🎯 dist/index.js                                    // entryPoint: src/index.ts
    β”œβ”€β”€ πŸ”— dist/chunks/chunk-U6O5K65G.js                    // imported by `dist/index.js`
    β”œβ”€β”€ πŸ”— dist/chunks/feature-2-X2YVDBQK.js               // imported by `dist/index.js`
    └── // excluded: dist/bin.js (entryPoint: src/bin.ts)

Scoring

The plugin assigns a score in the range [0 … 1] to each artefact (or artefact selection) based on:

  1. Size vs. a configurable maximum threshold
  2. Diagnostics penalties (errors & warnings, including blacklist violations as warnings)

A perfect score (1) means β€œwithin budget”; lower values indicate regressions.

The selection process of a scored set of files is explained in detail in File Selection

Types

export interface ScoringOptions {
  // targeting output path of a `OutputNode`
  totalSize: number | MinMax;
  penalty: {
    artefactSize?: number | MinMax;
    blacklist?: string[];
  }

Example Configuration

const selection: SelectionOptions = {
  includeOutputs: ['**/features/*'],
  excludeOutputs: ['**/features/legacy/**'],
  excludeInputs: ['**/ui/**'],
  includeEntryPoints: ['**/features/auth/*.ts'],
};

Total Size

Every artefact selection has budgets assigned under budget.

  • totalSize: Total bytes of all files in a selection.

Examples

const scoring1: Scoring = {
  totalSize: 300_000,
};

const scoring2: Scoring = {
  totalSize: [10_000, 300_000],
};

Panelties

To give actionable feedback to users of the plugin you can add penalties a set of penalties:

  • artefactSize: Byte size of a files in a selection.
  • blacklist: List of globs to flag inputs as well as imports as forbidden

Types

type Penalty = {
  artefactSize?: number | MinMax;
  blacklist?: string[];
};

Example Configuration

const penalty1: Penalty = {
  artefactSize: 50_000,
};

const penalty2: Penalty = {
  artefactSize: [1_000, 50_000],
  blacklist: ['node_modules/old-charting-lib'],
};

Scoring Parameters

Parameter Description
S Actual bytes
M Size threshold bytes
E Count of issues of severity errors (🚨)
W Count of issues of severity warnings (⚠️), including blacklist matches
we Weight per error (default 1)
ww Weight per warning (default 0.5)
Size score

$\mathrm{sizeScore} = \begin{cases} 1, &amp; S \le M\\[6pt] \max\bigl(0,\;1 - \tfrac{S - M}{M}\bigr), &amp; S &gt; M \end{cases}$

Issues penalty

$\mathrm{penalty} = we \times E \;+\; ww \times W$

Final blended score

$\mathrm{finalScore} = \max\!\Bigl(0,\;\mathrm{sizeScore} - \frac{\mathrm{penalty}}{we + ww}\Bigr)$

xychart-beta
    title "Score vs Artifact Size (with penalty shift)"
    x-axis [0, 1, 1.25, 1.5, 1.75, 2]
    y-axis "Score" 0 --> 1
    line Original  [1, 1,   0.75, 0.5, 0.25, 0]
    line Penalized [0.5, 0.5, 0.25, 0,   0,    0]
Loading

Issues

To give users actionable feedback we need to be able to tell WHAT happened, WHEN to fix it and HOW to fix it.

Issues are configured per audit under the scoring.penalty property.
The plugin creates diagnostics for each penalty. The table below shows all diagnostic types:

Diagnostic Description Config Key Default Severity Recommended Action
Blacklisted Artifact contains an import matching a forbidden glob pattern. blacklist β€” 🚨 Error Remove or replace the forbidden dependency.
Too Large Artifact exceeds the maximum allowed size. May indicate an unoptimized bundle or accidental check-in. maxArtifactSize 5 MB 🚨 Error Review and optimize (e.g. code splitting, compression).
Too Small Artifact is below the minimum expected size. Could signal missing dependencies or incomplete build. minArtifactSize 1 KB ⚠️ Warn Verify that the build output is complete and dependencies are included.

Too Large Issues

Artifacts that exceed the maximum allowed size threshold. This typically indicates unoptimized bundles, accidental inclusion of large files, or missing code splitting strategies.

Configuration: scoring.artifactSize

Example Issues:

Severity Message Source file Location
🚨 error main.js is 6.12 MB (exceeds 5 MB); consider splitting or compressing this bundle. dist/lib/main.js
🚨 error vendor.js is 2.05 MB (exceeds 1.5 MB); apply tree-shaking or extract shared dependencies. dist/lib/vendor.js

Use Cases:

  • Code Splitting: Break large bundles into smaller chunks
  • Tree Shaking: Remove unused code from dependencies
  • Compression: Enable gzip/brotli compression
  • Asset Optimization: Optimize images and other assets
  • Lazy Loading: Load code only when needed

Too Small Issues

Artifacts that fall below the minimum expected size threshold. This could signal missing dependencies, incomplete builds, or configuration issues.

Configuration: scoring.artifactSize

Example Issues:

Severity Message Source file Location
⚠️ warning utils.js is 50 kB (below 100 kB); confirm that expected dependencies are included. dist/lib/utils.js
⚠️ warning styles.css is 10 B (below 1 kB); confirm that expected dependencies are included. dist/lib/styles.css

Use Cases:

  • Dependency Check: Verify all required dependencies are included
  • Build Verification: Ensure the build process completed successfully
  • Configuration Review: Check bundler configuration for missing entries
  • Source Validation: Confirm source files contain expected content

Blacklisted Issues

Artifacts containing imports that match forbidden glob patterns. This helps enforce dependency policies and prevent usage of deprecated or unsafe libraries.

Configuration: scoring.blacklist

Example Issues:

Severity Message Source file Location
🚨 error node_modules/old-charting-lib/index.js matches a blacklist pattern; remove or replace this dependency. src/main-ASDFGAH.js

Use Cases:

  • Dependency Replacement: Replace blacklisted libraries with approved alternatives
  • Code Refactoring: Remove usage of forbidden dependencies
  • Policy Review: Update dependency policies if needed
  • Security Audit: Investigate security implications of blacklisted dependencies

Artefact Tree

The artefact tree is here to give users a quick understanding of the dependencies graph of the selected artefacts. It should be a replaccement for opening the bundle stats in the browser and search for respective files.

Complete Example:

πŸ—‚οΈ example-group                                 537.17 kB   101 files
β”œβ”€β”€ πŸ“ entry-1.js                                   138 kB    2 files
β”‚   β”œβ”€β”€ πŸ“„ src/input-1.ts                           101 kB
β”‚   └── πŸ“„ src/input-2.ts                            37 kB
β”œβ”€β”€ πŸ“„ entry-2.js                                   330 kB   2 files
β”‚   β”œβ”€β”€ πŸ“„ node_modules/@angular/router/provider.js  15 kB
β”‚   β”œβ”€β”€ πŸ“„ node_modules/@angular/router/service.js  15 kB
β”‚   └── ... 12 more files                          300 kB
β”œβ”€β”€ 🎨 styles.css                                    14 kB
└── πŸ”— static imports from πŸ“ entry-1.js            104 kB
    └── πŸ“„ file-1.js

Artefact Types

Each group is also displayed as tree of artefacts, inputs and static imports.
The following types are detected.

  • πŸ“„ - script file - JS/TS artefact
  • 🎨 - style files - CSS/SCSS artefacts
  • πŸ“ - entry file - JS/TS artefact
  • πŸ”— - static imports - List of S/TS artefacts statically imported by another file
πŸ—‚οΈ example-group
β”œβ”€β”€ πŸ“ entry-1.js
β”œβ”€β”€ πŸ“„ file-1.js
β”œβ”€β”€ 🎨 styles.css
└── πŸ”— static imports from πŸ“ entry-1.js
    └── πŸ“„ file-2.js

Artefact Inputs

Each artefact is maintains out of inputs ind imports. The inputs are listed under each chunk.

πŸ—‚οΈ example-group
└── πŸ“„ file-1.js
    β”œβ”€β”€ πŸ“„ src/lib/cli.ts
    β”‚    └── πŸ“„ node_modules/yargs/yargs.ts
    └── πŸ“„ node_modules/zod/helpers
        └── πŸ“„ node_modules/zod/helpers/description.ts

Artefact Imports

Each artefact imports that are statically imported and directly contribute to the artefact group size are listed under dedicated import groups per file. This is helpful to understand the true size of you artefact group.
Static imports are loaded together with it's importing parent and therefore will end up in the loaded bytes. Displaying them helps to inderstand why they are part and where they aer imported from.

πŸ—‚οΈ example-group
β”œβ”€β”€ πŸ“ entry-1.js
β”œβ”€β”€ πŸ“„ file-1.js
β”œβ”€β”€ πŸ“ entry-1.js        // imports file-2.ts and file-3.ts
β”œβ”€β”€ πŸ“„ file-2.ts         // imported from πŸ“ entry-1.js
β”‚   └── πŸ“„ file-2.ts
└── πŸ“„ file-3.ts         // imported from πŸ“ entry-1.js
    └── πŸ“„ file-3.ts

Artefact Grouping

Artefact inputs can be grouped over the configuration.

Types

type GroupRule = {
  title: string;
  patterns: string[];
  icon: string;
  maxDepth: number;
}

Ungrouped:

πŸ—‚οΈ example-group
└── πŸ“„ entry-2.js
    β”œβ”€β”€ πŸ“„ node_modules/@angular/router/provider.ts
    └── πŸ“„ node_modules/@angular/router/service.ts
        └── πŸ“„ node_modules/@angular/router/utils.ts

Example Configuration

const groupRules = [
  {
    title: "@angular/router",
    patterns: ["node_modules/@angular/router"],
    icon: "πŸ…°οΈ"
  }
];

Report Output:

πŸ—‚οΈ example-group
└── πŸ“„ entry-2.js
    └─  πŸ…°οΈ @angular/router

Tree Pruning

The artefact tree can get quite dense in information. To avoid overload we reduce the amount of visible nodes.

Types

type PruningOptions = {
  maxChildren: number;
  maxDepth: number;
}

Unpruned:

πŸ—‚οΈ example-group
β”œβ”€β”€ πŸ“ index.js
β”‚   β”œβ”€β”€ πŸ“„ src/app.ts
β”‚   β”‚   β”œβ”€β”€ πŸ“„ src/components/Header.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ πŸ“„ src/components/Logo.ts
β”‚   β”‚   β”‚   └── πŸ“„ src/components/Nav.ts
β”‚   β”‚   └── πŸ“„ src/utils/math.ts
β”‚   β”œβ”€β”€ πŸ“„ src/route-1.ts
β”‚   β”œβ”€β”€ πŸ“„ src/route-2.ts
β”‚   └── πŸ“„ src/main.css
β”œβ”€β”€ πŸ“„ vendor.js
β”‚   β”œβ”€β”€ πŸ“„ node_modules/react.ts
β”‚   β”œβ”€β”€ πŸ“„ node_modules/react-dom.ts
β”‚   β”œβ”€β”€ πŸ“„ node_modules/redux.ts
β”‚   β”œβ”€β”€ πŸ“„ node_modules/react-router.ts
β”‚   └── πŸ“„ node_modules/lodash.js
└── πŸ“„ logo.svg

Example Configuration

const pruningOptions: PruningOptions = {
  maxChildren: 3,
  maxDepth: 1,
};

Report Output:

πŸ—‚οΈ example-group
β”œβ”€β”€ πŸ“ index.js
β”‚   β”œβ”€β”€ πŸ“„ src/app.ts
β”‚   └── … 3 more inputs
β”œβ”€β”€ πŸ“„ vendor.js
β”‚   β”œβ”€β”€ πŸ“„ node_modules/react.ts
β”‚   └── … 4 more inputs
└── πŸ“„ logo.svg

Formatting

The Trees text content is formatted to get better readability by default.

Size Formatting

File sizes are calculated to the closest readable unit for better readability.

Unformatted:

πŸ—‚οΈ example-group                 300000
└── πŸ“ main.js                   300000

Formatted:

πŸ—‚οΈ example-group                 300 kB
└── πŸ“ main.js                   300 kB
Source Pluralization

Source counts are properly pluralized based on the number of files.

Unpluralized:

πŸ—‚οΈ example-group                     3
β”œβ”€β”€ πŸ“ main.js                       1
└── πŸ“„ utils.js                      2

Pluralized:

πŸ—‚οΈ example-group                     3 sources
β”œβ”€β”€ πŸ“ main.js                       1 source
└── πŸ“„ utils.js                      2 sources
Path Shortening

Long file paths are shortened while respecting important path segments.

Unshortened:

πŸ—‚οΈ example-group
└── πŸ“ src/lib/utils/helper/left-pad/left-pad.js

Shortened:

πŸ—‚οΈ example-group
└── πŸ“ src/.../left-pad.js
Redundant Information Handling

If a node has only 1 child or file, redundant size and file count information is not printed to reduce clutter.

With redundancy:

πŸ—‚οΈ example-group                        300 kB    3 sources
└── πŸ“ index.js                         100 kB    1 source
    β”œβ”€β”€ πŸ“„ src/app.js                   100 kB    1 source
    └── … 4 more inputs                 200 kB    4 sources

Without redundancy:

πŸ—‚οΈ example-group                        300 kB    3 files
└── πŸ“ index.js                         100 kB
    β”œβ”€β”€ πŸ“„ src/app.js
    └── … 4 more inputs                 200 kB
Hide very small chunks from output

If a node has a very little byte size we will move them into the pruned item at the end.

With small inputs:

πŸ—‚οΈ example-group                        840 kB  13 files
└── πŸ“ index.js                         840 kB  12 files
    β”œβ”€β”€ πŸ“„ src/app.js                  400 kB
    β”œβ”€β”€ πŸ“„ src/large-1.js.           200 kB
    β”œβ”€β”€ πŸ“„ src/meduim-1.js.      100 kB
    β”œβ”€β”€ πŸ“„ src/small-1.js.            10 kB  
    β”œβ”€β”€ πŸ“„ src/small-2.js.            10 kB
    β”œβ”€β”€ πŸ“„ src/small-3.js.            10 kB
    β”œβ”€β”€ πŸ“„ src/small-4.js.             5 kB
    β”œβ”€β”€ πŸ“„ src/small-5.js.             5 kB
    └── …                                  100 kB 5 files

Example Configuration

const pruningOptions: PruningOptions = {
   minSize: 90_000
};

Without small chunks:

πŸ—‚οΈ example-group                        840 kB  13 files
└── πŸ“ index.js                         840 kB  12 files
    β”œβ”€β”€ πŸ“„ src/app.js                  400 kB
    β”œβ”€β”€ πŸ“„ src/large-1.js.           200 kB
    β”œβ”€β”€ πŸ“„ src/meduim-1.js.      100 kB
    └── …                                  140 kB 8 files

Insights Table

The insights table gives an overview of the different areas of the file selection. The information is aggregated from output files, inputs and output overhead and grouped by pattern.

To have correct data, a process needs to be followed:

  1. For each Group
  2. For each Pattern
    1. Iterate over all inputs of all outputs and aggregate its contributing bytes
    2. Iterate over all imports of all outputs and aggregate its contributing bytes
    3. Iterate over all outputs and aggregate its contributing bytes
  3. Aggregate the unmatched bytes of all outputs under a group for the remaining bytes

Types

type InsightsOptions = {
  title?: string;
  patterns: string[];
  icon?: string;
}[];

Example Configuration

const insightsOptions: InsightsOptions = [
  {
    patterns: ["**/features/host-app/**"],
    title: "App Host",
    icon: "πŸ–₯️"
  },
  ...
]

Report Output:

| Group               | Size     | Sources |
| ------------------- | -------- | ------- |
| πŸ“¦ Node Modules     | 1.2 MB   | 100     |
| πŸ–₯️ App Host         | 48.15 kB | 109     |
| πŸ“ Loaders          | 30.53 kB | 21      |
| πŸ“ Legacy           | 30.53 kB | 21      |
| 🎯 Sports Feature   | 6.4 kB   | 61      |
| πŸ…°οΈ Angular          | 836 B    | 2       |
| 🌬️ Oxygen           | 287 B    | 3       |
| *Rest*               | 1.2 MB   | 100     |

Implementation details

Limitations

From feedback sessions on this issue we collected a couple of things that popped up regularly but are not directly possible with the plugin.

  • Any action triggered by a comparison of 2 measures.
    • increased size by X%
    • increased size by XkB
    • increased files by X

For example to implement a failing CI on 20% increase for a specific audit you would have to read the comparison json created by the GH action or over the @code-pushup/ci package directly and process it with your custom logic.

Data Processing Pipeline

flowchart LR
  subgraph Stats Generation
    A[Esbuild βž” stats.json] 
    B[Webpack βž” stats.json]
    C[Vite βž” stats.json]
  end

  D[Unify Stats]
  E[Merge Options]
  F[Group Stats by Audits]
  G[Compute Size Scores]
  H[Compute Issue Penalties]
  I[Score Audit]
 subgraph Generate Audits
 J[Add Issues]
 K[Add Table]
 L[Add Tree]
end
  A --> D
  B --> D
  C --> D
  D --> E
  E --> F
  F --> G
  F --> H
  G --> I
  H --> I
   I --> J
  J --> K
  K --> L

Loading

Plugin Configuration

The plugin integrates with supported bundlers to analyze bundle statistics and generate audit reports. Configure the plugin with bundler type, artifact paths, and audit settings.

Types

export type BundleStatsPluginOptions = {
    bundler: SUPPORTED_BUNDLERS;
    artefactsPaths: string;
    generateArtefacts?: string;
    audits: BundleStatsAuditOption[];
    artefactTree?: TreeOptions;
    insightsTable?: InsightsOptions;
    scoring?: Omit<ScoringOptions, 'totalSize'>;
}

Minimal Example Configuration

const pluginOptions: PluginOptions = {
    bundler: 'webpack',
    artefactsPaths: './dist/stats.json',
    generateArtefacts: 'esbuild src/index.js --bundle  --outfile=dist/bundle.js  --metafile=stats.json',
    audits: [
        {
            title: 'Initial Bundles',
            selection: {
                includeOutputs: ['**/*.js']
            },
            scoring: {
                totalSize: 500_000
            }
        }
    ]
};

Artefacts Gathering

The plugin can generate artefacts from the stats.json file. The plugin can either use an existing stats.json file or generate a new one if the generateArtefacts option is provided.

Types

type ArtefactsOptions = {
    bundler: SUPPORTED_BUNDLERS;
    artefactsPaths: string;
    generateArtefacts?: string;
}

Example Configuration:

const options: PluginOptions = {
    bundler: 'esbuild',
    artefactsPaths: './dist/stats.json'
    // ...
};

Full Example Configuration:

const options: PluginOptions = {
    bundler: 'esbuild',
    artefactsPaths: './dist/stats.json',
    generateArtefacts: 'esbuild src/index.js --bundle  --outfile=dist/bundle.js  --metafile=stats.json',
    // ...
};

Options Merging

The Plugin and audit share a set of options:

  • insightsTable - The insights to use (grouping)
  • scoring - The scoring penalty (not the totalSize) to use (penalty)
  • artefactTree - The artefact tree to use (pruning, grouping, formatting)
const pluginOptions: PluginOptions = {
    artefactTree: {},                 // πŸ“₯ merged into every audit[].artefactTree
    insightsTable: [],                // πŸ“₯ merged into every audit[].insightsTable
    scoring: {},                      // πŸ“₯ merged into every audit[].scoring (not the totalSize)
    audits: [
        {
            scoring: {
               totalSize: 500_000
               pelanty: {}              // πŸ”„ overrides plugin.scoring if set
            },        
            insightsTable: [],          // πŸ”„ overrides plugin.insightsTable if set
            artefactTree: {},           // πŸ”„ overrides plugin.artefactTree if set
        }
    ]
};

Audit Configuration

Each audit defines a specific bundle analysis with its own selection criteria, scoring thresholds, and reporting options. Audits can override plugin-level settings or inherit them for consistency across multiple audits.

Types

export type BundleStatsAuditOption = {
  slug?: string;
  title: string;
  description?: string;
  selection: SelectionOptions;
  scoring: ScoringOptions;
  artefactTree?: TreeOptions;
  insightsTable?: InsightsOptions;
}

Minimal Example Configuration

const auditConfig: BundleStatsAuditOption = {
  title: 'All Bundles',
  selection: { includeOutputs: ['**/*.js'] },
  scoring: { totalSize: 500_000 }
};

Full Example Configuration

const auditConfig: BundleStatsAuditOption = {
  slug: 'initial-bundles',
  title: 'Initial Bundle Size',
  description: 'Monitors the size of initial JavaScript bundles loaded on page load.',
  selection: {
    includeOutputs: ['**/main.js', '**/vendor.js'],
    excludeOutputs: ['**/chunks/**']
  },
  scoring: {
    totalSize: 500_000,
    penalty: {
      artefactSize: [50_000, 200_000],
      blacklist: ['**/legacy/**', '**/deprecated/**']
    }
  },
  artefactTree: {
    groups: [
      {
        title: 'App Code',
        patterns: ['**/src/**'],
        icon: '🎯'
      },
      {
        title: 'Node Modules',
        patterns: ['**/node_modules/**'],
        icon: 'πŸ“¦'
      }
    ],
    pruning: {
      maxChildren: 3,
      maxDepth: 1
    }
  },
  insightsTable: [
    {
      title: 'App Code',
      patterns: ['**/src/**'],
      icon: '🎯'
    },
    {
      title: 'Node Modules',
      patterns: ['**/node_modules/**'],
      icon: 'πŸ“¦'
    }
  ]
};

Market Research - Viewer

sonda.dev

Repo: https://sonda.dev/

Market Research - CI

SizeLimit

Repo: https://github.com/ai/size-limit

Setup

import type { SizeLimitConfig } from '../../packages/size-limit'

module.exports = [
  {
    path: "index.js",
    import: "{ createStore }",
    limit: "500 ms"
  }
] satisfies SizeLimitConfig

Relevant Options:

  • path: relative paths to files. The only mandatory option.
    It could be a path "index.js", a [pattern] "dist/app-*.js"
    or an array ["index.js", "dist/app-*.js", "!dist/app-exclude.js"].
  • import: partial import to test tree-shaking. It could be "{ lib }"
    to test import { lib } from 'lib', * to test all exports,
    or { "a.js": "{ a }", "b.js": "{ b }" } to test multiple files.
  • limit: size or time limit for files from the path option. It should be
    a string with a number and unit, separated by a space.
    Format: 100 B, 10 kB, 500 ms, 1 s.
  • name: the name of the current section. It will only be useful
    if you have multiple sections.
  • message: an optional custom message to display additional information,
    such as guidance for resolving errors, relevant links, or instructions
    for next steps when a limit is exceeded.
  • gzip: with true it will use Gzip compression and disable
    Brotli compression.
  • brotli: with false it will disable any compression.
  • ignore: an array of files and dependencies to exclude from
    the project size calculation.

Bundle Stats

repo: https://github.com/relative-ci/bundle-stats?tab=readme-ov-file

Setup

const { BundleStatsWebpackPlugin } = require('bundle-stats-webpack-plugin');

module.exports = {
  plugins: [
    new BundleStatsWebpackPlugin({
      compare: true,
      baseline: true,
      html: true
    })
  ]
};

Relevant Options

  • compare | Enable comparison to baseline bundle
  • baseline | Save stats as baseline for future runs
  • html | Output visual HTML report
  • json | Output JSON snapshot
  • stats | (advanced) Customize Webpack stats passed into plugin
  • silent | Disable logging

BundleMon

Repo: https://github.com/LironEr/bundlemon

Setup

"bundlemon": {
  "baseDir": "./build",
  "files": [
    {
      "path": "index.html",
      "maxSize": "2kb",
      "maxPercentIncrease": 5
    },
    {
      "path": "bundle.<hash>.js",
      "maxSize": "10kb"
    },
    {
      "path": "assets/**/*.{png,svg}"
    }
  ]
}

Relevant Options

  • path (string, required) – Glob pattern relative to baseDir (e.g. "**/*.js")
  • friendlyName (string, optional) – Human-readable name (e.g. "Main Bundle")
  • compression ("none" | "gzip", optional) – Override default compression (e.g. "gzip")
  • maxSize (string, optional) – Max size: "2000b", "20kb", "1mb"
  • maxPercentIncrease (number, optional) – Max % increase: 0.5 = 0.5%, 4 = 4%, 200 = 200%

Metadata

Metadata

Assignees

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions