Platform Guide22 min read

Angular Image Optimization with NgOptimizedImage

Master Angular's NgOptimizedImage directive for optimal image performance. Learn priority loading, automatic srcset, lazy loading, CDN integration, and performance patterns.

By ImageGuide Team·Published February 15, 2026
angularngoptimizedimageimage optimizationperformancetypescript

Angular’s NgOptimizedImage directive transforms the standard <img> element into a performance-optimized component with automatic srcset generation, lazy loading, and layout shift prevention. This guide covers everything from basic usage to advanced CDN integration patterns.

Why Angular Needs an Image Directive

Images are the leading cause of poor Core Web Vitals scores. Before NgOptimizedImage, Angular developers had to manually handle every optimization concern:

Problem Without Directive With NgOptimizedImage
LCP timing Manual preload/fetchpriority Automatic with priority
Layout shifts Forget dimensions, get CLS Enforces width/height
Lazy loading Manual Intersection Observer Built-in default
Responsive images Hand-coded srcset Auto-generated
CDN integration Custom URL construction Loader architecture
Format negotiation Manual <picture> elements CDN handles via loader

The Real-World Impact

On a typical content-heavy Angular application:

  • LCP improvement: 20-40% faster with priority loading and preconnect hints
  • CLS reduction: Near-zero layout shift when dimensions are enforced
  • Bandwidth savings: 40-60% with automatic responsive srcset
  • Developer experience: Compile-time warnings catch common mistakes

Unlike Next.js which provides a custom component (next/image), Angular enhances the native <img> tag through a directive. This means no wrapper elements in the DOM, standard HTML semantics, and easier migration from plain <img> tags.

Getting Started with NgOptimizedImage

Importing the Directive

NgOptimizedImage lives in @angular/common. Import it depending on your component style:

Standalone Components (recommended, Angular 14+):

import { Component } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';

@Component({
  selector: 'app-hero',
  standalone: true,
  imports: [NgOptimizedImage],
  template: `
    <img
      ngSrc="hero.jpg"
      width="1200"
      height="600"
      alt="Hero banner for Angular application"
    />
  `,
})
export class HeroComponent {}

NgModule-based Components:

import { NgModule } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';

@NgModule({
  imports: [NgOptimizedImage],
})
export class SharedModule {}

Basic Usage

Replace src with ngSrc and always provide width and height:

@Component({
  selector: 'app-product-card',
  standalone: true,
  imports: [NgOptimizedImage],
  template: `
    <div class="product-card">
      <img
        ngSrc="products/running-shoe.jpg"
        width="400"
        height="400"
        alt="Blue running shoe with white sole"
      />
      <h3>Running Shoe</h3>
      <p>{{ price | currency }}</p>
    </div>
  `,
})
export class ProductCardComponent {
  price = 129.99;
}

Key differences from plain <img>:

  • Use ngSrc instead of src
  • width and height are required (not optional)
  • Lazy loading is enabled by default
  • The directive validates your usage at compile time

For dynamic sources, bind ngSrc to a component property:

@Component({
  template: `
    <img
      [ngSrc]="user.avatarUrl"
      [width]="size"
      [height]="size"
      [alt]="user.name + ' profile photo'"
    />
  `,
})
export class UserAvatarComponent {
  @Input() user!: { name: string; avatarUrl: string };
  @Input() size = 48;
}

Priority Loading for LCP

The most impactful optimization is marking the Largest Contentful Paint image with priority.

@Component({
  template: `
    <section class="hero">
      <img
        ngSrc="hero-banner.jpg"
        width="1920"
        height="800"
        priority
        alt="Summer collection banner"
      />
    </section>
  `,
})
export class HeroBannerComponent {}

What Priority Does

When you add priority, Angular automatically:

  1. Sets loading="eager" instead of lazy
  2. Sets fetchpriority="high" to tell the browser this image is critical
  3. Generates a preload <link> in the document <head>
  4. Warns about missing preconnect if using a CDN loader
<!-- Angular injects this automatically -->
<link rel="preload" as="image" href="hero-banner.jpg" fetchpriority="high" />

Priority Guidelines

Image Location Use Priority? Reasoning
Hero banner Yes Almost always the LCP element
Above-fold product Yes Likely LCP on product pages
Carousel first slide Yes First visible image in a slider
Site logo Maybe Only if largest above-fold element
Below-fold content No Would hurt performance
Thumbnails No Too small to be LCP

Angular warns if you mark more than one image with priority in the same view. Limit it to one or two images maximum per route:

