Ship faster apps with smaller bundles — your users (and Lighthouse score) will thank you
Ever deployed an Angular app only to watch users bounce because it took forever to load? You're not alone. I've seen Angular apps balloon to 5MB+ bundles, crushing performance and killing user experience. But here's the thing — most of that weight is completely unnecessary.
What if I told you that you could slash your bundle size by 60% or more with just four strategic optimizations?
Before we dive into the examples, a quick note:
The code snippets provided here may be syntax from earlier Angular versions. Code Snippets just for understanding only.
By the end of this article, you'll know exactly how to analyze, optimize, and maintain lean Angular bundles that load in seconds, not minutes. We'll dive into practical techniques that you can implement today, complete with code examples that actually work in Angular 18+.
Why Bundle Size is Your Silent Performance Killer
Your Angular bundle size directly impacts:
- Initial load time: Every 100KB adds roughly 1 second on 3G
- SEO rankings: Google penalizes slow sites (Core Web Vitals matter!)
- Conversion rates: Amazon found every 100ms delay costs 1% in sales
Angular's build system (now using ESBuild by default) does some optimization out of the box, but it's not magic. Without deliberate optimization, your bundles accumulate cruft faster than a developer's Downloads folder.
When Should You Start Optimizing?
Here's when bundle optimization becomes critical:
- Your main bundle exceeds 500KB (gzipped)
- Lighthouse Performance score drops below 70
- Initial load takes more than 3 seconds on 4G
- Users complain about sluggish performance
- Your analytics show high bounce rates on first visit
Quick check: Run ng build and look at the output. See those yellow or red warnings about bundle sizes? Time to optimize.
Step 1: Analyze Your Bundle (Know Your Enemy)
You can't fix what you can't measure. Let's expose what's bloating your bundle.
# Install webpack-bundle-analyzer
npm install --save-dev webpack-bundle-analyzer
# Build with stats output
ng build --stats-json
# Analyze the bundle
npx webpack-bundle-analyzer dist/your-app/stats.json
For a more integrated approach, use source-map-explorer:
// package.json scripts
{
"scripts": {
"analyze": "ng build --source-map && source-map-explorer dist/**/*.js",
"analyze:prod": "ng build --configuration production --source-map && source-map-explorer dist/**/*.js"
}
}
// Install source-map-explorer
// npm install --save-dev source-map-explorer
// Run analysis
// npm run analyze:prod
This generates an interactive treemap showing exactly what's eating your bundle space. I bet you'll find surprises — like that one library you imported for a single function.
Pro tip: Set up bundle analysis in your CI/CD pipeline. Track bundle size over time and catch bloat before it ships.
Step 2: Remove Unused Code and Dependencies
Time to go on a diet. Most Angular apps carry dead weight from unused imports and bloated libraries.
Smart Imports Save Kilobytes
// BAD: Imports entire RxJS library (200KB+)
import * as rxjs from 'rxjs';
import { Observable } from 'rxjs';
// BAD: Imports all operators
import * as operators from 'rxjs/operators';
// GOOD: Tree-shakable imports (only what you need)
import { Observable, Subject, of } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
// BAD: Importing entire lodash (70KB+)
import _ from 'lodash';
const result = _.debounce(fn, 300);
// GOOD: Import specific functions (2KB)
import debounce from 'lodash-es/debounce';
const result = debounce(fn, 300);
// Even better: Use native alternatives when possible
// Instead of lodash's map, filter, reduce — use native Array methods
// Component example with optimized imports
import { Component, OnInit, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
@Component({
selector: 'app-search',
imports: [CommonModule, ReactiveFormsModule],
template: `
<input [formControl]="searchControl" placeholder="Search...">
@if (isSearching()) {
<div>Searching...</div>
}
`
})
export class SearchComponent implements OnInit {
searchControl = new FormControl('');
isSearching = signal(false);
ngOnInit() {
this.searchControl.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged()
).subscribe(value => {
this.performSearch(value);
});
}
private performSearch(query: string | null) {
this.isSearching.set(true);
// Search logic here
}
}
Audit and Remove Unused Dependencies
# Find unused dependencies
npx depcheck
# Check bundle impact of each dependency
npx bundle-phobia-cli moment
# Shows: moment@2.29.4 is 72.8KB minified, 232KB minified+gzipped
# Alternative: use date-fns instead of moment
npm uninstall moment
npm install date-fns
# date-fns is tree-shakable, moment isn't
# Remove unused Angular packages
npm uninstall @angular/animations # If not using animations
npm uninstall @angular/forms # If only using reactive forms
Ever checked how much moment.js adds to your bundle? Spoiler: it's huge. Switch to date-fns and save 200KB+ instantly.
Step 3: Implement Lazy Loading and Standalone Components
This is where the magic happens. Lazy loading splits your app into chunks that load on-demand.
Modern Route-Based Lazy Loading
// app.routes.ts - Modern Angular 18+ routing
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
loadComponent: () => import('./home/home.component').then(m => m.HomeComponent)
},
{
path: 'dashboard',
// Lazy load entire feature with child routes
loadChildren: () => import('./dashboard/dashboard.routes').then(m => m.DASHBOARD_ROUTES)
},
{
path: 'admin',
// Lazy load with guards
loadComponent: () => import('./admin/admin.component').then(m => m.AdminComponent),
canActivate: [() => import('./guards/auth.guard').then(m => m.canActivate())]
},
{
path: 'products',
// Preload this route for better UX
loadChildren: () => import('./products/products.routes').then(m => m.PRODUCTS_ROUTES),
data: { preload: true }
}
];
// dashboard/dashboard.routes.ts
import { Routes } from '@angular/router';
export const DASHBOARD_ROUTES: Routes = [
{
path: '',
loadComponent: () => import('./dashboard-layout.component').then(m => m.DashboardLayoutComponent),
children: [
{
path: 'analytics',
loadComponent: () => import('./analytics/analytics.component').then(m => m.AnalyticsComponent)
},
{
path: 'reports',
loadComponent: () => import('./reports/reports.component').then(m => m.ReportsComponent)
}
]
}
];
// dashboard-layout.component.ts
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-dashboard-layout',
imports: [RouterOutlet, CommonModule],
template: `
<div class="dashboard-container">
<nav class="sidebar">
<a routerLink="analytics">Analytics</a>
<a routerLink="reports">Reports</a>
</nav>
<main class="content">
<router-outlet />
</main>
</div>
`
})
export class DashboardLayoutComponent {}
Smart Component Lazy Loading
// chart-container.component.ts
import { Component, ViewContainerRef, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-chart-container',
imports: [CommonModule],
template: `
<button (click)="loadChart()">Show Analytics Chart</button>
@if (chartLoaded()) {
<div #chartContainer></div>
} @else if (isLoading()) {
<div class="spinner">Loading chart...</div>
}
`
})
export class ChartContainerComponent {
chartLoaded = signal(false);
isLoading = signal(false);
constructor(private viewContainerRef: ViewContainerRef) {}
async loadChart() {
this.isLoading.set(true);
// Dynamically import heavy chart component
const { ChartComponent } = await import('./chart/chart.component');
// Create component instance
const componentRef = this.viewContainerRef.createComponent(ChartComponent);
// Pass data to the component
componentRef.instance.data = this.getChartData();
this.chartLoaded.set(true);
this.isLoading.set(false);
}
private getChartData() {
return {
labels: ['Jan', 'Feb', 'Mar'],
values: [100, 200, 150]
};
}
}
// Lazy load heavy libraries conditionally
@Component({
selector: 'app-editor',
imports: [CommonModule],
template: `
<button (click)="initializeEditor()">Open Editor</button>
@if (editorReady()) {
<div #editorContainer></div>
}
`
})
export class EditorComponent {
editorReady = signal(false);
private editor: any;
async initializeEditor() {
// Load Monaco Editor only when needed (saves 2MB+!)
const monaco = await import('monaco-editor');
this.editor = monaco.editor.create(
document.getElementById('editorContainer')!,
{
value: 'console.log("Hello world");',
language: 'typescript',
theme: 'vs-dark'
}
);
this.editorReady.set(true);
}
ngOnDestroy() {
this.editor?.dispose();
}
}
Not everything needs to be route-based. Heavy components can be lazy-loaded on demand:Think about it — why load a 2MB chart library if 80% of users never click on the analytics tab?
Step 4: Enable Advanced Build Optimizations
Time to squeeze every last byte out of your build with Angular's optimization arsenal.
// angular.json - Production optimization settings
{
"projects": {
"your-app": {
"architect": {
"build": {
"configurations": {
"production": {
"optimization": {
"scripts": true,
"styles": {
"minify": true,
"inlineCritical": true
},
"fonts": {
"inline": true
}
},
"outputHashing": "all",
"sourceMap": false,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
],
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
]
},
"development": {
"optimization": false,
"sourceMap": true,
"vendorChunk": true,
"buildOptimizer": false
}
}
},
"serve": {
"configurations": {
"production": {
"buildTarget": "your-app:build:production",
"gzip": true,
"brotli": true
}
}
}
}
}
}
}
Enable Compression and Modern Build Features
// server.ts - Express server with compression
import compression from 'compression';
import express from 'express';
const app = express();
// Enable gzip/brotli compression
app.use(compression({
level: 9, // Maximum compression
threshold: 0, // Compress everything
filter: (req, res) => {
// Don't compress responses with this request header
if (req.headers['x-no-compression']) {
return false;
}
// Fallback to standard filter
return compression.filter(req, res);
}
}));
// Serve static files with proper cache headers
app.use(express.static('dist', {
maxAge: '1y',
etag: false,
setHeaders: (res, path) => {
if (path.endsWith('.html')) {
// Don't cache HTML files
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
}
}
}));
// Build script with compression (package.json)
{
"scripts": {
"build:prod": "ng build --configuration production && npm run compress",
"compress": "gzip -k -9 dist/**/*.{js,css,html} && brotli-cli compress dist/**/*.{js,css,html}",
"analyze:size": "npm run build:prod && ls -lah dist/**/*.{js,css}"
}
}
// Preload strategy for better performance
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
import { routes } from './app.routes';
// Custom preload strategy
class CustomPreloadStrategy implements PreloadingStrategy {
preload(route: Route, load: () => Observable<any>): Observable<any> {
// Preload routes marked with data: { preload: true }
return route.data && route.data['preload'] ? load() : of(null);
}
}
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(
routes,
withPreloading(CustomPreloadStrategy)
)
]
};
Testing Your Optimizations
Let's make sure our optimizations actually work:
// bundle-size.spec.ts - Unit test for bundle size
import { readFileSync, statSync } from 'fs';
import { join } from 'path';
describe('Bundle Size Tests', () => {
const distPath = join(__dirname, '../../../dist');
it('should keep main bundle under 500KB', () => {
const mainBundle = join(distPath, 'main.js');
const stats = statSync(mainBundle);
const sizeInKB = stats.size / 1024;
expect(sizeInKB).toBeLessThan(500);
});
it('should have lazy-loaded chunks', () => {
const files = readdirSync(distPath);
const lazyChunks = files.filter(f => f.includes('chunk'));
expect(lazyChunks.length).toBeGreaterThan(0);
});
it('should not include unused libraries', () => {
const mainContent = readFileSync(join(distPath, 'main.js'), 'utf-8');
// Check that moment.js is not in the bundle
expect(mainContent).not.toContain('moment');
// Check that only used RxJS operators are included
expect(mainContent).toContain('debounceTime');
expect(mainContent).not.toContain('exhaustMap'); // Unused operator
});
});
// performance.spec.ts - E2E performance tests
import { test, expect } from '@playwright/test';
test.describe('Performance Metrics', () => {
test('should load within 3 seconds on slow 3G', async ({ page }) => {
// Simulate slow 3G
await page.route('**/*', route => route.continue());
await page.context().setOffline(false);
const client = await page.context().newCDPSession(page);
await client.send('Network.emulateNetworkConditions', {
offline: false,
downloadThroughput: (1.6 * 1024 * 1024) / 8, // 1.6 Mbps
uploadThroughput: (750 * 1024) / 8, // 750 Kbps
latency: 150
});
const startTime = Date.now();
await page.goto('http://localhost:4200');
await page.waitForLoadState('networkidle');
const loadTime = Date.now() - startTime;
expect(loadTime).toBeLessThan(3000);
});
test('should have good Core Web Vitals', async ({ page }) => {
await page.goto('http://localhost:4200');
// Measure LCP (Largest Contentful Paint)
const lcp = await page.evaluate(() => {
return new Promise(resolve => {
new PerformanceObserver(list => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
resolve(lastEntry.renderTime || lastEntry.loadTime);
}).observe({ entryTypes: ['largest-contentful-paint'] });
});
});
expect(lcp).toBeLessThan(2500); // Good LCP is under 2.5s
});
});
Pros and Cons of Bundle Optimization
The Good Stuff:
- 50-70% faster initial load times — Users see content quicker
- Better SEO rankings — Google loves fast sites
- Lower bounce rates — Users stick around when apps load fast
- Reduced hosting costs — Smaller files = less bandwidth
- Improved mobile experience — Critical for users on slower connections
The Trade-offs:
- More complex build configuration — Takes time to set up properly
- Potential lazy-loading delays — Some features load on-demand
- Debugging gets harder — Source maps disabled in production
- Risk of over-optimization — Too many chunks can hurt performance
- Maintenance overhead — Need to monitor bundle size regularly
How to Measure Your Success
Before celebrating, let's verify the improvements:
#!/bin/bash
# performance-check.sh
echo "Building optimized version..."
ng build --configuration production
echo "Bundle Size Analysis:"
echo "========================"
# Check main bundle size
MAIN_SIZE=$(stat -f%z dist/main.*.js 2>/dev/null || stat -c%s dist/main.*.js 2>/dev/null)
MAIN_SIZE_KB=$((MAIN_SIZE / 1024))
if [ $MAIN_SIZE_KB -lt 500 ]; then
echo "Main bundle: ${MAIN_SIZE_KB}KB (Good!)"
else
echo "Main bundle: ${MAIN_SIZE_KB}KB (Too large!)"
fi
# Check total size
TOTAL_SIZE=$(du -sh dist | cut -f1)
echo "Total dist size: $TOTAL_SIZE"
# Run Lighthouse
echo "Running Lighthouse..."
npx lighthouse http://localhost:4200 \
--only-categories=performance \
--output=json \
--output-path=./lighthouse-report.json
# Parse and display score
PERF_SCORE=$(cat lighthouse-report.json | grep -o '"performance":{"score":[0-9.]*' | grep -o '[0-9.]*$')
SCORE_PERCENT=$(echo "$PERF_SCORE * 100" | bc)
echo "Lighthouse Performance Score: ${SCORE_PERCENT}%"
if (( $(echo "$PERF_SCORE >= 0.9" | bc -l) )); then
echo "Excellent performance!"
elif (( $(echo "$PERF_SCORE >= 0.7" | bc -l) )); then
echo "Good, but room for improvement"
else
echo "Performance needs work"
fi
Real-world targets you should aim for:
- Main bundle: Under 250KB (gzipped)
- Lazy chunks: 50-150KB each
- Total initial load: Under 500KB
- Lighthouse score: 90+ for performance
- First Contentful Paint: Under 1.5s
- Time to Interactive: Under 3s
Common Mistakes to Avoid
Learn from my pain — here are the gotchas that waste hours:
1. Accidentally disabling tree-shaking:
Setting "sideEffects": false in the wrong package.json can break your app.
2. Forgetting to update budgets:
Your CI/CD should fail if bundles exceed limits. Don't let bloat sneak in.
3. Over-eager lazy loading:
Loading 50 tiny chunks is worse than one slightly larger bundle. Find the balance.
4. Loading unnecessary polyfills:
Angular 18 targets modern browsers by default. Don't ship IE11 polyfills in 2025!
5. Keeping source maps in production:
They're great for debugging but add 3-5x to your bundle size.
Bonus Tips from the Trenches
Here's what I wish someone told me earlier:
Use the Angular DevTools Profiler — It shows exactly which components are slowing down your app. Install the browser extension and look for components taking more than 16ms to render.
Consider Module Federation for micro-frontends — If your app is huge, split it into separate deployable units. Each team can optimize independently.
Preconnect to critical domains:
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="dns-prefetch" href="https://api.yourapp.com">
Use a CDN for static assets — Your 500KB vendor bundle loads way faster from a CDN edge server than your origin.
Monitor bundle size in PR checks — Add this to your CI pipeline and reject PRs that increase bundle size without justification.
Quick Recap
You've just learned how to transform your bloated Angular app into a lean, mean, loading machine:
- Analyze — Use webpack-bundle-analyzer to see what's eating space
- Remove — Eliminate unused code and optimize imports
- Split — Implement lazy loading for routes and heavy components
- Optimize — Enable production builds with compression and budgets
The best part? These techniques compound. Combine all four and watch your bundle shrink by 60-80%.
Remember: Performance optimization isn't a one-time task. Make it part of your development workflow. Set up monitoring, enforce budgets, and regularly audit your dependencies.
Your users are waiting. Every second counts. Now go optimize that bundle!
What's Your Experience?
Have you tackled a massive Angular bundle before? What was your biggest surprise when you analyzed it? Drop a comment below — I'm curious what monsters you've found lurking in your bundles.
Found this helpful? Hit that button (you can clap up to 50 times, just saying) so other devs can discover these optimization techniques too.
Want more Angular performance tips delivered to your inbox? Follow me here on Medium or subscribe to my weekly newsletter where I share one actionable performance tip every Tuesday.
Action point: Pick ONE optimization from this article and implement it today. Then share your before/after bundle sizes in the comments. Let's see who gets the biggest reduction!
Follow Me for More Angular & Frontend Goodness:
I regularly share hands-on tutorials, clean code tips, scalable frontend architecture, and real-world problem-solving guides.
- 💼 LinkedIn — Let’s connect professionally
- 🎥 Threads — Short-form frontend insights
- 🐦 X (Twitter) — Developer banter + code snippets
- 👥 BlueSky — Stay up to date on frontend trends
- 🌟 GitHub Projects — Explore code in action
- 🌐 Website — Everything in one place
- 📚 Medium Blog — Long-form content and deep-dives
- 💬 Dev Blog — Free Long-form content and deep-dives
- ✉️ Substack — Weekly frontend stories & curated resources
- 🧩 Portfolio — Projects, talks, and recognitions
- ✍️ Hashnode — Developer blog posts & tech discussions
- ✍️ Reddit — Developer blog posts & tech discussions
Top comments (0)