
Improving Angular Performance Scores By Over 150%
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.
1. Lazy Loading Revolution
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 the GitHub Actions workflow.
Why Lighthouse CI?
- Automated testing on every PR and push to main
- Historical tracking via Lighthouse Server (self-hosted)
- PR comments with visual performance trends
- Commit statuses that block merging if scores drop below thresholds
GitHub Action Setup
We added .github/workflows/lighthouse-ci.yml (see file) which:
- Builds the Next.js app (actually Angular build, but conceptually similar)
- Installs and runs
lhci autorun - Collects Lighthouse results for all configured URLs
- Generates a markdown table summarizing performance, accessibility, best practices, and SEO scores with emojis to indicate health
- Posts the results as a comment on PRs
- Creates individual commit statuses for each page/route
The workflow uses the Lighthouse CI server for persistence (configured via LHCI_GITHUB_APP_TOKEN and LHCI_BUILD_CONTEXT). This allows us to track performance over time and compare against previous baselines.
Example PR Comment
When a PR is opened, the workflow automatically generates a table like:
| URL | Performance | Accessibility | Best Practices | SEO | Report |
|---|---|---|---|---|---|
| Home | 🟢 98 (98 | 97 | 99) | 🟢 100 | 🟢 100 |
| Products | 🟡 82 (80 | 81 | 85) | 🟢 98 | 🟡 92 |
This immediate feedback helps reviewers consider performance implications before merging.
Manual Snapshots
We also use the workflow_dispatch trigger to run Lighthouse on demand, configured with a number_of_runs input (default 3) to get a reliable median.
Local Development
For local testing, we run:
npx lhci autorun
With a .lighthouserc.js that defines the URLs to test and the build command.
Results
After applying these optimizations and maintaining vigilance through CI, we achieved:
- LCP: 8.2s → 2.1s (↓74%)
- CLS: 0.45 → 0.12 (↓73%)
- FID: 320ms → 89ms (↓72%)
All metrics now fall within Google's "Good" thresholds for Core Web Vitals. 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.