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.
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
ngSrcinstead ofsrc widthandheightare 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:
- Sets
loading="eager"instead oflazy - Sets
fetchpriority="high"to tell the browser this image is critical - Generates a preload
<link>in the document<head> - 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
- Import
NgOptimizedImagein every component using images - Use
ngSrcinstead ofsrcfor all<img>elements - Provide
width/heightor usefillmode - Add
priorityto the LCP image on each route - Provide
sizesfor responsive images - Configure a CDN loader (Sirv, Cloudinary, Imgix, or custom)
- Add preconnect hints for your CDN origin
- Enable
placeholderfor visible loading states - Run Lighthouse before and after migration to quantify gains
- 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.