// BAD: Multiple priority images fight for bandwidth
template: `
  <img ngSrc="hero.jpg" width="1920" height="800" priority />
  <img ngSrc="promo.jpg" width="600" height="400" priority />
`

// GOOD: Only the LCP image gets priority
template: `
  <img ngSrc="hero.jpg" width="1920" height="800" priority />
  <img ngSrc="promo.jpg" width="600" height="400" />
`

Automatic Layout Shift Prevention

Cumulative Layout Shift (CLS) is often caused by images loading without reserved space. NgOptimizedImage solves this by requiring dimensions.

Required Width and Height

Every ngSrc image must have explicit width and height:

// ERROR: NG02954 - NgOptimizedImage requires width and height
template: `<img ngSrc="photo.jpg" alt="Photo" />`

// Correct
template: `<img ngSrc="photo.jpg" width="800" height="600" alt="Photo" />`

These dimensions serve as the intrinsic aspect ratio, not the rendered size. CSS controls the display size while the ratio prevents layout shift:

img {
  width: 100%;
  height: auto; /* Maintains aspect ratio from width/height attributes */
}

Fill Mode for Unknown Dimensions

When you cannot determine dimensions in advance (user-generated content, CMS images), use fill:

@Component({
  template: `
    <div class="banner-container">
      <img ngSrc="dynamic-banner.jpg" fill alt="Dynamic banner" />
    </div>
  `,
  styles: [`
    .banner-container {
      position: relative;
      width: 100%;
      height: 400px;
    }
    img { object-fit: cover; }
  `],
})
export class BackgroundImageComponent {}

When using fill, do not provide width/height. The parent must have position: relative (or fixed/absolute) and defined dimensions.

Lazy Loading Configuration

Default Lazy Behavior

All ngSrc images are lazy-loaded by default:

<!-- Lazy-loaded automatically, no extra attributes -->
<img ngSrc="photo.jpg" width="800" height="600" alt="Photo" />

When to Override

Scenario Recommendation
Hero image Use priority (includes eager + preload)
Navigation icons loading="eager" if above fold
Image carousel priority on first, lazy on rest
Product grid below fold Keep default lazy
Footer logos Keep default lazy
<!-- Force eager without the full priority treatment -->
<img ngSrc="logo.jpg" width="200" height="50" loading="eager" alt="Logo" />

Lazy Loading with Placeholders

Combine lazy loading with placeholders for a polished experience:

<img
  ngSrc="gallery/photo-42.jpg"
  width="600"
  height="400"
  placeholder
  alt="Gallery photo"
/>

Image Loaders and CDN Integration

Loaders are functions that construct final image URLs, enabling seamless CDN integration with automatic resizing and format conversion.

How Loaders Work

type ImageLoaderConfig = {
  src: string;           // The ngSrc value
  width?: number;        // Requested width (from srcset generation)
  isPlaceholder?: boolean; // True when generating placeholder URL
};

type ImageLoader = (config: ImageLoaderConfig) => string;

Built-in CDN Loaders

Angular provides loaders for popular CDNs:

// Cloudinary
import { provideCloudinaryLoader } from '@angular/common';
providers: [provideCloudinaryLoader('https://res.cloudinary.com/my-account')]

// Imgix
import { provideImgixLoader } from '@angular/common';
providers: [provideImgixLoader('https://my-site.imgix.net')]

// Netlify
import { provideNetlifyLoader } from '@angular/common';
providers: [provideNetlifyLoader('https://my-site.netlify.app')]

Custom Loader for Sirv CDN

Sirv provides real-time image optimization with on-the-fly resizing, format conversion, and global CDN delivery. Create a custom loader:

import { IMAGE_LOADER, ImageLoaderConfig } from '@angular/common';

function sirvLoader(config: ImageLoaderConfig): string {
  const base = 'https://your-account.sirv.com';

  if (config.isPlaceholder) {
    return `${base}/${config.src}?w=30&blur=80&q=10`;
  }

  return config.width
    ? `${base}/${config.src}?w=${config.width}`
    : `${base}/${config.src}`;
}

// Register globally in app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [
    { provide: IMAGE_LOADER, useValue: sirvLoader },
  ],
};

Sirv automatically serves the optimal format (WebP, AVIF) based on browser support without requiring format parameters. Angular’s srcset combined with Sirv’s content negotiation delivers the best format at the right size with zero additional configuration.

Preconnect Hints

Angular warns you to add a preconnect link for your CDN. Add it to index.html:

