Building fast Angular apps is one thing. Keeping them fast as the codebase grows and many developers contribute is another. Without guardrails, large bundles slip into production: third-party libraries creep in, lazy loading is forgotten, and a single careless import can add hundreds of kilobytes to a critical route. The result: slower first paint, worse Core Web Vitals, and frustrated users.
In this article I’ll show a practical, production-ready approach to an Angular Performance Budget System that automatically prevents heavy bundles from being built or deployed. The goal is twofold:
Build-time enforcement — fail builds or block merges when budgets are breached.
Runtime safeguards — detect and mitigate heavy bundles that still reach clients.
I’ll cover design, concrete Angular and Node examples, CI integration (GitHub Actions), monitoring, and recommended budgets. The article is written in simple Indian English and aimed at senior developers who want a pragmatic, repeatable solution.
Table of contents
Why a performance budget system matters
What to budget (metrics and assets)
Angular CLI built-in budgets (quick wins)
Build-time enforcement: advanced checks and custom scripts
Integrating budgets into CI/CD (example: GitHub Actions)
Runtime safeguards and progressive rollout
Monitoring, alerts, and rollback strategies
Developer workflow and education
Performance budget thresholds — practical recommendations
Caveats and limitations
Conclusion
1. Why a performance budget system matters
A performance budget is an explicit limit on a resource (e.g., JS bundle size, image weight, number of fonts) meant to keep your app fast. Enforcing budgets is important because:
Teams scale: more code, more dependencies, and more accidental regressions.
Shipping large bundles hurts users on slow networks (mobile, 2G/3G) common in many regions.
Using budgets as blocking rules turns performance from an afterthought into a continuous quality gate.
We will implement both automated build blockers and runtime guards so performance is not just tested — it is enforced.
2. What to budget (metrics and assets)
A budget system should track both network and render concerns:
Main JS bundle (critical path).
Lazy chunks (per route).
Vendor bundle (third-party libs).
Initial download size (all assets required for first render).
CSS size (critical CSS).
Fonts (woff2 weight).
Images (initial hero images).
Number of requests (HTTP/2 connection limits matter).
Runtime metrics (largest contentful paint LCP, FCP).
Start with bundle sizes and images; add runtime metrics later using Lighthouse or Sentry.
3. Angular CLI built-in budgets (quick wins)
Angular CLI has a simple budgets feature you can add to angular.json. This is the fastest way to block a build when a size is exceeded.
Example angular.json snippet
"projects": {
"my-app": {
"architect": {
"build": {
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "250kb",
"maximumError": "500kb"
},
{
"type": "bundle",
"name": "main",
"maximumWarning": "150kb",
"maximumError": "300kb"
},
{
"type": "bundle",
"name": "vendor",
"maximumWarning": "200kb",
"maximumError": "400kb"
}
]
}
}
}
}
}
}
maximumWarning will surface a warning.
maximumError makes the build fail (non-zero exit).
Types: initial, bundle, anyComponentStyle, allScript, all.
Pros: simple and built into Angular CLI.
Cons: coarse control; does not cover per-route chunks easily; no image/font checks.
4. Build-time enforcement: advanced checks and custom scripts
Angular CLI budgets are great, but for a comprehensive policy you need custom checks:
Per-chunk budgets (for route chunks).
Vendor submodule checks (e.g., large package introduced).
Image/font weight checks.
Source map size checks (source maps add server cost).
Custom regression checks (bundle increased by > X% vs baseline).
4.1 Generate stats.json from Angular build
First generate a stats.json (webpack stats) to inspect bundles:
ng build --configuration production --stats-json
# produces dist/stats.json
4.2 Node script to parse stats.json and block build
Create scripts/check-budgets.js:
const fs = require('fs');
const path = require('path');
const statsPath = path.resolve(__dirname, '../dist/stats.json');
if (!fs.existsSync(statsPath)) {
console.error('stats.json not found. Run ng build --stats-json first.');
process.exit(1);
}
const stats = JSON.parse(fs.readFileSync(statsPath, 'utf8'));
// Example budgets: main <= 300 KB, vendor <= 500 KB, any chunk <= 200 KB
const KB = 1024;
const budgets = {
main: 300 * KB,
vendor: 500 * KB,
chunk: 200 * KB,
};
// Gather assets
const assets = stats.assets || [];
let failed = false;
for (const asset of assets) {
const name = asset.name;
const size = asset.size;
// match main.*.js
if (/^main.*\.js$/.test(name) && size > budgets.main) {
console.error(`Budget exceeded: ${name} is ${(size/KB).toFixed(1)} KB, limit ${(budgets.main/KB)} KB`);
failed = true;
}
// match vendor.*
if (/^vendor.*\.js$/.test(name) && size > budgets.vendor) {
console.error(`Budget exceeded: ${name} is ${(size/KB).toFixed(1)} KB, limit ${(budgets.vendor/KB)} KB`);
failed = true;
}
// other chunk files
if (/\.js$/.test(name) && !/^main.*|vendor.*|polyfills.*|runtime.*$/.test(name) && size > budgets.chunk) {
console.error(`Budget exceeded: chunk ${name} is ${(size/KB).toFixed(1)} KB, limit ${(budgets.chunk/KB)} KB`);
failed = true;
}
}
if (failed) process.exit(2);
console.log('All budgets OK.');
How it works:
Build with --stats-json.
Run node scripts/check-budgets.js.
The script exits non-zero if budgets are breached, causing a CI fail.
4.3 Advanced: compare against baseline bundle sizes
Store previous artifact sizes (e.g., from last released stats.json) and fail if the bundle grows more than X%:
const baseline = require('../build-baseline.json'); // { main: 250000, vendor: 400000, ... }
const current = computeCurrentSizes(stats);
for (const key of Object.keys(baseline)) {
const base = baseline[key];
const cur = current[key] || 0;
const pct = ((cur - base) / base) * 100;
if (pct > 10) {
console.error(`${key} increased by ${pct.toFixed(1)}% (allowed 10%)`);
failed = true;
}
}
Store build-baseline.json in a safe place and update it only after manual review and approval.
4.4 Checking images, fonts and other static assets
Create a script that recursively scans the dist folder and sums image/font file sizes. Fail if the total initial image weight exceeds a threshold.
5. Integrating budgets into CI/CD (example: GitHub Actions)
Make checks automatic by running them in CI. Example GitHub Actions workflow ci.yml:
name: CI
on:
push:
branches: [ main ]
pull_request:
types: [opened, synchronize, reopened]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
- name: Install
run: npm ci
- name: Build production and generate stats
run: npm run build:prod -- --stats-json
- name: Run bundle budget check
run: node scripts/check-budgets.js
If check-budgets.js exits non-zero, the job fails and the PR is blocked. You can add an action to post a comment on the PR explaining the failure.
5.1 Fail fast for PRs
Run lighter checks for PRs (only changed routes) and full checks on main branch.
5.2 Fail only if budget exceeded by margin
Sometimes you want CI to warn rather than block for small regressions. Use a policy flag:
Implement this logic in your script with configurable environment variables.
6. Runtime safeguards and progressive rollout
Build-time checks are strong, but not foolproof. You should add runtime protections to reduce user impact if heavy bundles slip into production.
6.1 Dynamic import guards
When loading a lazy route, measure the download size before instantiating it. Using the Fetch API with HEAD is not always possible for chunk files, but you can use server metadata or check Content-Length on the chunk URL.
Simpler strategy: track performance.getEntriesByType('resource') and alert if a resource is large:
const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
const large = resources.filter(r => r.initiatorType === 'script' && r.transferSize > 200 * 1024);
if (large.length) {
// show fallback UI, or delay non-essential work
}
6.2 Conditional loading and skeletons
If a chunk is large, avoid loading it eagerly. Replace with a lightweight fallback and offer a button to load when user explicitly wants the feature.
6.3 Feature flags & staged rollout
If heavy feature causes budget spike, gate it behind a feature flag and ramp to users gradually. That reduces production blast radius.
6.4 Client-side blocking? (use cautiously)
You can refuse to initialize certain heavy features client-side if the runtime environment is poor (slow network or CPU). Use navigator.connection.effectiveType and navigator.deviceMemory to make decisions:
if (navigator.connection && navigator.connection.effectiveType === '2g') {
// force minimal UI
}
Never silently remove critical functionality — inform the user or provide opt-in.
7. Monitoring, alerts, and rollback strategies
Even with build checks, monitor production.
7.1 Integrate Lighthouse CI or PageSpeed checks
Run scheduled Lighthouse audits and fail when budgets drift.
7.2 Real-user monitoring (RUM)
Use Sentry, New Relic, or Google Analytics to collect LCP, FID, and CLS. Alert when Core Web Vitals degrade.
7.3 Automated rollback
If RUM shows sudden regressions after deploy, automate rollback steps or block further promotion of the release.
8. Developer workflow and education
Technical enforcement works only when teams understand why.
Document budgets and reasons in the repo README.
Add a pre-commit hook that runs a lightweight check (e.g., source-map-explorer diff on changed files).
Create a “how to reduce bundle” checklist: lazy load, use import() not require, prefer smaller libraries, tree-shakeable packages, prefer date-fns over moment, use on-demand icons rather than whole icon packs.
Run periodic bundle analysis sessions (monthly) and publish a “bundle health” report.
9. Performance budget thresholds — practical recommendations
These are sensible starting points for modern SPAs. Adjust for your product and audience.
Initial JS (after gzip): 200–300 KB (goal), 500 KB (absolute max).
Main bundle: 100–200 KB.
Lazy chunk (route): 50–150 KB.
Vendor bundle: keep < 400 KB if possible.
CSS: initial critical CSS < 50 KB.
Fonts: 50 KB per used font family (subset).
Images (initial): hero image < 200 KB (webp/avif preferred).
Remember: gzipped and brotli sizes matter for network, so check compressed sizes in stats.json or use gzip-size tools in scripts.
10. Caveats and limitations
Bundles differ by build environment; AOT vs JIT, minifier versions, and toolchain updates can change sizes. Always pin build tools.
Third-party packages may load dynamic sub-dependencies unexpectedly. Use npm ls <pkg> and bundle-phobia.
Overly strict budgets can block legitimate feature work. Use clear exception and review processes.
Client detection (navigator.connection) is not always reliable across browsers. Use it as a hint, not the sole decision factor.
11. Conclusion
A performant Angular application is not an accident. It needs automated, enforceable guardrails that stop heavy bundles from entering your release pipeline and runtime safeguards that reduce user impact if something slips through. Combining Angular CLI budgets, custom stats.json parsers, CI gating, runtime checks, monitoring, and developer education gives you a full performance budget system.
Actionable next steps (practical checklist):
Add initial budgets to angular.json and set maximumError for critical bundles.
Add ng build --stats-json to your CI pipeline.
Add a scripts/check-budgets.js that fails CI when limits are exceeded.
Store a vetted build-baseline.json and use percentage growth checks.
Add scheduled Lighthouse audits and RUM monitoring.
Teach developers the quick remedies to reduce bundle size.