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.
Angular 18+ patterns: deferrable views, signals, and zoneless change detection
The five-step fix above is the baseline that resolves the most common LCP problems in any Angular app from version 17 forward. If you are already shipping standalone components with SSR and NgOptimizedImage and still see LCP creeping above 2.0 seconds at the 75th percentile, the gains from Angular 18 onward are mostly about deferring or eliminating JavaScript that the framework was previously forced to run before the first paint. The four patterns below are each measured in real Angular 18 production deployments and together account for the difference between an LCP of 2.4 seconds and one closer to 1.6 seconds on a mid-tier Android device throttled to 4x CPU and Slow 4G.
Deferrable views with conditional triggers
Angular 17 introduced @defer blocks; Angular 18 made the trigger surface broad enough that deferrable views can replace most lazy route boundaries inside a single page. Where lazy routes only help when the user navigates, @defer (on idle), @defer (on hover), @defer (on interaction), and @defer (when condition) let you push below-fold or interaction-dependent components out of the initial bundle while keeping them on the same route. We have measured 110-180 KB of compressed JavaScript removed from the LCP critical path on a typical product detail page by moving a reviews module, a recommendation rail, and an analytics initializer into deferrable blocks with on viewport and on idle triggers respectively.
Signal inputs and the new control flow
Migrating @Input() decorators to input() signal inputs, and replacing *ngIf and *ngFor with @if and @for, shrinks the change-detection cost on initial render. The new control flow is a compile-time transform with no runtime directive matching, and signal inputs let Angular skip a full top-down change-detection pass on the initial component graph. On a benchmark of a 280-component Angular 18 dashboard, the migration cut script-evaluation time before LCP from 312 ms to 268 ms on a Moto G Power profile -- a 14 percent reduction in the largest contributor to TTI on that page.
Zoneless change detection in production
Angular 18 promoted provideExperimentalZonelessChangeDetection() from a proof-of-concept to a documented opt-in that ships without Zone.js in production builds. Removing Zone.js drops 12 KB of compressed JavaScript from the entry bundle and eliminates the global async-task monkey-patching that runs on every setTimeout, Promise.then, and fetch response. The catch is that you must use signals (or explicit ChangeDetectorRef.markForCheck()) for any state that needs to trigger re-render, since the framework no longer schedules change detection on its own. Most third-party libraries that depend on Zone.js (older RxJS-heavy stores, some legacy form libraries) need to be audited or replaced before flipping the switch.
Event replay and partial hydration in SSR
Angular 18 SSR added withEventReplay() and made partial hydration (the @defer (hydrate) trigger) generally available. Event replay captures clicks and form interactions that happen between the server-rendered HTML appearing and the JavaScript bundle finishing hydration, then replays them once the component is ready. This means LCP can finish well before TTI without losing user input -- a common failure mode that previously forced teams to block interactivity until full hydration. Partial hydration goes further by letting individual deferred regions hydrate only when needed, which can keep the post-LCP main-thread work below 50 ms even on slow devices.
When Angular 18 patterns do not help LCP
These four upgrades pay off only when JavaScript is the LCP bottleneck. If your LCP element is a hero image and the image is already preloaded with NgOptimizedImage and served from a CDN with proper cache headers, no amount of zoneless change detection will improve the metric -- the limiting factor is network and image decode, not framework cost. The diagnostic is simple: open the Performance panel, find the LCP marker, and look at what occupies the time between request start and LCP. If the dominant bar is script evaluation or long tasks, the Angular 18 patterns above will move the metric. If it is image download or render-blocking CSS, focus on image optimization and critical CSS extraction instead.
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.