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

πŸ‘‰ #??? – BundleBudget Plugin PoC Implementation


Metric

Bundle size over time across key dimensions.
Parsed from --stats-json output and grouped by logical artifact buckets.

Property Value Description
value 132341 Total size of all chunks.
displayValue 13.4 MB Delta compared to base (cached or previous release).
score 1 1 if within budget or unchanged, 0 if regression is detected.

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 Angular/Esbuild builds.
  • Identify and compare main, initial, and lazy chunks.
  • 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.

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%

Integration Requirements


TODO:

  • Scoring (compare against budget/trashhold)
  • identify hashed files
  • unified data

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

stats.json

The stats.json follows the following types

stats.json types ```ts /** * Describes the kind of an import. This is a string literal union * for better type safety and autocompletion. */ export type ImportKind = | 'entry-point' // An import statement, e.g., `import "foo"` | 'import-statement' // A require call, e.g., `require("foo")` | 'require-call' // A dynamic import, e.g., `import("foo")` | 'dynamic-import' // A require.resolve call, e.g., `require.resolve("foo")` | 'require-resolve' // An @import rule in CSS | 'import-rule' // A url() token in CSS | 'url-token';

/**

  • Represents a single import record within a file.
    /
    export interface MetafileImport {
    /
    *
    • The path of the imported file, relative to the working directory.
      /
      path: string;
      /
      *
    • The kind of the import.
      /
      kind: ImportKind;
      /
      *
    • If true, this dependency is external and was not included in the bundle.
      /
      external?: boolean;
      /
      *
    • The original path string as it appeared in the source code.
    • Useful for context, especially with aliases or tsconfig paths.
      */
      original?: string;
      }

/**

  • Details about a single input file that contributed to the bundle.
    /
    export interface MetafileInput {
    /
    *
    • The size of the file in bytes.
      /
      bytes: number;
      /
      *
    • A list of all imports within this file.
      */
      imports: MetafileImport[];
      }

/**

  • Details about the contribution of a single input file to a specific output file.
    /
    export interface MetafileOutputInput {
    /
    *
    • The number of bytes from this input file that are part of this specific output file.
      */
      bytesInOutput: number;
      }

/**

  • Represents a single output file (a "chunk") from the build process.
    /
    export interface MetafileOutput {
    /
    *
    • The total size of this output file in bytes.
      /
      bytes: number;
      /
      *
    • A map of all input files that were bundled into this output file.
    • The key is the input file path, and the value provides details about its contribution.
      /
      inputs: {
      [path: string]: MetafileOutputInput;
      };
      /
      *
    • A list of imports that were not bundled and remain as import/require statements in the output.
    • This is common for external packages.
      /
      imports: MetafileImport[];
      /
      *
    • A list of all exported symbols from this output file.
      /
      exports: string[];
      /
      *
    • The entry point that corresponds to this output file. This is only present if the
    • output file is an entry point.
      */
      entryPoint?: string;
      }

/**

  • The root structure of the metafile JSON generated by esbuild.
    /
    export interface EsbuildMetafile {
    /
    *
    • A map of all input files involved in the build.
    • The key is the file path relative to the working directory.
      /
      inputs: {
      [path: string]: MetafileInput;
      };
      /
      *
    • A map of all output files generated by the build.
    • The key is the file path relative to the outdir.
      */
      outputs: {
      [path: string]: MetafileOutput;
      };
      }
</details>


Example stats.json

<details>
<summary>Example stats.json</summary>
```json
{
  "inputs": {
    "node_modules/@angular/core/fesm2022/untracked-BKcld_ew.mjs": {
      "bytes": 20949,
      "imports": [
        {
          "path": "<runtime>",
          "kind": "import-statement",
          "external": true
        }
      ],
      "format": "esm"
    },
    "node_modules/@angular/core/fesm2022/primitives/di.mjs": {
      "bytes": 1158,
      "imports": [],
      "format": "esm"
    },
    "node_modules/@angular/core/fesm2022/primitives/signals.mjs": {
      "bytes": 3167,
      "imports": [
        {
          "path": "node_modules/@angular/core/fesm2022/untracked-BKcld_ew.mjs",
          "kind": "import-statement",
          "original": "../untracked-BKcld_ew.mjs"
        },
        {
          "path": "node_modules/@angular/core/fesm2022/untracked-BKcld_ew.mjs",
          "kind": "import-statement",
          "original": "../untracked-BKcld_ew.mjs"
        },
        {
          "path": "<runtime>",
          "kind": "import-statement",
          "external": true
        }
      ],
      "format": "esm"
    }
  },
  "outputs": {
    "chunk-MXYP3LXH.js.map": {
      "imports": [],
      "exports": [],
      "inputs": {},
      "bytes": 15799
    },
    "chunk-MXYP3LXH.js": {
      "imports": [
        {
          "path": "chunk-PH2Q42UX.js",
          "kind": "import-statement"
        },
        {
          "path": "chunk-A3IGLJVE.js",
          "kind": "import-statement"
        },
        {
          "path": "chunk-MMWVBYNW.js",
          "kind": "import-statement"
        },
        {
          "path": "chunk-KTVYRR64.js",
          "kind": "import-statement"
        }
      ],
      "exports": [
        "a",
        "b"
      ],
      "inputs": {
        "lib/re-captcha.ts": {
          "bytesInOutput": 165
        },
        "lib/re-captcha.service.ts": {
          "bytesInOutput": 2408
        },
        "lib/re-captcha-noop.service.ts": {
          "bytesInOutput": 342
        },
        "lib/recaptcha.module.ts": {
          "bytesInOutput": 120
        },
        "index.ts": {
          "bytesInOutput": 0
        }
      },
      "bytes": 3345
    },
    "chunk-5HVHEFQE.js.map": {
      "imports": [],
      "exports": [],
      "inputs": {},
      "bytes": 3942
    },
    "chunk-5HVHEFQE.js": {
      "imports": [
        {
          "path": "chunk-J5C35BAI.js",
          "kind": "import-statement"
        },
        {
          "path": "chunk-PUHQEZG3.js",
          "kind": "import-statement"
        },
        {
          "path": "chunk-ZQTK7NVT.js",
          "kind": "import-statement"
        },
        {
          "path": "chunk-KTVYRR64.js",
          "kind": "import-statement"
        }
      ],
      "exports": [
        "a"
      ],
      "inputs": {
        "auth-state.service.ts": {
          "bytesInOutput": 732
        }
      },
      "bytes": 946
    }
  }
}

