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

πŸ‘‰ #PoC – 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).
  • 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
β”œβ”€β”€ inputs (Record<string, MetafileInput>)
β”‚   └── <inputPath>
β”‚       β”œβ”€β”€ bytes: number
β”‚       └── imports (MetafileImport[])
β”‚           └── [ ]
β”‚               β”œβ”€β”€ path: string
β”‚               β”œβ”€β”€ kind: ImportKind
β”‚               β”œβ”€β”€ external?: boolean
β”‚               └── original?: string
└── outputs (Record<string, MetafileOutput>)
    └── <outputPath>
        β”œβ”€β”€ bytes: number
        β”œβ”€β”€ inputs (Record<string, MetafileOutputInput>)
        β”‚   └── <inputPath>
        β”‚       └── bytesInOutput: number
        β”œβ”€β”€ imports (MetafileImport[])
        β”‚   └── [ ]
        β”‚       β”œβ”€β”€ path: string
        β”‚       β”œβ”€β”€ kind: ImportKind
        β”‚       β”œβ”€β”€ external?: boolean
        β”‚       └── original?: string
        β”œβ”€β”€ exports: string[]
        └── entryPoint?: string

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:
    • esbuild – or any tool that emits --metafile/stats.json.

πŸ“ Configuration Files

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

Implementation details

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[Apply Plugin Config]
  F[Compute Size Scores]
  G[Compute Issue Penalties]
  H[Blend into Final Score]
  I[Generate Audits & Tree Display & Issues]

  A --> D
  B --> D
  C --> D
  D --> E
  E --> F
  E --> G
  F --> H
  G --> H
  H --> I

Loading

Plugin Types

export type SupportedBundlers = 'esbuild' | 'webpack' | 'vite' | 'rsbuild';

export type MinMax = [number, number];

export interface BundleStatsConfig {
  name: string;
  include?: string[];
  exclude?: string[];
  thresholds: {
    totalSize: number | MinMax;
    artefactSize?: number | MinMax;
  };
  // includeInputs?: string[]; 
}

export interface GroupingOptions {
  name: string;
  patterns: PatternList;
  icon?: string;
  depth?: number;
}

export interface PruningOptions {
  maxChildren?: number;
  startDepth?: number;
  maxDepth?: number;
}

export type PluginOptions {
  generateArtefacts?: string;
  artefactsPath: string;
  bundler: SupportedBundlers;
  customAudits: BundleStatsConfig[];
  tree: boolean | {
    grouping?: GroupingOptions;
    pruning?: PruningOptions;
  }
}

Plugin Usage

const config = {
plugins: [
  bundleStatsPlugin({
    generateArtefacts: "nx run app-1:build --stats",
    artefactsPath: "./stats.json",
    bundler: 'esbuild',
    configs: [
     { 
         name: "Initial Bundles",
         include: ["main.js", "vendor.js"],
         thresholds : { totalSize: 5000 }
      },
      {
          name: 'Lazy Products',
          include: ['src/app/products/**', '!src/app/products/shared/**'],
          thresholds: {
             totalSize: 1000,
             artefactSize: 100
          }
        }
     ]
  })
]
}

Audits

A audit encapsulates a group of artefacts determined by BundleStatsConfig.

Scoring

The plugin assigns a score in the range [0 … 1] to each artefact (or artefact group) based on its byte size relative to a configurable maximum threshold (maxArtefactSize). A perfect score (1) means β€œwithin budget,” and a zero score (0) means β€œcritically over budget.”

Scoring Parameters
Parameter Description
S Actual bytes
M Size threshold bytes
E Number of β€œToo Large” (🚨) errors
W Number of β€œToo Small” (⚠️) warnings
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

$penalty = we * E + ww * 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

The plugin optionally creates diagnostic for the given artefacts.
The following table shows all different options:

Diagnostic Description Config Key Default Severity Recommended Action
Too Large 🚨 Artifact exceeds the maximum allowed size. May indicate an unoptimized bundle or accidentally checked-in binary. maxArtifactSize 5 MB Error
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.

Example Issues:

Severity Message Source file Line(s)
🚨 error main.js is 6.12 MB (max 5 MB); enable code-splitting or lazy-loading. dist/lib/main.js
🚨 error vendor.js is 2.05 MB (max 1.5 MB); apply tree-shaking or serve libs via CDN. dist/lib/vendor.js
⚠️ warning utils.js is 50 kB (min 100 kB); verify bundling. dist/lib/utils.js
⚠️ warning styles.css is 10 B (min 1 kB); check CSS inclusion. dist/lib/styles.css

Artefact Tree

Example:

πŸ—‚οΈ example-group                         237.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                             30 kB 2 files   
β”‚   β”œβ”€β”€ πŸ“„ node_modules/@angular/router/provider.js            
β”‚   └── πŸ“„ node_modules/@angular/router/service.js            
β”œβ”€β”€ 🎨 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                             190 kB  4 files
β”œβ”€β”€ πŸ“ entry-1.js                            100 kB   2 files
β”œβ”€β”€ πŸ“„ file-1.js                                 30 kB   2 files   
β”œβ”€β”€ 🎨 styles.css                             10 kB        
└── πŸ”— static imports from πŸ“ entry-1.js  
    └── πŸ“„ file-2.js                            50 kB

Artefact Inputs

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

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

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                                           150 kB  6 files
β”œβ”€β”€ πŸ“ entry-1.js                                          100 kB  
β”œβ”€β”€ πŸ“„ file-1.js                                               50 kB  
β”œβ”€β”€ πŸ”— static imports from πŸ“ entry-1.js     60 kB  2 files     
β”‚    β”œβ”€β”€ πŸ“„ file-2.js                                         40 kB   
β”‚    └── πŸ“„ file-3.js                                        40 kB   
└── πŸ”— static imports from πŸ“„ file-1.js        80 kB  2 files    
    β”œβ”€β”€ πŸ“„ file-4.js                                          40 kB    
    └── πŸ“„ file-5.js                                         40 kB     

Artefact Grouping

Artefact inputs can be grouped over the configuration.

Ungrouped:

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

Grouped by:

  • patterns - node_modules/@angular/router
  • name - @angular/router
  • icon - "πŸ…°οΈ"
  • depth - 1
πŸ—‚οΈ example-group                             150 kB  1 files
└── πŸ“„ entry-2.js                           150 kB  1 files
    └─  πŸ…°οΈ @angular/router           

Tree Pruning

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

Unpruned:

πŸ—‚οΈ example-group                           800 kB   10 files
β”œβ”€β”€ πŸ“ index.js                             250 kB    4 files
β”‚   β”œβ”€β”€ πŸ“„ src/app.js                       120 kB    3 files
β”‚   β”‚   β”œβ”€β”€ πŸ“„ src/components/Header.js      40 kB    2 files
β”‚   β”‚   β”‚   β”œβ”€β”€ πŸ“„ src/components/Logo.js     15 kB    1 file
β”‚   β”‚   β”‚   └── πŸ“„ src/components/Nav.js      25 kB    1 file
β”‚   β”‚   └── πŸ“„ src/utils/math.js             30 kB    1 file
β”‚   β”œβ”€β”€ πŸ“„ src/route-1.js                     120 kB    3 files
β”‚   β”œβ”€β”€ πŸ“„ src/route-2.js                     120 kB    3 files
β”‚   └── πŸ“„ src/main.css                       50 kB    1 file
β”œβ”€β”€ πŸ“„ vendor.js                             300 kB    5 files
β”‚   β”œβ”€β”€ πŸ“„ node_modules/react.js             100 kB    1 file
β”‚   β”œβ”€β”€ πŸ“„ node_modules/react-dom.js          80 kB    1 file
β”‚   β”œβ”€β”€ πŸ“„ node_modules/redux.js              60 kB    1 file
β”‚   β”œβ”€β”€ πŸ“„ node_modules/react-router.js       40 kB    1 file
β”‚   └── πŸ“„ node_modules/lodash.js             20 kB    1 file
└── πŸ“„ logo.svg                              250 kB    1 file

Pruning options:

  • startDepth - 1
  • maxChildren - 1
  • maxDepth - 3

Pruned:

πŸ—‚οΈ example-group                             150 kB  10 files
β”œβ”€β”€ πŸ“ index.js                             250 kB    4 files
β”‚   β”œβ”€β”€ πŸ“„ src/app.js                       120 kB    
β”‚   └── … 3 more inputs                    130 kB    
β”œβ”€β”€ πŸ“„ vendor.js                             300 kB    5 files
β”‚   β”œβ”€β”€ πŸ“„ node_modules/react.js             100 kB    
β”‚   └── … 4 more inputs                            200 kB    
└── πŸ“„ logo.svg                              250 kB    

Report examples


Code PushUp Report

🏷 Category ⭐ Score πŸ›‘ Audits
Bundle Size 🟑 54 13

🏷 Categories

Performance

Bundle size metrics πŸ“– Docs

🟒 Score: 92

πŸ›‘οΈ Audits

Bundle size changes InitialChunks (CodePushUp)

πŸŸ₯ 3 info (score: 0)

Initial Chunks

πŸ—‚οΈ initial-bundles                               237.16 kB  101 files
β”œβ”€β”€ πŸ“ main-XTHQ3HCO.js                            98.01 kB   14 files
β”‚   β”œβ”€β”€ src/app/app.routes.ts                         101 B     1 file
β”‚   β”œβ”€β”€ src/app/app.config.ts                          53 B     1 file
β”‚   β”œβ”€β”€ src/app/app.component.ts                   17.59 kB     1 file
β”‚   β”œβ”€β”€ src/main.ts                                    37 B     1 file
β”‚   β”œβ”€β”€ πŸ…°οΈ @angular/common                         5.46 kB    4 files
β”‚   β”œβ”€β”€ πŸ…°οΈ @angular/platform-browser              11.47 kB    3 files
β”‚   └── πŸ…°οΈ @angular/router                        63.31 kB    3 files
β”œβ”€β”€ πŸ“ polyfills-B6TNHZQ6.js                       33.77 kB    2 files
β”‚   β”œβ”€β”€ angular:polyfills:angular:polyfills                     1 file
β”‚   └── πŸ“¦ zone.js                                 33.77 kB     1 file
β”œβ”€β”€ πŸ“„ styles-EGIV7BA7.css                            631 B     1 file
└── πŸ”— static imports from πŸ“ main-XTHQ3HCO.js    104.76 kB   84 files
    └── πŸ“„ chunk-5QRGP6BJ.js

Issues

Severity Message Source file Line(s)
⚠️ warning Bundle size (236.51 kB) is below minimum threshold (300 kB)

CI Comment

πŸ”Œ Plugin πŸ›‘οΈ Audit πŸ“ Previous value πŸ“ Current value πŸ”„ Value change
Bundle size Total 🟨 12.0 MB πŸŸ₯ 13.1 MB ↑ +9.2 %
Bundle size Initial Chunks 🟩 6.0 MB 🟩 6.2 MB ↑ +3.3 %
Bundle size Lazy Chunks πŸŸ₯ 6.0 MB πŸŸ₯ 6.9 MB ↑ +15.0 %
Bundle size Main Bundle 🟨 5.0 MB πŸŸ₯ 5.2 MB ↑ +4.0 %
Bundle size Auth Module 🟨 1.1 MB πŸŸ₯ 1.3 MB ↑ +18.2 %
Bundle size Lazy Products 🟩 3.0 MB 🟩 3.1 MB ↑ +3.3 %

Market Research

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

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions