LCP Angular 17+

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.

Angular -- NgOptimizedImage with priority
// 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.

Angular 17 -- standalone lazy routes
// 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.

Angular 17 -- @defer with on viewport trigger
<!-- 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.

Angular -- @angular/ssr setup with TransferState
// 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.

Angular -- standalone component migration
// 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 NgOptimizedImage with ngSrc, width, height, and priority
  • All routes use loadComponent or loadChildren for 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
  • TransferState is 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.

Continue learning