How I cut LCP from 4s to 1.8s on a high-traffic Angular app
A practical, step-by-step breakdown of the techniques that halved Largest Contentful Paint on a production Angular application serving millions of users — no magic, just methodical work.
Largest Contentful Paint at 4.1 seconds. That was the number staring back at me in PageSpeed Insights on a Monday morning in early 2025. The page in question: the product listing page of a high-traffic Angular application, serving tens of millions of users across Europe.
By the following sprint, LCP was sitting at 1.8s. Here is exactly how we got there.
Why LCP matters more than you think
LCP is the moment a user perceives that the page has loaded. Not technically finished loading — perceived as loaded. That distinction is everything. Google's Core Web Vitals threshold puts "good" LCP at under 2.5s. We were at 4.1s. That meant real users experiencing real frustration, and real organic ranking penalties.
The fix wasn't clever. It was methodical. We audited, prioritised by impact, and shipped incrementally.
Step 1: Identify what the LCP element actually is
Before touching a line of code, I ran a proper Lighthouse audit in a throttled mobile Chrome profile — not a desktop run, not a fast Wi-Fi run. The LCP element turned out to be a hero image rendered by an Angular *ngIf block that was conditionally showing a CMS-driven banner.
This is an extremely common Angular antipattern: wrapping above-the-fold images in structural directives that delay their rendering.
<!-- Before: LCP image hidden behind a conditional -->
<ng-container *ngIf="bannerData$ | async as banner">
<img [src]="banner.imageUrl" [alt]="banner.altText" />
</ng-container>
The async pipe meant the image wasn't requested until the observable resolved — a delay of 300–600ms on slower connections, before the network request for the image even began.
The fix: eager skeleton + preload
We restructured the component to always render a placeholder, and added a <link rel="preload"> hint for the LCP image in the server-rendered <head>:
// Angular Universal: inject preload link in document head
@Injectable()
export class PreloadService {
constructor(@Inject(DOCUMENT) private document: Document) {}
preloadImage(src: string, fetchPriority: 'high' | 'low' = 'high') {
const link = this.document.createElement('link');
link.rel = 'preload';
link.as = 'image';
link.href = src;
link.setAttribute('fetchpriority', fetchPriority);
this.document.head.appendChild(link);
}
}
This alone shaved ~400ms off LCP on 4G connections.
Step 2: Eliminate render-blocking resources
npm run build -- --stats-json followed by webpack-bundle-analyzer revealed the culprit: a 380KB synchronous CSS import from our legacy design system, loaded in the document <head>. Every byte of that CSS was blocking the browser from rendering anything.
The fix was two-pronged:
- Critical CSS extraction — we inlined the above-the-fold CSS (roughly 12KB) directly in the
<head>using a build-time script, and deferred the rest:
<link rel="preload" href="/styles.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/styles.css"></noscript>
- Font loading optimisation — we switched from
@font-facein CSS to a<link rel="preconnect">+font-display: optionalstrategy, eliminating layout shifts caused by font swaps.
Step 3: Image format and sizing
The hero image was being served as a 1.4MB PNG at a fixed URL, regardless of viewport. Mobile users on a 375px screen were downloading a 1400px-wide image. This is genuinely embarrassing when you see it in cold numbers.
We moved image delivery to a CDN with automatic format negotiation and implemented proper srcset:
<img
[src]="banner.src"
[srcset]="banner.srcset"
sizes="(max-width: 768px) 100vw, 1200px"
loading="eager"
fetchpriority="high"
[alt]="banner.altText"
width="1200"
height="600"
/>
The srcset delivered AVIF to Chrome, WebP to Safari, and JPEG as fallback. Average image payload dropped from 1.4MB to 180KB on mobile. LCP element load time: down from 2.8s to 0.6s.
Step 4: Server-side rendering with Angular Universal
The single biggest structural change: we moved the product listing page from client-side rendering to Angular Universal SSR.
Without SSR, here's what a browser had to do before it could paint anything:
- Download HTML (minimal shell)
- Download, parse, and execute the Angular bundle (~420KB gzipped)
- Bootstrap the Angular application
- Make API calls for page data
- Render the actual content
With SSR, step 5 happens on the server. The browser receives a fully-rendered HTML payload and can paint immediately, while Angular hydrates in the background.
// app.config.server.ts
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { appConfig } from './app.config';
const serverConfig: ApplicationConfig = {
providers: [provideServerRendering()]
};
export const config = mergeApplicationConfig(appConfig, serverConfig);
Combined with HTTP transfer state (to avoid double-fetching data on hydration), this removed the full round-trip cost from LCP.
Step 5: Third-party script deferral
A tag manager was loading four third-party analytics scripts synchronously. Each one was blocking the main thread during the critical rendering window.
We moved every non-essential third-party script behind a user interaction trigger:
// Load analytics only after first user interaction
function deferThirdParties() {
const events = ['click', 'scroll', 'keydown', 'touchstart'];
const load = () => {
events.forEach(e => window.removeEventListener(e, load));
// Load tag manager
const script = document.createElement('script');
script.src = '...';
document.head.appendChild(script);
};
events.forEach(e => window.addEventListener(e, load, { passive: true }));
}
This had zero measurable impact on conversion — analytics still fired on all meaningful interactions — but it removed ~800ms of main thread blocking during the LCP window.
The final numbers
| Metric | Before | After | |--------|--------|-------| | LCP (mobile, 4G) | 4.1s | 1.8s | | LCP (desktop) | 1.9s | 0.9s | | CLS | 0.18 | 0.04 | | Total Blocking Time | 680ms | 140ms | | JS bundle (gzip) | 420KB | 248KB | | Lighthouse Performance | 52 | 91 |
What I learned
The performance gains came from removing things, not adding them. Every *ngIf around an above-the-fold image, every synchronous third-party script, every unoptimised image — each one is a tax on your users.
The 4s → 1.8s improvement came from six discrete changes across four weeks of work. None of them were particularly clever. All of them required understanding why the browser works the way it does.
That's the real skill in web performance: not knowing the tricks, but understanding the model deeply enough to see where the waste is.
If you're working on Angular performance and want to talk through your specific setup, book 15 minutes — I'm always happy to look at real PageSpeed reports.
Deepak
Full-Stack Engineer based in Germany. I write about performance, Angular, Node.js, design systems, and the craft of building exceptional web experiences.
Book a 15-min call →