<head>
  <link rel="preconnect" href="https://your-account.sirv.com" crossorigin />
  <link rel="dns-prefetch" href="https://your-account.sirv.com" />
</head>

This establishes the connection early, saving 100-300ms on the first image request.

Automatic srcset Generation

When you use ngSrc with a loader and provide sizes, Angular generates a responsive srcset:

<!-- What you write -->
<img ngSrc="hero.jpg" width="1200" height="600" sizes="100vw" alt="Hero" />

<!-- What Angular renders -->
<img
  src="https://cdn.example.com/hero.jpg?w=1920"
  srcset="
    https://cdn.example.com/hero.jpg?w=640 640w,
    https://cdn.example.com/hero.jpg?w=750 750w,
    https://cdn.example.com/hero.jpg?w=828 828w,
    https://cdn.example.com/hero.jpg?w=1080 1080w,
    https://cdn.example.com/hero.jpg?w=1200 1200w,
    https://cdn.example.com/hero.jpg?w=1920 1920w,
    https://cdn.example.com/hero.jpg?w=2048 2048w,
    https://cdn.example.com/hero.jpg?w=3840 3840w
  "
  sizes="100vw"
  loading="lazy"
  alt="Hero"
/>

Default Breakpoints

Type Breakpoints
Responsive (sizes provided) 640, 750, 828, 1080, 1200, 1920, 2048, 3840
Fixed (no sizes) 1x, 2x density descriptors based on width

Customizing Breakpoints

import { IMAGE_CONFIG } from '@angular/common';

providers: [
  {
    provide: IMAGE_CONFIG,
    useValue: {
      breakpoints: [320, 480, 640, 800, 960, 1200, 1440, 1920],
    },
  },
]

Common sizes Patterns

Layout sizes Value
Full width 100vw
Two-column grid (max-width: 768px) 100vw, 50vw
Three-column grid (max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw
Fixed sidebar + content (max-width: 768px) 100vw, calc(100vw - 300px)
Card in container (max-width: 640px) 100vw, (max-width: 1024px) 50vw, 400px

Density Descriptors for Fixed Images

Without sizes, Angular generates density-based srcset:

<!-- Input -->
<img ngSrc="avatar.jpg" width="100" height="100" alt="Avatar" />

<!-- Output -->
<img
  src="https://cdn.example.com/avatar.jpg?w=100"
  srcset="
    https://cdn.example.com/avatar.jpg?w=100 1x,
    https://cdn.example.com/avatar.jpg?w=200 2x
  "
  width="100" height="100" alt="Avatar"
/>

Responsive Fill Images

Fill mode is ideal for hero banners, background images, and layouts where dimensions are unknown.

@Component({
  template: `
    <div class="hero-wrapper">
      <img
        ngSrc="hero-landscape.jpg"
        fill
        priority
        sizes="100vw"
        alt="Mountain landscape hero"
      />
      <div class="hero-content">
        <h1>Explore the Mountains</h1>
      </div>
    </div>
  `,
  styles: [`
    .hero-wrapper {
      position: relative;
      width: 100%;
      height: 60vh;
      overflow: hidden;
    }
    img {
      object-fit: cover;
      object-position: center 30%;
    }
    .hero-content {
      position: relative;
      z-index: 1;
      color: white;
      padding: 2rem;
    }
  `],
})
export class HeroComponent {}

Parent Container Options

/* Fixed height */
.container { position: relative; width: 100%; height: 400px; }

/* Aspect ratio */
.container { position: relative; width: 100%; aspect-ratio: 16 / 9; }

/* Absolute fill */
.container { position: absolute; inset: 0; }

Object-Fit Options

Value Behavior
cover Fills container, may crop edges
contain Fits inside, may letterbox
fill Stretches to fill (may distort)
scale-down Like contain, never upscales

Placeholder Strategies

Automatic Blur Placeholders

With a CDN loader, Angular can auto-generate LQIP (low-quality image placeholders):

<img ngSrc="photo.jpg" width="800" height="600" placeholder alt="Photo" />

The loader receives isPlaceholder: true and returns a tiny, blurred version. With Sirv:

function sirvLoader(config: ImageLoaderConfig): string {
  const base = 'https://your-account.sirv.com';
  if (config.isPlaceholder) {
    // ~200 bytes: tiny, blurred, low quality
    return `${base}/${config.src}?w=30&blur=80&q=10`;
  }
  return config.width
    ? `${base}/${config.src}?w=${config.width}`
    : `${base}/${config.src}`;
}

Dominant Color Placeholders

Use a solid color matching the image while it loads:

@Component({
  template: `
    <div class="image-wrapper" [style.background-color]="dominantColor">
      <img
        [ngSrc]="imageUrl"
        [width]="width"
        [height]="height"
        [alt]="alt"
        (load)="isLoaded = true"
        [class.loaded]="isLoaded"
      />
    </div>
  `,
  styles: [`
    img { opacity: 0; transition: opacity 0.3s; }
    img.loaded { opacity: 1; }
  `],
})
export class ImageWithColorComponent {
  @Input() imageUrl = '';
  @Input() width = 0;
  @Input() height = 0;
  @Input() alt = '';
  @Input() dominantColor = '#e0e0e0';
  isLoaded = false;
}

Server-Side Rendering Considerations

Preload Hints in SSR

With Angular Universal, priority images get preload hints injected into server-rendered HTML:

<head>
  <!-- Injected by Angular SSR automatically -->
  <link rel="preload" as="image" href="https://cdn.example.com/hero.jpg?w=1920" fetchpriority="high" />
</head>

The browser starts fetching the LCP image from the very first byte of HTML, before any JavaScript executes.

Hydration Compatibility

NgOptimizedImage is fully compatible with Angular’s hydration. During hydration, the server-rendered <img> is reused without a flash or re-fetch:

// app.config.ts
import { provideClientHydration } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    provideClientHydration(),
    { provide: IMAGE_LOADER, useValue: sirvLoader },
  ],
};

