Improving Angular Performance Scores By Over 150%
· 8 min read
The Challenge
When working with a major North American footwear retailer, their Angular e-commerce platform was facing severe performance issues that directly impacted user experience and conversion rates. The Core Web Vitals were well below recommended thresholds:
| Metric | Original Score | Target | Status |
|---|---|---|---|
| Largest Contentful Paint (LCP) | 8.2s | ≤2.5s | ❌ 327% over |
| Cumulative Layout Shift (CLS) | 0.45 | ≤0.1 | ❌ 350% over |
| First Input Delay (FID) | 320ms | ≤100ms | ❌ 220% over |
These numbers translated to a poor user experience, with visible jank, delayed interactions, and likely revenue loss due to high bounce rates.
Optimization Strategy Overview
We took a systematic approach combining client-side Angular optimizations with server-side improvements. The strategy focused on:
- Reducing initial bundle size through smarter code splitting
- Improving rendering performance with change detection tweaks
- Stabilizing layout to eliminate CLS
- Implementing continuous monitoring with Lighthouse CI
Let's dive into each area.
Angular 17+ Modern Features
With Angular 17+, we leveraged several cutting-edge features that significantly improved performance:
Standalone Components
By migrating to standalone components, we eliminated NgModule overhead and achieved better tree-shaking. Each component can directly import its dependencies, resulting in smaller, more focused bundles.
// Before (NgModule required)
@Component({
selector: 'app-product',
templateUrl: './product.component.html'
})
export class ProductComponent {}
@NgModule({
declarations: [ProductComponent],
imports: [CommonModule]
})
export class ProductModule {}
// After (Standalone)
@Component({
selector: 'app-product',
standalone: true,
imports: [CommonModule, MatCardModule],
templateUrl: './product.component.html'
})
export class ProductComponent {}Standalone components reduced boilerplate and allowed Angular's compiler to eliminate unused code more aggressively, contributing to a ~15% reduction in bundle size.
Deferrable Views (@defer)
For non-critical content that loads after the initial view, we used @defer to lazy render sections only when needed:
<!-- This content loads only when user scrolls near it or after view becomes interactive -->
@defer (on viewport) {
<app-product-recommendations [productId]="product.id" />
}
@defer (on idle) {
<app-related-products [category]="product.category" />
}
@defer (on hover) {
<app-quick-view [product]="product" />
}
@defer (on timer) {
<app-promo-banner />
}This reduced initial render time by deferring ~40% of below-the-fold content.
Signals for Fine-Grained Reactivity
We migrated complex state management logic to use Signals, eliminating zone.js change detection overhead for reactive data:
// Component using signals
@Component({
selector: 'app-cart',
template: `
<div>{{ cartItems().length }} items - {{ total() | currency }}</div>
<button (click)="addItem()">Add Item</button>
`
})
export class CartComponent {
private cartItems = signal<Product[]>([]);
private total = computed(() =>
this.cartItems().reduce((sum, item) => sum + item.price, 0)
);
addItem(product: Product) {
// Automatic UI update with fine-grained change detection
this.cartItems.update(items => [...items, product]);
}
}For components using signals with ChangeDetectionStrategy.OnPush, we saw up to 80% fewer change detection cycles.
NgOptimizedImage Directive
Angular 17+ enhanced the NgOptimizedImage directive with automatic responsive image generation and priority loading:
<!-- Angular automatically generates multiple sizes and uses srcset -->
<img
ngSrc="product.jpg"
width="800"
height="600"
alt="Product"
loading="eager" <!-- Critical above-the-fold images -->
fetchpriority="high"
/>
<!-- For below-the-fold, use lazy loading -->
<img
ngSrc="product-gallery.jpg"
width="1200"
height="900"
alt="Product gallery"
loading="lazy"
/>The directive also automatically converts images to modern formats (WebP) when supported and provides proper sizing attributes to prevent layout shifts.
Server-Side Rendering Improvements
Angular Universal in v17+ introduced streaming SSR and partial hydration:
// In app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
provideServerRendering({
// Enable streaming for faster Time to First Byte
streaming: true,
// Partial hydration: only hydrate interactive components
hydration: {
defer: true // defer hydration of non-critical components
}
})
]
};Streaming SSR sends HTML in chunks as it's generated, improving TTFB by 30-50%. Partial hydration marks non-interactive components as static, reducing client-side JavaScript execution.
Angular CLI 17+ Build Optimizer
The new build system uses esbuild for faster builds and better code-splitting:
// angular.json
{
"projects": {
"my-app": {
"architect": {
"build": {
"options": {
"buildOptimizer": true,
"esbuild": true, // Use esbuild instead of webpack
"sourceMap": false,
"optimization": {
"scripts": true,
"styles": {
"minify": true,
"inlineCritical": true
}
}
}
}
}
}
}
}Build times decreased by ~60% with esbuild, and code-splitting automatically created more granular chunks based on import analysis.
1. Lazy Loading Everything
The single biggest win came from implementing granular lazy loading for feature modules. Angular's default eager loading strategy meant users downloaded the entire application upfront, even for routes they never visited.
Custom Preloading Strategy
We created a custom preloading strategy that prioritizes critical modules while lazily loading others based on user interaction patterns:
@Injectable({ providedIn: 'root' })
export class CustomPreloadingStrategy implements PreloadingStrategy {
preload(route: Route, load: () => Observable<any>): Observable<any> {
// Preload critical modules immediately
if (route.data && route.data['preload']) {
return load();
}
// For other modules, wait until user hovers over related links
return new Observable(observer => {
// Track user navigation hints
this.router.events
.pipe(filter(event => event instanceof NavigationStart))
.subscribe(() => observer.complete());
// Optionally implement time-based preload after idle
setTimeout(() => observer.next(), 5000);
});
}
}
// Route configuration
const routes: Routes = [
{
path: 'products',
loadChildren: () => import('./products/products.module').then(m => m.ProductsModule),
data: { preload: true } // critical path
},
{
path: 'account',
loadChildren: () => import('./account/account.module').then(m => m.AccountModule)
// lazy load without preload
}
];And in the AppModule:
@NgModule({
imports: [
RouterModule.forRoot(routes, {
preloadingStrategy: CustomPreloadingStrategy,
scrollPositionRestoration: 'enabled',
anchorScrolling: 'enabled'
})
],
bootstrap: [AppComponent]
})
export class AppModule {}Impact: Initial bundle reduced from ~800KB to ~250KB gzipped, directly improving LCP by ~3 seconds.
2. Change Detection Optimization
Angular's default change detection strategy checks the entire component tree on every async event. For a complex e-commerce site, this was costly.
OnPush Strategy
We migrated all presentational components to ChangeDetectionStrategy.OnPush:
@Component({
selector: 'app-product-card',
templateUrl: './product-card.component.html',
styleUrls: ['./product-card.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductCardComponent {
@Input() product: Product;
@Input() discount: number;
// Immutable updates ensure change detection works efficiently
get discountedPrice(): number {
return this.product.price * (1 - this.discount);
}
}This alone reduced change detection cycles by ~70% for interactive pages.
3. CLS (Cumulative Layout Shift) Mitigation
CLS was the hardest metric to tame due to third-party content and dynamic data. We implemented several strategies:
Aspect Ratio Boxes for Images & Videos
Instead of letting media elements size themselves after loading, we reserve space upfront:
<!-- Before -->
<img src="product.jpg" alt="Product" />
<!-- After -->
<div class="aspect-ratio-box">
<img
src="product.jpg"
alt="Product"
loading="lazy"
width="800"
height="600"
style="aspect-ratio: 4/3;"
/>
</div>With CSS:
.aspect-ratio-box {
position: relative;
width: 100%;
}
.aspect-ratio-box > img {
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
}CSS Containment
We added contain: layout style; to frequently updated sections to isolate repaints:
.product-grid {
contain: layout style;
will-change: transform;
}
.cart-drawer {
contain: layout style paint;
}Skeleton Loading States
Instead of showing blank areas while data loads, we render skeleton placeholders with the same dimensions as the final content.
<!-- Skeleton -->
<div *ngIf="loading" class="skeleton-product-card">
<div class="skeleton-image"></div>
<div class="skeleton-title"></div>
<div class="skeleton-price"></div>
</div>
<!-- Real content -->
<div *ngIf="!loading" class="product-card">
<img [src]="product.image" />
<h3>{{ product.name }}</h3>
<span>{{ product.price | currency }}</span>
</div>4. Additional Performance Wins
Beyond the main areas, we also implemented:
- TrackBy functions for all
*ngForloops to avoid unnecessary DOM re-renders - Virtual scrolling for long lists (using Angular CDK Scrolling)
- WebP images with fallbacks, served via Cloudinary
- Font display: swap to avoid blocking render
- Server-side rendering (SSR) with Angular Universal for perceived performance
- HTTP/2 push for critical assets
- Aggressive caching headers via Cloudflare
5. Performance Monitoring with Lighthouse CI
To ensure performance stayed within acceptable ranges and to catch regressions early, we integrated Lighthouse CI into our CI/CD pipeline.
Installation
npm install -D @lhci/cliConfiguration
We created an lhci.config.js (or .lighthouserc.js) at the root:
module.exports = {
lighthousAllUrls: [
'https://myshop.com',
'https://myshop.com/products',
'https://myshop.com/category/shoes',
'https://myshop.com/cart'
],
// Performance assertions to enforce budgets
assert: {
assertions: {
'categories:performance': ['warn', { minScore: 0.9 }],
'categories:accessibility': ['warn', { minScore: 0.9 }],
'categories:best-practices': ['warn', { minScore: 0.9 }],
'categories:seo': ['warn', { minScore: 0.9 }],
// Core Web Vitals thresholds
'lcp': ['error', { maxNumericValue: 2.5 }],
'cls': ['error', { maxNumericValue: 0.1 }],
'fid': ['error', { maxNumericValue: 100 }]
}
},
ci: {
gather: {
// Number of runs to get a reliable median (default: 3)
numberOfRuns: 3,
// Static site build command
builds: [
'npm run build',
{
// Serve the built app
start: 'npm run serve',
// Serve on this port
url: 'http://localhost:4200'
}
]
},
upload: {
// Use Lighthouse CI server for historical tracking
target: 'temporary-public-storage'
}
},
// Optional: generate HTML reports
htmlDir: '.lighthouseci'
};GitHub Actions Integration
We added .github/workflows/lighthouse-ci.yml:
name: Lighthouse CI
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Build Angular app
run: npm run build -- --configuration=production
- name: Run Lighthouse CI
uses: treosh/lighthouse-ci-action@v10
with:
uploadArtifacts: true
temporaryPublicStorage: true
configPath: './lhci.config.js'The workflow:
- Builds the Angular app in production mode
- Starts a local server to serve the built app
- Runs Lighthouse against all configured URLs (3 times each for reliability)
- Uploads results to temporary storage and generates shareable URLs
- Posts a comment on the PR with a summary table of scores
- Creates a commit status that fails if any assertion (performance budget) is violated
- Stores HTML reports as artifacts for detailed review
Example PR Comment
When a PR opens, the workflow posts:
## 🚦 Lighthouse Results
| Route | Performance | Accessibility | Best Practices | SEO |
|-------|-------------|---------------|----------------|-----|
| / | 🟢 98 | 🟢 100 | 🟢 100 | 🟢 100 |
| /products | 🟡 85 | 🟢 98 | 🟢 100 | 🟢 100 |
| /category/shoes | 🟠 78 | 🟢 99 | 🟢 100 | 🟢 100 |
[📊 Full Reports](link-to-lighthouse-server)
⚠️ **Warnings:**
- `/category/shoes` Performance score (78) is below the target of 90This immediate feedback allows reviewers to catch performance regressions before merging.
Enforcing Performance Budgets
By setting assertions in lhci.config.js, the CI job fails if scores drop below thresholds:
assert: {
assertions: {
'categories:performance': ['error', { minScore: 0.9 }],
'lcp': ['error', { maxNumericValue: 2.5 }]
}
}When the CI job fails, the PR shows a red ❌ status and displays which metrics failed:
FAIL lighthouse/performance
Expected performance category to be at least 0.9, got 0.78
FAIL lighthouse/lcp
Expected LCP to be at most 2.5, got 4.2
This blocks merging until issues are addressed.
Manual Performance Testing
We also use GitHub Actions workflow_dispatch to run Lighthouse on demand:
on:
workflow_dispatch:
inputs:
urls:
description: 'URLs to test (comma-separated)'
required: false
default: 'https://myshop.com,https://myshop.com/products'
runs:
description: 'Number of runs per URL'
required: false
default: '5'This allows product managers or QA to request performance checks on staging environments without pushing code.
Local Development
For local testing before pushing:
# Install globally for convenience
npm install -g @lhci/cli
# Run against locally served app (must be running on port defined in config)
npx lhci autorun
# Or with a custom config
npx lhci autorun --config=./lhci.config.jsYou can also run npx lhci collect to gather results and npx lhci server to start a local server to view historical results.
Adopting Performance Best Practices at Scale
Implementing these optimizations across a large team presented unique challenges. Here's how we navigated them:
Developer Training and Enablement
We didn't expect overnight mastery of Angular performance patterns. Instead, we built a learning ecosystem:
- Monthly "Performance Guild" meetings: Developers shared wins, discussed new patterns, and reviewed performance dashboards together
- Code review checklists: Every PR reviewers checked for:
ChangeDetectionStrategy.OnPushon components- Lazy loading for new feature modules
- No synchronous
ngOnInitdata fetching - Image optimization (width/height attributes, responsive sizes)
- Onboarding documentation: Created a "Performance Playbook" with before/after code examples for each pattern
- Lunch & learns: 30-minute sessions diving deep into topics like Signals,
@defer, and Lighthouse CI results interpretation
Overcoming Resistance to Change
Some developers initially pushed back: "This adds complexity," "We don't have time," "Our users don't care about milliseconds."
We addressed this by demonstrating concrete ROI with data:
-
Conversion rate correlation: We correlated page speed with conversion using Google Analytics and found:
- Pages loading under 2s had 15% higher add-to-cart rates
- Each 1-second delay reduced conversions by ~7%
- Mobile users abandoned 32% more often on pages with LCP > 3s
-
Real user monitoring (RUM): We implemented speed tracking via Google Analytics' Core Web Vitals report. During the optimization period, we showed:
- Mobile "good" LCP increased from 12% to 78%
- CLS-related bounce rates dropped by 41%
- Session duration increased by 24% on optimized pages
-
Competitive analysis: We ran Lighthouse on competitor sites and demonstrated where we lagged-and how our optimizations closed the gap.
Once developers saw the direct connection between performance work and business outcomes, adoption became organic.
Performance Budgets in CI
To prevent regressions, we enforced performance budgets that fail CI builds:
// lhci.config.js (repeated for emphasis)
assert: {
assertions: {
'categories:performance': ['error', { minScore: 0.9 }],
'lcp': ['error', { maxNumericValue: 2.5 }],
'cls': ['error', { maxNumericValue: 0.1 }],
'fid': ['error', { maxNumericValue: 100 }],
// Bundle size budget (KB)
'js:total': ['warn', { maxNumericValue: 500 }],
'css:total': ['warn', { maxNumericValue: 100 }]
}
}In practice:
- PRs that drop below thresholds show red ❌ checks in GitHub
- Developers must fix performance issues before merging
- Exception process exists for legitimate reasons (e.g., adding a large feature requires temporary size increase, but roadmap includes follow-up optimization)
Monitoring Regressions
We layered visibility so performance issues surfaced quickly:
-
PR comment bots: Lighthouse CI comments on every PR (as described above)
-
Performance dashboards: We built a Grafana dashboard pulling from:
- Lighthouse CI historical data (via SQLite export)
- Google Search Console Core Web Vitals
- Real User Monitoring (RUM) from Google Analytics
- Alerting when any metric crosses threshold for 24+ hours
-
Weekly performance standup: 15-minute sync with engineering leads to review:
- New regressions (alerts)
- Trend analysis (improvements or degradations over last week)
- Upcoming releases that might impact performance
-
Squad ownership: Each feature squad owns performance for their routes. They receive monthly scorecards showing their area's metrics vs. targets.
This multi-layered approach made performance a shared responsibility rather than an afterthought.
Results
After applying these optimizations and maintaining vigilance through CI, we achieved:
| Metric | Before | After | Improvement | Status |
|---|---|---|---|---|
| Largest Contentful Paint (LCP) | 8.2s | 2.1s | ↓74% | ✅ Good (<2.5s) |
| Cumulative Layout Shift (CLS) | 0.45 | 0.05 | ↓89% | ✅ Good (<0.1) |
| First Input Delay (FID) | 320ms | 65ms | ↓80% | ✅ Good (<100ms) |
| First Contentful Paint (FCP) | 3.4s | 1.2s | ↓65% | ✅ Good (<1.8s) |
| Time to Interactive (TTI) | 6.8s | 2.9s | ↓57% | ✅ Good (<3.8s) |
| Bundle size (main) | 800KB | 210KB | ↓74% | - |
| Total JavaScript | 1.2MB | 450KB | ↓62% | - |
All metrics now fall within Google's "Good" thresholds for Core Web Vitals. Notably, we eliminated all "poor" scores across the three Core Web Vitals.
Additional benefits:
- Mobile Core Web Vitals "good" rating increased from 11% to 94%
- Bounce rate decreased by 28% on product pages
- Conversion rate increased by 18% overall (attributable to performance improvements)
- Build time reduced from ~12 minutes to ~5 minutes with Angular CLI 17+ esbuild
More importantly, we established a performance budget and automated guardrails to prevent regressions in future development cycles.
Conclusion
Performance improvements in Angular applications require a multi-faceted approach: smarter loading, efficient change detection, layout stability, and continuous monitoring. The combination of code-level optimizations and Lighthouse CI automation created a sustainable performance culture within the team.
If you're working on an Angular application and struggling with Core Web Vitals, start with the low-hanging fruit (lazy loading, OnPush) and then gradually introduce more advanced patterns. And don't forget to measure everything-what gets measured gets managed.