Angular App - Tree example

πŸ“ main-TVMA6NI7.js                98.7 kB
β”œβ”€β–Ά chunk-5QRGP6BJ.js             105.9 kB
β”œβ”€β–· chunk-NY7Q4ZJ6.js               0.34 kB
β”‚   └─▢ chunk-5QRGP6BJ.js           re-used
β”œβ”€ …2 more imports (β–·/β–Ά)           ≀ 0.30 kB
β”œβ”€β”€ @angular/router                 63.3 kB / 2 files
β”œβ”€β”€ @angular/common                  5.2 kB / 3 files
β”œβ”€β”€ @angular/platform-browser       11.3 kB / 5 files
β”œβ”€β”€ src/app/app.component.ts        17.6 kB
β”œβ”€β”€ src/app/app.routes.ts            0.10 kB
β”œβ”€β”€ src/app/app.config.ts            0.05 kB
β”œβ”€β”€ src/main.ts                      0.04 kB
└── …2 more inputs                   ≀ 0.30 kB

πŸ“¦ chunk-NY7Q4ZJ6.js              0.34 kB  (lazy)
β”œβ”€β–Ά chunk-5QRGP6BJ.js             re-used
└── src/app/route-1.component.ts  0.23 kB

πŸ“¦ chunk-5QRGP6BJ.js            105.9 kB  (shared)
β”œβ”€β”€ @angular/core                 86.1 kB / 3 files
β”œβ”€β”€ tslib                         1.86 kB / 2 files
β”œβ”€β”€ rxjs                          4.71 kB / 4 files
└── …44 more inputs               ≀ 3.40 kB