SSR-Specific Patterns

import { isPlatformServer } from '@angular/common';
import { PLATFORM_ID, inject } from '@angular/core';

@Component({
  template: `
    <img ngSrc="hero.jpg" width="1920" height="800" priority sizes="100vw" alt="Hero" />

    @if (!isServer) {
      <img [ngSrc]="userSelectedImage" width="800" height="600" alt="User content" />
    }
  `,
})
export class SmartImageComponent {
  private platformId = inject(PLATFORM_ID);
  isServer = isPlatformServer(this.platformId);
}

Performance Monitoring

Angular DevTools Warnings

NgOptimizedImage provides runtime warnings in development:

Warning Fix
Missing width/height Add width/height or use fill
Oversized image Provide sizes or reduce source dimensions
Missing preconnect Add <link rel="preconnect"> to index.html
Multiple priorities Only mark the LCP image as priority
LCP without priority Add priority to the LCP image

Programmatic LCP Monitoring

Track LCP in production to verify priority images are working:

@Injectable({ providedIn: 'root' })
export class PerformanceMonitorService {
  constructor() {
    if (typeof PerformanceObserver === 'undefined') return;

    new PerformanceObserver((entryList) => {
      const entries = entryList.getEntries();
      const lastEntry = entries[entries.length - 1] as any;

      console.log('LCP:', lastEntry.startTime.toFixed(0), 'ms');
      console.log('LCP element:', lastEntry.element?.tagName);

      if (lastEntry.startTime > 2500) {
        console.warn('LCP exceeds 2.5s threshold');
      }
    }).observe({ type: 'largest-contentful-paint', buffered: true });
  }
}

Migration from Plain img Tags

Step-by-Step Process

Step 1: Import NgOptimizedImage

import { NgOptimizedImage } from '@angular/common';

@Component({
  standalone: true,
  imports: [NgOptimizedImage],
})

Step 2: Replace src with ngSrc and add dimensions

<!-- Before -->
<img src="/assets/hero.jpg" alt="Hero" />

<!-- After -->
<img ngSrc="/assets/hero.jpg" width="1200" height="600" alt="Hero" />

Find dimensions using DevTools:

document.querySelectorAll('img').forEach(img => {
  console.log(img.src, img.naturalWidth, img.naturalHeight);
});

Step 3: Mark the LCP image with priority

<img ngSrc="hero.jpg" width="1920" height="800" priority alt="Hero banner" />

Step 4: Add sizes for responsive images

<img
  ngSrc="content.jpg"
  width="1200"
  height="800"
  sizes="(max-width: 768px) 100vw, 50vw"
  alt="Content image"
/>

Step 5: Configure a CDN loader

export const appConfig: ApplicationConfig = {
  providers: [
    {
      provide: IMAGE_LOADER,
      useValue: (config: ImageLoaderConfig) => {
        const base = 'https://your-account.sirv.com';
        if (config.isPlaceholder) {
          return `${base}/${config.src}?w=30&blur=80&q=10`;
        }
        return config.width
          ? `${base}/${config.src}?w=${config.width}`
          : `${base}/${config.src}`;
      },
    },
  ],
};

