Fix LCP in Angular
Largest Contentful Paint (LCP) in Angular apps is commonly hurt by large main bundles that must execute before Angular bootstraps and renders, hero images without preload or priority hints, and client-side-only rendering where the LCP element is absent until JavaScript runs. Angular 17 introduced first-class tools to address each of these: NgOptimizedImage with priority, @defer blocks, and improved standalone-based tree-shaking. Combined with Angular Universal SSR, these changes can move LCP from the Poor range into Good.
Expected results
Before
4.1s
LCP (Poor) -- no NgOptimizedImage, eager imports, large main bundle, no SSR
After
1.5s
LCP (Good) -- NgOptimizedImage priority, lazy routes, defer block, SSR
Step-by-step fix
Use NgOptimizedImage with the priority attribute
NgOptimizedImage is Angular's built-in image directive. When you add the priority attribute to the LCP image, Angular automatically inserts a <link rel="preload"> tag in the document head so the browser discovers and fetches the image as early as possible -- before the JavaScript bundle finishes executing. It also disables loading="lazy" for priority images and enforces required width and height attributes to prevent CLS. The directive is available in @angular/common -- no additional package is needed.
// hero.component.ts
import { Component } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';
@Component({
selector: 'app-hero',
standalone: true,
imports: [NgOptimizedImage],
template: `
<!-- Bad: plain img with no preload, no dimensions -->
<!-- <img src="/images/hero.jpg" alt="Hero image" /> -->
<!-- Good: NgOptimizedImage with priority generates a preload link -->
<img
ngSrc="/images/hero.jpg"
alt="Product hero image"
width="1200"
height="600"
priority
/>
<!-- For images from a CDN image loader (e.g. Cloudinary, Imgix) -->
<img
ngSrc="hero"
alt="Product hero image"
width="1200"
height="600"
priority
[ngSrcset]="'400w, 800w, 1200w'"
sizes="(max-width: 768px) 100vw, 1200px"
/>
`,
})
export class HeroComponent {}
// app.config.ts -- register an image loader for CDN support
import { provideImgixLoader } from '@angular/common';
export const appConfig = {
providers: [
// Replace with your CDN base URL
provideImgixLoader('https://your-project.imgix.net/'),
],
};
Lazy-load routes with loadComponent
In a module-based Angular app, every eagerly-imported component ends up in the main bundle. Even if a component is used only on one route, it inflates the bundle that must download and parse before Angular can bootstrap and render the initial view. Angular 17's standalone router supports loadComponent which splits each route into a separate chunk. The main bundle shrinks to only what the first-loaded route actually needs, directly reducing the time to LCP.
// app.routes.ts
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
// HomeComponent loads eagerly -- it is the LCP route
loadComponent: () =>
import('./home/home.component').then(m => m.HomeComponent),
},
{
path: 'products',
// Products page loads only when navigated to
loadComponent: () =>
import('./products/products.component').then(m => m.ProductsComponent),
},
{
path: 'products/:id',
loadComponent: () =>
import('./product-detail/product-detail.component').then(
m => m.ProductDetailComponent
),
},
{
path: 'admin',
// Entire admin section in a lazy-loaded child route config
loadChildren: () =>
import('./admin/admin.routes').then(m => m.adminRoutes),
},
];
// main.ts -- bootstrap with standalone providers
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes),
],
});
Use @defer for below-fold content
Angular 17's @defer block is a template syntax for deferring component loading until a trigger condition is met. For below-fold content -- carousels, review sections, recommendation grids -- using @defer (on viewport) means Angular does not download or render those components until the user scrolls them into view. This reduces the initial JavaScript parse cost and the component render work that would otherwise delay LCP by competing for main-thread time during page load.
<!-- product-page.component.html -->
<!-- Above-fold content: renders immediately -->
<app-hero [product]="product" />
<app-product-details [product]="product" />
<!-- Below-fold: defer until the section enters the viewport -->
@defer (on viewport) {
<app-reviews-section [productId]="product.id" />
} @loading {
<!-- Placeholder shown while the chunk downloads -->
<div class="reviews-skeleton" style="height: 400px;"></div>
} @placeholder {
<!-- Shown before the defer trigger fires -->
<div class="reviews-placeholder" style="height: 400px;"></div>
}
@defer (on viewport) {
<app-related-products [categoryId]="product.categoryId" />
} @placeholder {
<div class="related-placeholder" style="height: 320px;"></div>
}
<!-- Defer on interaction: load only when user clicks -->
@defer (on interaction) {
<app-size-guide />
} @placeholder {
<button class="size-guide-trigger">View size guide</button>
}
Enable Angular Universal SSR
Without SSR, Angular sends an empty <app-root></app-root> shell. The browser must download, parse, and execute the full JavaScript bundle before Angular bootstraps and the LCP element appears in the DOM. Angular Universal renders the app on the server and sends complete HTML in the first response, so the LCP element is present immediately. Use TransferState to serialize server-fetched data into the HTML and transfer it to the client, preventing a duplicate API call during hydration.
// Add SSR to an existing Angular project:
// ng add @angular/ssr
// app.config.server.ts -- server-specific providers
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);
// product-detail.component.ts -- using TransferState
import { Component, inject, OnInit } from '@angular/core';
import { TransferState, makeStateKey } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { isPlatformServer } from '@angular/common';
import { PLATFORM_ID } from '@angular/core';
const PRODUCT_KEY = makeStateKey<Product>('product');
@Component({
selector: 'app-product-detail',
standalone: true,
template: `
<app-hero [product]="product" />
`,
})
export class ProductDetailComponent implements OnInit {
private transferState = inject(TransferState);
private http = inject(HttpClient);
private platformId = inject(PLATFORM_ID);
product: Product | null = null;
async ngOnInit() {
// On server: fetch and store in TransferState
if (isPlatformServer(this.platformId)) {
this.product = await this.http
.get<Product>('/api/product/1')
.toPromise() ?? null;
this.transferState.set(PRODUCT_KEY, this.product);
} else {
// On client: rehydrate from TransferState (no extra HTTP request)
this.product = this.transferState.get(PRODUCT_KEY, null);
this.transferState.remove(PRODUCT_KEY);
}
}
}
Optimize the main bundle with standalone components
NgModule-based Angular apps typically have poor tree-shaking because module boundaries force many components and services into the same chunk even when they are not used by the initial view. Standalone components declare their own imports directly, allowing the Angular compiler and bundler to build a precise import graph and eliminate unused code. Migrating the critical-path components to standalone -- even without migrating the entire app -- reduces the main bundle and the parse time that delays LCP.
// Before: NgModule-based (poor tree-shaking)
// app.module.ts imports everything into one module
@NgModule({
declarations: [
AppComponent,
HeroComponent,
ProductListComponent,
AdminDashboardComponent, // pulled into main bundle even on home page
ChartsComponent, // same -- adds ~80 kB to main chunk
],
imports: [BrowserModule, RouterModule, SharedModule],
bootstrap: [AppComponent],
})
export class AppModule {}
// After: standalone component (explicit, tree-shakeable imports)
// hero.component.ts
import { Component } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-hero',
standalone: true,
// Only imports actually used in this component's template
imports: [NgOptimizedImage, RouterLink],
template: `
<section class="hero">
<img ngSrc="/images/hero.jpg" width="1200" height="600" priority alt="Hero" />
<a routerLink="/products">Shop now</a>
</section>
`,
})
export class HeroComponent {}
// Migration command (Angular 15+):
// ng generate @angular/core:standalone
// Automatically converts declarations to standalone and updates imports.
Quick checklist
-
LCP image uses
NgOptimizedImagewithngSrc,width,height, andpriority -
All routes use
loadComponentorloadChildrenfor lazy loading -
Below-fold components are wrapped in
@defer (on viewport)blocks - Angular Universal SSR is enabled and LCP content is present in the initial HTML response
-
TransferStateis used to pass server-fetched data to the client without a duplicate API call - Critical-path components are standalone to enable precise tree-shaking of unused framework code
Frequently asked questions
The most common causes are large main bundles from NgModule-based apps that must fully execute before Angular bootstraps, hero images with no preload or priority hint, client-side-only rendering where the LCP element is absent from the initial HTML, and large component trees that are eagerly imported even when not needed for the initial view. Addressing all four with the steps in this guide is typically enough to move from Poor to Good LCP.
NgOptimizedImage does several things: it generates a <link rel="preload"> tag for images marked with priority, ensuring the browser fetches the LCP image early; it requires width and height attributes to prevent layout shift; it automatically adds loading="lazy" for below-fold images to avoid wasting bandwidth; and it warns during development if a large image is missing dimensions or priority. Together these eliminate the most common Angular-specific LCP and CLS issues with a single directive, available from Angular 15+.
Zone.js patches async operations to schedule change detection and adds a small overhead per async call. In practice this is rarely the primary LCP bottleneck -- the much larger impacts come from bundle size and lack of SSR. If you have already addressed those and want to eliminate Zone.js overhead entirely, Angular 18+ introduced provideExperimentalZonelessChangeDetection() which removes Zone.js and uses signals-based reactivity instead.
Use Angular Universal whenever the LCP element is data-driven and not present in the initial HTML of a client-side Angular app. SSR is most impactful for content sites, e-commerce product pages, and pages where the hero image or headline comes from an API call. For purely interactive tools or dashboards behind a login where SEO and first-paint speed are secondary concerns, SSR adds server complexity without proportional LCP benefit.
In Chrome DevTools, open the Performance panel, record a page load, and look for the LCP marker in the Timings row -- the Elements panel will highlight the LCP element. For field data, add the web-vitals package and call onLCP(console.log) in main.ts. The Angular DevTools extension shows component render times that contribute to LCP. For CI pipelines, run Lighthouse in a Node script with lighthouse(url, { output: 'json' }) and assert on the largest-contentful-paint audit value.
Google rates LCP as 'good' when it is under 2.5 seconds at the 75th percentile. For Angular applications specifically, aim for under 2.0 seconds. Measure with field data from Chrome User Experience Report (CrUX) through PageSpeed Insights, as lab tests may not reflect real-user experience with third-party scripts and varying network conditions.
Related resources
Complete LCP Guide
Deep dive into LCP element candidates, sub-part breakdown (TTFB, resource load delay, render delay), and cross-framework strategies.
FixFix LCP in React
React LCP fixes: image preload, Suspense streaming, React Server Components, and bundle splitting.
FixFix LCP in Vue 3
Vue 3 LCP fixes: preload hero images, font-display swap, Nuxt SSR, and critical CSS extraction.
Continue learning
Complete LCP Guide
Deep dive into LCP -- thresholds, measurement, and optimization strategies.
FixFix CLS in Angular
Related performance optimization for the same framework.
FixFix INP in Angular
Related performance optimization for the same framework.
ToolCWV Score Explainer
Enter your scores for personalized fix recommendations.