πŸ“„ app.component.css               2.86 kB
└── inline from HTML               2.93 kB

πŸ“„ polyfills.js                    33.8 kB
β”œβ”€β”€ zone.js                        33.8 kB / 1 file
└── angular:polyfills               0 B

πŸ“„ styles.css                      0 B
β”œβ”€β”€ src/styles.css                  0 B
└── angular:styles/global           0 B

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

Option Types

export interface BundleStatsConfig {
  slug: string;
  include: string[];
  exclude: string[];
  thresholds?: {
    total?: TotalThresholds;
    chunk?: ChunkThresholds;
  };
}
export interface BundleThresholds {
  bytesChange: MinMax;
  percentageChange: MinMax;
}
export type MinMax = [number, number];
export interface TotalThresholds extends BundleThresholds {
  chunks: MinMax;
}
export interface ChunkThresholds extends BundleThresholds {
  files: MinMax;
}

Examples:

const pluginConfig = {
generateStats: "nx run app-1:build --stats";
statsPath: "./stats.json"
customAudits: [
  {
  {
    name: 'Initial Chunks',
    include: ['src/main.js', 'src/polyfills.js', 'src/runtime.js'],
    thresholds: {
      maxTotalSize: 102400
    }
  },
  {
    name: 'Auth Module',
    include: ['src/app/auth/**'],
    thresholds: {
      bytes: 102400
    }
  },
  {
    name: 'Lazy Products',
    include: ['src/app/products/**', '!src/app/products/shared/**'],
    thresholds: {
      percent: 0.02
    }
  }
]
} 

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-chunk 26.0 KB

πŸ“ main-TVMA6NI7.js                    198.7 kB
β”œβ–Ά chunk-5QRGP6BJ.js             105.9 kB
β”œβ”€ …2 more imports                ≀ 0.30 kB
β”œβ”€ @angular/router                 63.3 kB / 2 files
β”œβ”€ @angular/common                  5.2 kB / 3 files
β”œβ”€ @angular/platform-browser       11.3 kB / 5 files
└─ …2 more inputs                     ≀ 0.30 kB

πŸ“¦ chunk-5QRGP6BJ.js            105.9 kB  
β”œβ”€β”€ @angular/core                 86.1 kB / 3 files
β”œβ”€β”€ tslib                         1.86 kB / 2 files
β”œβ”€β”€ rxjs                          4.71 kB / 4 files
└── …44 more inputs               ≀ 3.40 kB

πŸ“„ app.component.css               2.86 kB 
πŸ“„ polyfills.js                               33.8 kB
πŸ“„ styles.css                                0 B

Issues

Severity Message Source file Line(s)
🚨 error Chunk too big - 200 KB (allowed 14KB) β€” β€”
⚠️ warning Size increase +5% (allowed 3–14%) β€” β€”
⚠️ warning Chunk size - 9KB (allowed 3–14KB) β€” β€”
🚨 error Chunk too small - 1KB (allowed 3–14KB) β€” β€”

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 %

To Discuss

| Severity | Message | Source file | Line(s) |
| 🚨 error | Not enough files - 2 (allowed 3–14) | β€” | β€” |
| 🚨 error | Too many files - 20 (allowed 14) | β€” | β€” |
| 🚨 error | Too many chunks - 20 (allowed 14) | β€” | β€” |
| 🚨 error | Huge size increase +20% (allowed 14%) | β€” | β€” |
| ⚠️ warning | Many chunks - 10 (allowed 3–14) | β€” | β€” |
| ⚠️ warning | Many files - 10 (allowed 3–14) | β€” | β€” |

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions