JOURNAL

Angular Ecommerce Performance Playbook — Virtual Scroll, Image Optimization, and Bundle Analysis

Post I — Complete Angular 21 performance guide for ecommerce: CDK virtual scrolling for large product grids, NgOptimizedImage LQIP and fill mode, preconnect hints from SSR, and bundle analysis with nx build.

angular techlead performance

Read with AI

Choose content to copy and paste into your AI assistant. Nothing is sent automatically. CMS content is converted to Markdown; original Markdown is used when available.

Performance in ecommerce is direct revenue. A 100ms improvement in page load time can increase conversion by 1%. At scale, that’s significant. Angular gives you all the tools — the challenge is knowing which one to use and when.

Virtual Scrolling for Large Product Grids

A product catalog with 500+ items rendered all at once creates thousands of DOM nodes. The browser must lay out, paint, and maintain every single one — even those off-screen.

CDK Virtual Scrolling renders only the items visible in the viewport, destroying and creating DOM nodes as the user scrolls.

Installation

pnpm add @angular/cdk

Fixed-height virtual scroll (uniform items)

import { CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll, CdkVirtualForOf } from '@angular/cdk/scrolling';

@Component({
  selector: 'app-product-list',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll, CdkVirtualForOf, ProductCardComponent],
  template: `
    <cdk-virtual-scroll-viewport
      [itemSize]="320"
      class="product-viewport"
      [minBufferPx]="640"
      [maxBufferPx]="960">
      
      <div class="product-grid">
        <app-product-card
          *cdkVirtualFor="let product of products(); trackBy: trackById"
          [product]="product" />
      </div>
    </cdk-virtual-scroll-viewport>
  `,
  styles: [`
    .product-viewport {
      height: calc(100vh - 120px); /* Full viewport minus header */
      width: 100%;
    }
  `]
})
export class ProductListComponent {
  products = input.required<Product[]>();
  trackById = (_: number, p: Product) => p.id;
}

Performance comparison:

Approach DOM nodes (500 products) Memory Scroll FPS
*ngFor / @for 500 × ~15 elements = 7,500 ~120MB 20–40fps
CDK Virtual Scroll ~10 × ~15 elements = 150 ~8MB 60fps

Variable-height virtual scroll (product cards with different heights)

import { CdkAutoSizeVirtualScroll } from '@angular/cdk-experimental/scrolling';
// Note: experimental — test thoroughly

<cdk-virtual-scroll-viewport autosize class="product-viewport">
  <app-product-card *cdkVirtualFor="let p of products(); trackBy: p.id" [product]="p" />
</cdk-virtual-scroll-viewport>

NgOptimizedImage — Every Product Image

NgOptimizedImage is Angular’s built-in directive for image optimization. It enforces best practices automatically:

  • Generates srcset for responsive images
  • Adds loading="lazy" by default (or eager + fetchpriority="high" for LCP images)
  • Warns if the intrinsic dimensions are wrong
  • Supports LQIP (Low Quality Image Placeholder) blur-up
import { NgOptimizedImage } from '@angular/common';

@Component({
  imports: [NgOptimizedImage],
  template: `
    <!-- Hero/primary image — above fold, load immediately, high priority -->
    <img
      [ngSrc]="product().imageUrl"
      [alt]="product().name"
      width="600"
      height="600"
      priority
      placeholder />

    <!-- Thumbnail in list — below fold, lazy load with blur placeholder -->
    <img
      [ngSrc]="product().thumbnailUrl"
      [alt]="product().name"
      width="300"
      height="300"
      placeholder
      [placeholderConfig]="{ threshold: 40 }" />
  `
})
export class ProductImageComponent {}

Fill mode for banners and hero sections

<!-- Container controls the size, image fills it -->
<div class="hero-banner" style="position: relative; height: 400px;">
  <img
    ngSrc="/images/hero-banner.jpg"
    fill
    alt="Summer Sale"
    priority />
</div>

Image CDN loader (Azure CDN / Cloudflare Images)

// Custom loader for Azure Static Web Apps CDN
const azureLoader: ImageLoader = (config: ImageLoaderConfig) => {
  const { src, width, loaderParams } = config;
  return `https://cdn.techshop.com/${src}?w=${width}&f=webp&q=${loaderParams?.['quality'] ?? 80}`;
};

// app.config.ts
provideImageKitLoader('https://cdn.techshop.com')
// or custom:
{ provide: IMAGE_LOADER, useValue: azureLoader }

Preconnect and Resource Hints from SSR

Angular SSR can inject performance hints into the <head> before the HTML reaches the browser.

// server.ts
app.use((req, res, next) => {
  // Preconnect — establish TCP connection early
  res.setHeader('Link', [
    // Image CDN — most pages will need it
    '<https://cdn.techshop.com>; rel=preconnect',
    // Font provider
    '<https://fonts.gstatic.com>; rel=preconnect; crossorigin',
    // API — preconnect so first API call is fast
    '<https://api.techshop.com>; rel=preconnect',
  ].join(', '));

  next();
});

For product pages, add prefetch for related API data:

// In SSR render context, inject Link headers for data-driven hints
if (req.url.startsWith('/products/')) {
  const slug = req.url.split('/products/')[1];
  res.setHeader('Link', [
    `</api/products/${slug}/recommendations>; rel=prefetch`,
    `</api/products/${slug}/reviews>; rel=prefetch`,
  ].join(', '));
}

Bundle Analysis

# Build with stats JSON
pnpm nx build shell --configuration=production --stats-json

# Analyze the output (opens in browser)
npx webpack-bundle-analyzer dist/apps/shell/browser/stats.json

# Quick CLI summary without browser
pnpm nx build shell --bundle-size

What to look for in the bundle:

Finding Action
moment.js > 200KB Replace with date-fns (tree-shakable)
lodash > 70KB Import individual functions: import debounce from 'lodash-es/debounce'
Large @defer chunk > 50KB Split into smaller components
Duplicate dependencies Check peerDependencies conflicts
rxjs operators not tree-shaken Ensure named imports: import { map } from 'rxjs/operators'

INP Optimization — Interaction to Next Paint

LCP and CLS are usually solved by SSR + NgOptimizedImage. INP (the newest Core Web Vital) requires different treatment — it measures how fast the browser responds to clicks.

// Heavy computation — blocks the main thread
onFilterChange(filter: Filter) {
  // ❌ Synchronously processing 500 products
  this.filteredProducts.set(
    this.allProducts().filter(p => matches(p, filter)) // blocks for 80ms
  );
}

// ✅ Defer heavy computation to next animation frame
onFilterChange(filter: Filter) {
  // Updates signal immediately (non-blocking) for UI feedback
  this.filterSignal.set(filter);

  // Computation scheduled in idle time
  // (or use Web Workers for true parallelism)
}

// ✅ Even better — computed() is lazy and doesn't block the event loop
filteredProducts = computed(() => {
  return this.allProducts().filter(p => matches(p, this.filterSignal()));
});

Performance Targets for TechShop

Metric Target Tool
LCP (product page) < 2.0s Lighthouse, CrUX
INP < 100ms Chrome DevTools
CLS < 0.05 Lighthouse
Home page JS (initial) < 100KB webpack-bundle-analyzer
Product page JS (initial) < 120KB webpack-bundle-analyzer
Time to First Byte (SSR) < 400ms Server logs, Lighthouse

References


Part of the Angular Tech Lead Series — Back to main series overview

Discussion

Comments are reviewed before publication. Your email is kept private.

Add a comment

Name and email are required. Do not include confidential information.

Privacy & data

Loading anti-spam verification…

← Back to allĐọc tiếng Việt