Core Web Vitals in Angular: A Production Optimisation Playbook
LCP above 4 seconds on a flagship Angular app. Heres the systematic approach I used to bring it to 1.2s — and the Angular-specific patterns that made the biggest difference.
The Performance Audit That Changed My Approach
Eighteen months ago I inherited an Angular 17 application with an LCP of 4.3 seconds on a mid-range mobile device. The product team was losing conversions. The engineering team had tried the obvious things — lazy loading, OnPush everywhere, tree-shaking — and still couldn't break the 2.5s threshold that Google uses for "Good" LCP.
The problem wasn't the obvious things. It was a series of compounding issues that only became visible when I measured the right things in the right order.
This is the playbook I developed. LCP is now 1.2s. Here's exactly how we got there.
Measurement First — Always
Before touching a line of code, instrument your app properly. You need three measurement layers:
1. Lab data — Lighthouse in Chrome DevTools (controlled, reproducible) 2. Field data — Chrome User Experience Report or your own RUM setup 3. Angular-specific profiling — Angular DevTools flame graphs
// Add this to your app.config.ts for development profiling
import { provideExperimentalZonelessChangeDetection } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [
// Measure CD cycles without Zone noise in dev
...(isDevMode() ? [provideExperimentalZonelessChangeDetection()] : []),
]
};
Rule: Don't optimise what you haven't measured. The thing you think is slow is rarely the bottleneck.
LCP Optimisation: The Angular-Specific Issues
Issue 1: The Hero Image Is Always Late
In Angular apps with SSR or even basic lazy loading, the hero image is frequently loaded after JavaScript executes. This delays LCP by 1–2 seconds on slow connections.
The fix: preload the hero image in index.html and mark it with fetchpriority="high".
<!-- src/index.html -->
<head>
<link rel="preload" as="image" href="/assets/hero.webp" fetchpriority="high">
</head>
And in the Angular component template:
<img
src="/assets/hero.webp"
alt="Hero image"
width="1200"
height="600"
fetchpriority="high"
loading="eager"
/>
Do not use loading="lazy" on above-the-fold images. Angular's image directive (NgOptimizedImage) handles this correctly by default — if you're not using it, start.
Issue 2: Transfer State Not Configured
Without HttpTransferCache, your Angular SSR app makes every HTTP request twice: once on the server during render, and once on the client during hydration. This means your users wait for data that was already fetched.
// app.config.ts — browser config
import { provideClientHydration, withHttpTransferCache } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(withHttpTransferCache()),
// ...
]
};
With this in place, data fetched during SSR is serialised into the HTML, and the client reads it from the DOM rather than making a new network request. For data-heavy landing pages, this can shave 300–800ms off Time to Interactive.
Issue 3: Third-Party Scripts Blocking Render
Analytics, chat widgets, and A/B testing scripts loaded in <head> block HTML parsing. Angular apps are particularly vulnerable because the framework itself needs to parse before any content renders.
<!-- WRONG — blocks rendering -->
<script src="https://analytics.example.com/tracker.js"></script>
<!-- RIGHT — defer non-critical scripts -->
<script defer src="https://analytics.example.com/tracker.js"></script>
<!-- BEST — lazy load after interaction -->
// Lazy load analytics only after first user interaction
@Injectable({ providedIn: 'root' })
export class AnalyticsService {
private loaded = false;
init() {
if (this.loaded || !isPlatformBrowser(inject(PLATFORM_ID))) return;
fromEvent(document, 'click', { once: true }).subscribe(() => {
const script = document.createElement('script');
script.src = 'https://analytics.example.com/tracker.js';
script.defer = true;
document.head.appendChild(script);
this.loaded = true;
});
}
}
CLS Optimisation: Layout Stability in Angular
Cumulative Layout Shift is Angular's most underappreciated performance problem. The most common sources:
Dynamic Content Without Reserved Space
/* WRONG — no dimensions, causes layout shift when image loads */
.hero-image img { width: 100%; }
/* RIGHT — reserve space with aspect-ratio */
.hero-image {
aspect-ratio: 16 / 9;
width: 100%;
overflow: hidden;
}
.hero-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
Skeleton Loaders That Don't Match Final Content
Skeleton loaders that are the wrong size cause layout shift when they're replaced. Match dimensions exactly:
@Component({
template: `
@if (loading()) {
<!-- Skeleton must match final content dimensions exactly -->
<div class="card-skeleton" aria-label="Loading...">
<div class="sk-title" style="height: 28px; width: 70%;"></div>
<div class="sk-body" style="height: 80px; margin-top: 12px;"></div>
</div>
} @else {
<app-post-card [post]="post()" />
}
`
})
@defer Blocks and CLS
Angular's @defer is powerful but can introduce CLS if the deferred content has different dimensions than the placeholder. Always measure:
@defer (on viewport) {
<app-heavy-component />
} @placeholder (minimum 200ms) {
<!-- Placeholder must match <app-heavy-component>'s dimensions -->
<div style="height: 340px; width: 100%;"></div>
}
INP Optimisation: Interaction Responsiveness
Interaction to Next Paint replaced FID in 2024 and is significantly harder to optimise. In Angular, poor INP almost always comes from one of three sources:
Long-Running Change Detection
Zone.js fires change detection on every browser event. If your component tree is large and uses Default change detection, every click triggers a full tree check.
// Migrate to OnPush everywhere
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
// ...
})
// And eliminate unnecessary Zone triggers
export class HeavyComponent {
private ngZone = inject(NgZone);
attachScrollListener() {
// Run outside Zone — scroll events don't need CD
this.ngZone.runOutsideAngular(() => {
fromEvent(window, 'scroll')
.pipe(debounceTime(16))
.subscribe(this.handleScroll.bind(this));
});
}
}
Synchronous Work on Click Handlers
Click handlers that do synchronous work > 50ms directly cause poor INP. Break long tasks:
// WRONG — synchronous, blocks main thread
handleFilter(term: string) {
this.filteredItems.set(
this.items().filter(item => heavyFilter(item, term))
);
}
// RIGHT — yield to the browser between chunks
async handleFilter(term: string) {
const items = this.items();
const results: Item[] = [];
for (let i = 0; i < items.length; i += 50) {
const chunk = items.slice(i, i + 50);
results.push(...chunk.filter(item => heavyFilter(item, term)));
await new Promise(resolve => setTimeout(resolve, 0)); // yield
}
this.filteredItems.set(results);
}
Unvirtualised Long Lists
Rendering 500+ items in the DOM destroys INP. Use CDK virtual scrolling:
import { ScrollingModule } from '@angular/cdk/scrolling';
@Component({
imports: [ScrollingModule],
template: `
<cdk-virtual-scroll-viewport itemSize="72" style="height: 500px;">
<div *cdkVirtualFor="let item of items" class="list-item">
{{ item.title }}
</div>
</cdk-virtual-scroll-viewport>
`
})
The Angular Build Configuration Checklist
Beyond runtime optimisation, ensure your build is configured for maximum performance:
// angular.json — production configuration
{
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kb"
}
]
}
Set aggressive budgets. If the build starts to warn, investigate immediately rather than raising the limit.
Results After Systematic Optimisation
After applying this playbook to the 4.3s LCP app:
| Metric | Before | After | Change |
|---|---|---|---|
| LCP | 4.3s | 1.2s | -72% |
| CLS | 0.18 | 0.02 | -89% |
| INP | 380ms | 95ms | -75% |
| Bundle (initial) | 890kb | 310kb | -65% |
| Lighthouse Score | 41 | 94 | +53pts |
The order mattered. We fixed LCP first (biggest user impact), then CLS (biggest ranking impact), then INP. Attacking all three simultaneously creates measurement noise and makes it impossible to attribute improvements.
The Non-Negotiable Baseline
If you take nothing else from this article, apply these three things to every Angular app before launch:
NgOptimizedImagefor every<img>— handles preload, sizing, lazy/eager automaticallyprovideClientHydration(withHttpTransferCache())— eliminates duplicate HTTP requests in SSR appsChangeDetectionStrategy.OnPushby default — opt into Default only when you need it
These three changes alone typically move Lighthouse scores 15–25 points without touching your application logic.
Performance is not a feature you add at the end. It's a property of the decisions you make from the first component.
Comments