Common Migration Gotchas

Images in loops — only the first should get priority:

template: `
  @for (product of products; track product.id; let i = $index) {
    <img
      [ngSrc]="product.imageUrl"
      width="400"
      height="400"
      [priority]="i === 0"
      [alt]="product.name"
    />
  }
`

CSS background images must convert to <img> with fill:

// Before: CSS background
template: `<div [style.background-image]="'url(' + heroUrl + ')'"></div>`

// After: img with fill
template: `
  <div class="hero" style="position: relative; height: 400px;">
    <img [ngSrc]="heroUrl" fill alt="Hero background" />
  </div>
`

Conditional images — ensure ngSrc has a value when rendered:

template: `
  @if (imageUrl) {
    <img [ngSrc]="imageUrl" width="800" height="600" alt="Dynamic image" />
  }
`

Complete Application Example

import { Component, inject } from '@angular/core';
import { NgOptimizedImage, IMAGE_LOADER, ImageLoaderConfig } from '@angular/common';

function sirvLoader(config: ImageLoaderConfig): string {
  const base = 'https://your-store.sirv.com';
  if (config.isPlaceholder) {
    return `${base}/${config.src}?w=30&blur=80&q=10`;
  }
  return config.width
    ? `${base}/${config.src}?w=${config.width}`
    : `${base}/${config.src}`;
}

@Component({
  selector: 'app-product-list',
  standalone: true,
  imports: [NgOptimizedImage],
  providers: [{ provide: IMAGE_LOADER, useValue: sirvLoader }],
  template: `
    <!-- Hero: priority + fill + srcset -->
    <section class="hero-banner">
      <img
        ngSrc="banners/summer-sale.jpg"
        fill
        priority
        sizes="100vw"
        placeholder
        alt="Summer sale: up to 50% off running shoes"
      />
    </section>

    <!-- Product Grid: responsive srcset + lazy -->
    <section class="product-grid">
      @for (product of products; track product.id) {
        <article class="product-card">
          <img
            [ngSrc]="product.image"
            width="800"
            height="800"
            sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
            placeholder
            [alt]="product.name + ' - ' + product.color"
          />
          <h3>{{ product.name }}</h3>
          <p>{{ product.price | currency }}</p>
        </article>
      }
    </section>
  `,
  styles: [`
    .hero-banner {
      position: relative;
      width: 100%;
      height: 60vh;
      overflow: hidden;
    }
    .hero-banner img { object-fit: cover; }

    .product-grid {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
      gap: 1.5rem;
      padding: 2rem;
    }
    .product-card img {
      width: 100%;
      height: auto;
      transition: transform 0.3s;
    }
    .product-card:hover img { transform: scale(1.05); }
  `],
})
export class ProductListComponent {
  products = inject(ProductService).getProducts();
}

For large product catalogs, Sirv AI Studio can batch-process images before they reach your Angular app — background removal, auto-cropping, quality optimization, and format generation — creating an end-to-end optimized pipeline.

Summary

Quick Reference

Scenario Key Attributes Notes
Hero image priority, fill, sizes="100vw" Mark as priority for LCP
Product grid sizes="...", placeholder Responsive srcset auto-generated
Thumbnails Fixed width/height Gets 1x/2x density descriptors
Background fill, object-fit: cover Parent needs position + dimensions
User avatar Fixed width/height Small, fixed size
CMS content fill or dynamic dimensions Use fill if dimensions unknown

Performance Checklist

  1. Import NgOptimizedImage in every component using images
  2. Use ngSrc instead of src for all <img> elements
  3. Provide width/height or use fill mode
  4. Add priority to the LCP image on each route
  5. Provide sizes for responsive images
  6. Configure a CDN loader (Sirv, Cloudinary, Imgix, or custom)
  7. Add preconnect hints for your CDN origin
  8. Enable placeholder for visible loading states
  9. Run Lighthouse before and after migration to quantify gains
  10. Monitor Core Web Vitals in production

NgOptimizedImage handles the complexity of responsive images, lazy loading, and performance hints automatically. Combined with a CDN like Sirv for on-the-fly optimization and format negotiation, your Angular application can achieve excellent Core Web Vitals scores with minimal manual effort.

Related Resources

Format References

Ready to optimize your images?

Sirv automatically optimizes, resizes, and converts your images. Try it free.

Start Free Trial