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:

ApproachDOM nodes (500 products)MemoryScroll FPS
*ngFor / @for500 × ~15 elements = 7,500~120MB20–40fps
CDK Virtual Scroll~10 × ~15 elements = 150~8MB60fps

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:

FindingAction
moment.js > 200KBReplace with date-fns (tree-shakable)
lodash > 70KBImport individual functions: import debounce from 'lodash-es/debounce'
Large @defer chunk > 50KBSplit into smaller components
Duplicate dependenciesCheck peerDependencies conflicts
rxjs operators not tree-shakenEnsure 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

MetricTargetTool
LCP (product page)< 2.0sLighthouse, CrUX
INP< 100msChrome DevTools
CLS< 0.05Lighthouse
Home page JS (initial)< 100KBwebpack-bundle-analyzer
Product page JS (initial)< 120KBwebpack-bundle-analyzer
Time to First Byte (SSR)< 400msServer logs, Lighthouse

References


Part of the Angular Tech Lead SeriesBack to main series overview

Export for reading

Comments