Platform Guide 22 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:

ProblemWithout DirectiveWith NgOptimizedImage
LCP timingManual preload/fetchpriorityAutomatic with priority
Layout shiftsForget dimensions, get CLSEnforces width/height
Lazy loadingManual Intersection ObserverBuilt-in default
Responsive imagesHand-coded srcsetAuto-generated
CDN integrationCustom URL constructionLoader architecture
Format negotiationManual <picture> elementsCDN 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 LocationUse Priority?Reasoning
Hero bannerYesAlmost always the LCP element
Above-fold productYesLikely LCP on product pages
Carousel first slideYesFirst visible image in a slider
Site logoMaybeOnly if largest above-fold element
Below-fold contentNoWould hurt performance
ThumbnailsNoToo 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

ScenarioRecommendation
Hero imageUse priority (includes eager + preload)
Navigation iconsloading="eager" if above fold
Image carouselpriority on first, lazy on rest
Product grid below foldKeep default lazy
Footer logosKeep 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

TypeBreakpoints
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

Layoutsizes Value
Full width100vw
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

ValueBehavior
coverFills container, may crop edges
containFits inside, may letterbox
fillStretches to fill (may distort)
scale-downLike 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:

WarningFix
Missing width/heightAdd width/height or use fill
Oversized imageProvide sizes or reduce source dimensions
Missing preconnectAdd <link rel="preconnect"> to index.html
Multiple prioritiesOnly mark the LCP image as priority
LCP without priorityAdd 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

ScenarioKey AttributesNotes
Hero imagepriority, fill, sizes="100vw"Mark as priority for LCP
Product gridsizes="...", placeholderResponsive srcset auto-generated
ThumbnailsFixed width/heightGets 1x/2x density descriptors
Backgroundfill, object-fit: coverParent needs position + dimensions
User avatarFixed width/heightSmall, fixed size
CMS contentfill or dynamic dimensionsUse 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