# Angular Image Optimization with NgOptimizedImage

> Angular's NgOptimizedImage directive: priority loading, automatic srcset, lazy loading, CDN loaders, and the patterns that fix LCP.

Canonical page: https://www.imageguide.dev/guides/angular-image-optimization/

Published: 2026-02-15. Updated: 2026-10-17.

Publisher: ImageGuide, published by Sirv. [Editorial method](https://www.imageguide.dev/about/#how-we-write).

## Quick answer

Use Angular's NgOptimizedImage directive to supply dimensions, responsive sources, and appropriate loading behavior. Mark the likely LCP image as a priority image and let off-screen images load later. Check the generated URLs and sizes in the browser; the directive still needs a suitable image source or loader.

Reference for this answer: [Angular image optimization documentation](https://angular.dev/guide/image-optimization).

## 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+):**

```typescript
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:**

```typescript
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`:

```typescript
@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:

```typescript
@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`.

```typescript
@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

```html
<!-- 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:

```typescript
// 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`:

```typescript
// 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:

```css
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`:

```typescript
@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:

```html
<!-- 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 |

```html
<!-- 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:

```html
<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

```typescript
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:

```typescript
// 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](https://sirv.com/) provides real-time image optimization with on-the-fly resizing, format conversion, and global CDN delivery. Create a custom loader:

```typescript
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`:

```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`:

```html
<!-- 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

```typescript
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:

```html
<!-- 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.

```typescript
@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

```css
/* 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):

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

The loader receives `isPlaceholder: true` and returns a tiny, blurred version. With [Sirv](https://sirv.com/):

```typescript
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:

```typescript
@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:

```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:

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

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

### SSR-Specific Patterns

```typescript
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:

```typescript
@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**

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

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

**Step 2: Replace `src` with `ngSrc` and add dimensions**

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

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

Find dimensions using DevTools:

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

**Step 3: Mark the LCP image with `priority`**

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

**Step 4: Add `sizes` for responsive images**

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

**Step 5: Configure a CDN loader**

```typescript
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:**

```typescript
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`:**

```typescript
// 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:**

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

## Complete Application Example

```typescript
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](https://www.sirv.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](https://sirv.com/), 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](https://my.sirv.com/#/signup) for on-the-fly optimization and format negotiation, your Angular application can achieve excellent Core Web Vitals scores with minimal manual effort.

## Sources

- [NgOptimizedImage, Angular](https://angular.dev/api/common/NgOptimizedImage)
- [Largest Contentful Paint, web.dev](https://web.dev/articles/lcp)
