This content originally appeared on DEV Community and was authored by Hardi
Angular applications often struggle with image performance, especially as they scale beyond simple use cases. While Angular’s built-in lazy loading provides a foundation, modern applications need sophisticated optimization strategies that handle responsive images, format selection, preloading, and performance monitoring.
This comprehensive guide explores advanced Angular image optimization techniques that leverage Angular’s powerful features like directives, services, and the latest image optimization APIs to deliver exceptional performance.
The Angular Image Challenge
Angular applications face unique image optimization challenges that require framework-specific solutions:
// Common Angular image performance issues
interface ImagePerformanceChallenges {
changeDetection: {
issue: 'Image source changes trigger unnecessary re-renders'
impact: 'Poor performance, wasted CPU cycles'
solution: 'OnPush strategy and memoization'
}
routeTransitions: {
issue: 'Images reload during route changes'
impact: 'Poor perceived performance, wasted bandwidth'
solution: 'Service-based caching and preloading'
}
componentReuse: {
issue: 'Same images loaded multiple times across components'
impact: 'Memory bloat, slow performance'
solution: 'Singleton services and intelligent caching'
}
ssrHydration: {
issue: 'Server-side rendered images conflict with client hydration'
impact: 'Hydration mismatches, poor LCP scores'
solution: 'SSR-aware optimization strategies'
}
}
Advanced Lazy Loading Directive
// directives/smart-lazy-loading.directive.ts
import {
Directive,
ElementRef,
Input,
OnInit,
OnDestroy,
Inject,
PLATFORM_ID,
NgZone,
Renderer2,
Output,
EventEmitter
} from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { ImageOptimizationService } from '../services/image-optimization.service';
@Directive({
selector: '[appSmartLazyLoading]',
standalone: true
})
export class SmartLazyLoadingDirective implements OnInit, OnDestroy {
@Input() src!: string;
@Input() placeholder?: string;
@Input() errorImage?: string;
@Input() rootMargin = '50px';
@Input() threshold = 0.1;
@Input() priority: 'low' | 'normal' | 'high' = 'normal';
@Input() formats: string[] = ['avif', 'webp', 'jpg'];
@Input() quality = 80;
@Output() imageLoad = new EventEmitter<Event>();
@Output() imageError = new EventEmitter<Event>();
@Output() imageLoadStart = new EventEmitter<void>();
private observer!: IntersectionObserver;
private isLoaded = false;
private isLoading = false;
constructor(
private elementRef: ElementRef<HTMLImageElement>,
private renderer: Renderer2,
private ngZone: NgZone,
private imageService: ImageOptimizationService,
@Inject(PLATFORM_ID) private platformId: Object
) {}
ngOnInit(): void {
if (!isPlatformBrowser(this.platformId)) {
// SSR: Set placeholder or load immediately for critical images
this.handleSSR();
return;
}
this.setupLazyLoading();
}
ngOnDestroy(): void {
if (this.observer) {
this.observer.disconnect();
}
}
private handleSSR(): void {
if (this.priority === 'high') {
// Load critical images immediately during SSR
this.loadImage();
} else if (this.placeholder) {
this.renderer.setAttribute(this.elementRef.nativeElement, 'src', this.placeholder);
}
}
private setupLazyLoading(): void {
// Set placeholder immediately
if (this.placeholder) {
this.renderer.setAttribute(this.elementRef.nativeElement, 'src', this.placeholder);
}
// Create intersection observer
this.ngZone.runOutsideAngular(() => {
this.observer = new IntersectionObserver(
(entries) => this.handleIntersection(entries),
{
rootMargin: this.rootMargin,
threshold: this.threshold
}
);
this.observer.observe(this.elementRef.nativeElement);
});
// Setup image event listeners
this.setupImageListeners();
}
private setupImageListeners(): void {
const img = this.elementRef.nativeElement;
this.renderer.listen(img, 'load', (event) => {
this.ngZone.run(() => {
this.isLoaded = true;
this.isLoading = false;
this.renderer.addClass(img, 'loaded');
this.imageLoad.emit(event);
});
});
this.renderer.listen(img, 'error', (event) => {
this.ngZone.run(() => {
this.isLoading = false;
this.handleImageError(event);
});
});
}
private handleIntersection(entries: IntersectionObserverEntry[]): void {
entries.forEach(entry => {
if (entry.isIntersecting && !this.isLoaded && !this.isLoading) {
this.ngZone.run(() => {
this.loadImage();
});
this.observer.unobserve(entry.target);
}
});
}
private async loadImage(): Promise<void> {
if (this.isLoading || this.isLoaded) return;
this.isLoading = true;
this.imageLoadStart.emit();
try {
// Get optimal image URL from service
const optimizedSrc = await this.imageService.getOptimizedImageUrl(this.src, {
formats: this.formats,
quality: this.quality,
priority: this.priority
});
// Preload the image
await this.preloadImage(optimizedSrc);
// Set the optimized source
this.renderer.setAttribute(this.elementRef.nativeElement, 'src', optimizedSrc);
this.renderer.addClass(this.elementRef.nativeElement, 'loading');
} catch (error) {
console.warn('Failed to load optimized image:', error);
this.handleImageError(error);
}
}
private preloadImage(src: string): Promise<void> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve();
img.onerror = reject;
img.src = src;
});
}
private handleImageError(event: any): void {
if (this.errorImage) {
this.renderer.setAttribute(this.elementRef.nativeElement, 'src', this.errorImage);
} else {
this.renderer.addClass(this.elementRef.nativeElement, 'error');
}
this.imageError.emit(event);
}
}
Image Optimization Service
// services/image-optimization.service.ts
import { Injectable, Inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { BehaviorSubject, Observable, from, of } from 'rxjs';
import { map, catchError, shareReplay } from 'rxjs/operators';
export interface ImageOptimizationConfig {
formats?: string[];
quality?: number;
priority?: 'low' | 'normal' | 'high';
width?: number;
height?: number;
fit?: 'cover' | 'contain' | 'fill';
}
export interface FormatSupport {
webp: boolean;
avif: boolean;
heic: boolean;
}
export interface CachedImage {
src: string;
optimizedSrc: string;
timestamp: number;
metadata: {
width: number;
height: number;
format: string;
fileSize?: number;
};
}
@Injectable({
providedIn: 'root'
})
export class ImageOptimizationService {
private readonly cache = new Map<string, CachedImage>();
private readonly maxCacheSize = 100;
private readonly cacheExpiry = 24 * 60 * 60 * 1000; // 24 hours
private formatSupport$ = new BehaviorSubject<FormatSupport>({
webp: false,
avif: false,
heic: false
});
private readonly defaultConfig: Required<ImageOptimizationConfig> = {
formats: ['avif', 'webp', 'jpg'],
quality: 80,
priority: 'normal',
width: 0,
height: 0,
fit: 'cover'
};
constructor(@Inject(PLATFORM_ID) private platformId: Object) {
if (isPlatformBrowser(this.platformId)) {
this.detectFormatSupport();
}
}
async getOptimizedImageUrl(
src: string,
config: ImageOptimizationConfig = {}
): Promise<string> {
const fullConfig = { ...this.defaultConfig, ...config };
const cacheKey = this.generateCacheKey(src, fullConfig);
// Check cache first
const cached = this.getCachedImage(cacheKey);
if (cached) {
return cached.optimizedSrc;
}
try {
const optimizedSrc = await this.generateOptimizedUrl(src, fullConfig);
// Cache the result
this.cacheImage(cacheKey, {
src,
optimizedSrc,
timestamp: Date.now(),
metadata: {
width: fullConfig.width,
height: fullConfig.height,
format: this.getBestFormat(fullConfig.formats)
}
});
return optimizedSrc;
} catch (error) {
console.warn('Image optimization failed, returning original:', error);
return src;
}
}
getFormatSupport(): Observable<FormatSupport> {
return this.formatSupport$.asObservable();
}
preloadImage(src: string, config: ImageOptimizationConfig = {}): Observable<string> {
return from(this.getOptimizedImageUrl(src, config)).pipe(
map(optimizedSrc => {
// Trigger actual preload
const link = document.createElement('link');
link.rel = 'preload';
link.as = 'image';
link.href = optimizedSrc;
document.head.appendChild(link);
return optimizedSrc;
}),
catchError(error => {
console.warn('Preload failed:', error);
return of(src);
}),
shareReplay(1)
);
}
generateResponsiveSrcSet(
src: string,
sizes: number[],
config: ImageOptimizationConfig = {}
): Observable<string> {
const promises = sizes.map(size =>
this.getOptimizedImageUrl(src, { ...config, width: size })
.then(url => `${url} ${size}w`)
);
return from(Promise.all(promises)).pipe(
map(srcSetEntries => srcSetEntries.join(', '))
);
}
clearCache(): void {
this.cache.clear();
}
getCacheStats(): { size: number; entries: number } {
return {
size: Array.from(this.cache.values())
.reduce((total, item) => total + (item.metadata.fileSize || 0), 0),
entries: this.cache.size
};
}
private async detectFormatSupport(): Promise<void> {
const support: FormatSupport = {
webp: false,
avif: false,
heic: false
};
try {
support.webp = await this.testFormatSupport('webp');
support.avif = await this.testFormatSupport('avif');
support.heic = await this.testFormatSupport('heic');
} catch (error) {
console.warn('Format detection failed:', error);
}
this.formatSupport$.next(support);
}
private testFormatSupport(format: string): Promise<boolean> {
const testImages: Record<string, string> = {
webp: 'data:image/webp;base64,UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==',
avif: 'data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAABGgAAAB0AAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAEAAAABAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQAMAAAAABNjb2xybmNseAACAAIABoAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAAB9tZGF0EgAKCBgABogQEDQgMgkQAAAAB8dSLfI=',
heic: 'data:image/heic;base64,AAAAHGZ0eXBoZWljAAAAAG1pZjFoZWljbWlhZgAAA'
};
return new Promise(resolve => {
const img = new Image();
img.onload = () => resolve(true);
img.onerror = () => resolve(false);
img.src = testImages[format] || '';
});
}
private getBestFormat(formats: string[]): string {
const support = this.formatSupport$.value;
for (const format of formats) {
if (format === 'avif' && support.avif) return 'avif';
if (format === 'webp' && support.webp) return 'webp';
if (format === 'heic' && support.heic) return 'heic';
}
return 'jpg';
}
private async generateOptimizedUrl(
src: string,
config: Required<ImageOptimizationConfig>
): Promise<string> {
const format = this.getBestFormat(config.formats);
const params = new URLSearchParams({
src: encodeURIComponent(src),
f: format,
q: config.quality.toString(),
fit: config.fit
});
if (config.width > 0) params.set('w', config.width.toString());
if (config.height > 0) params.set('h', config.height.toString());
// This would integrate with your image optimization service
return `/api/images/optimize?${params.toString()}`;
}
private generateCacheKey(src: string, config: ImageOptimizationConfig): string {
return `${src}_${JSON.stringify(config)}`;
}
private getCachedImage(key: string): CachedImage | null {
const cached = this.cache.get(key);
if (!cached) return null;
// Check if cache entry is expired
if (Date.now() - cached.timestamp > this.cacheExpiry) {
this.cache.delete(key);
return null;
}
return cached;
}
private cacheImage(key: string, image: CachedImage): void {
// Implement LRU cache eviction
if (this.cache.size >= this.maxCacheSize) {
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}
this.cache.set(key, image);
}
}
Responsive Image Component
// components/responsive-image/responsive-image.component.ts
import {
Component,
Input,
Output,
EventEmitter,
OnInit,
OnDestroy,
ChangeDetectionStrategy,
ViewChild,
ElementRef,
Inject,
PLATFORM_ID
} from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { Subject, takeUntil, combineLatest } from 'rxjs';
import { ImageOptimizationService, ImageOptimizationConfig } from '../../services/image-optimization.service';
interface ResponsiveBreakpoint {
minWidth: number;
maxWidth?: number;
sizes: string;
quality?: number;
}
@Component({
selector: 'app-responsive-image',
standalone: true,
templateUrl: './responsive-image.component.html',
styleUrls: ['./responsive-image.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ResponsiveImageComponent implements OnInit, OnDestroy {
@Input() src!: string;
@Input() alt!: string;
@Input() loading: 'lazy' | 'eager' = 'lazy';
@Input() priority: 'low' | 'normal' | 'high' = 'normal';
@Input() placeholder?: string;
@Input() errorImage?: string;
@Input() aspectRatio?: string;
@Input() objectFit: 'cover' | 'contain' | 'fill' | 'scale-down' | 'none' = 'cover';
@Input() formats: string[] = ['avif', 'webp', 'jpg'];
@Input() quality = 80;
@Input() breakpoints: ResponsiveBreakpoint[] = [
{ minWidth: 320, sizes: '100vw', quality: 70 },
{ minWidth: 768, sizes: '50vw', quality: 80 },
{ minWidth: 1024, sizes: '33vw', quality: 85 }
];
@Output() imageLoad = new EventEmitter<Event>();
@Output() imageError = new EventEmitter<Event>();
@Output() loadStart = new EventEmitter<void>();
@ViewChild('imageElement') imageElement!: ElementRef<HTMLImageElement>;
currentSrc = '';
currentSrcSet = '';
currentSizes = '';
isLoaded = false;
isLoading = false;
hasError = false;
private destroy$ = new Subject<void>();
private resizeObserver?: ResizeObserver;
constructor(
private imageService: ImageOptimizationService,
@Inject(PLATFORM_ID) private platformId: Object
) {}
ngOnInit(): void {
this.initializeImage();
if (isPlatformBrowser(this.platformId)) {
this.setupResponsiveHandling();
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
}
private async initializeImage(): Promise<void> {
if (!this.src) return;
try {
this.isLoading = true;
this.loadStart.emit();
// Generate responsive srcset
const srcSetSizes = this.breakpoints.map(bp => bp.minWidth);
const config: ImageOptimizationConfig = {
formats: this.formats,
quality: this.quality,
priority: this.priority
};
// Get optimized primary source
this.currentSrc = await this.imageService.getOptimizedImageUrl(this.src, config);
// Generate responsive srcset
combineLatest([
this.imageService.generateResponsiveSrcSet(this.src, srcSetSizes, config)
]).pipe(
takeUntil(this.destroy$)
).subscribe(([srcSet]) => {
this.currentSrcSet = srcSet;
this.currentSizes = this.generateSizesAttribute();
});
} catch (error) {
this.handleError(error);
}
}
private setupResponsiveHandling(): void {
// Setup resize observer for container-based responsive sizing
this.resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
this.updateImageForSize(entry.contentRect.width);
}
});
}
private updateImageForSize(containerWidth: number): void {
// Find appropriate breakpoint
const breakpoint = this.breakpoints
.filter(bp => containerWidth >= bp.minWidth)
.pop();
if (breakpoint && breakpoint.quality !== this.quality) {
// Update image quality for current breakpoint
this.quality = breakpoint.quality || this.quality;
this.initializeImage();
}
}
private generateSizesAttribute(): string {
return this.breakpoints
.map(bp => {
if (bp.maxWidth) {
return `(min-width: ${bp.minWidth}px) and (max-width: ${bp.maxWidth}px) ${bp.sizes}`;
}
return `(min-width: ${bp.minWidth}px) ${bp.sizes}`;
})
.join(', ');
}
onImageLoad(event: Event): void {
this.isLoaded = true;
this.isLoading = false;
this.hasError = false;
this.imageLoad.emit(event);
}
onImageError(event: Event): void {
this.hasError = true;
this.isLoading = false;
this.handleError(event);
}
private handleError(error: any): void {
console.warn('Image loading failed:', error);
this.imageError.emit(error);
if (this.errorImage) {
this.currentSrc = this.errorImage;
}
}
}
<!-- components/responsive-image/responsive-image.component.html -->
<div class="responsive-image-container" [style.aspect-ratio]="aspectRatio">
<img
#imageElement
[src]="currentSrc"
[srcset]="currentSrcSet"
[sizes]="currentSizes"
[alt]="alt"
[loading]="loading"
[style.object-fit]="objectFit"
[class.loaded]="isLoaded"
[class.loading]="isLoading"
[class.error]="hasError"
(load)="onImageLoad($event)"
(error)="onImageError($event)"
appSmartLazyLoading
[src]="src"
[placeholder]="placeholder"
[priority]="priority"
[formats]="formats"
[quality]="quality"
/>
<!-- Loading placeholder -->
<div *ngIf="isLoading && placeholder" class="image-placeholder">
<img [src]="placeholder" [alt]="alt + ' (placeholder)'" />
</div>
<!-- Error state -->
<div *ngIf="hasError && !errorImage" class="image-error">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
</svg>
<span>Failed to load image</span>
</div>
</div>
// components/responsive-image/responsive-image.component.scss
.responsive-image-container {
position: relative;
display: inline-block;
overflow: hidden;
img {
width: 100%;
height: 100%;
transition: opacity 0.3s ease;
&.loading {
opacity: 0;
}
&.loaded {
opacity: 1;
}
&.error {
opacity: 0.5;
}
}
}
.image-placeholder {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
img {
filter: blur(5px);
transform: scale(1.1);
}
}
.image-error {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #f8f8f8;
color: #666;
svg {
margin-bottom: 8px;
}
span {
font-size: 14px;
}
}
Virtual Scrolling Gallery Component
// components/virtual-gallery/virtual-gallery.component.ts
import {
Component,
Input,
Output,
EventEmitter,
OnInit,
OnDestroy,
ChangeDetectionStrategy,
ViewChild,
ElementRef,
AfterViewInit,
ChangeDetectorRef
} from '@angular/core';
import { Subject, fromEvent, takeUntil, debounceTime } from 'rxjs';
export interface GalleryItem {
id: string;
src: string;
alt: string;
caption?: string;
metadata?: any;
}
@Component({
selector: 'app-virtual-gallery',
standalone: true,
templateUrl: './virtual-gallery.component.html',
styleUrls: ['./virtual-gallery.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class VirtualGalleryComponent implements OnInit, AfterViewInit, OnDestroy {
@Input() items: GalleryItem[] = [];
@Input() itemWidth = 200;
@Input() itemHeight = 200;
@Input() gap = 10;
@Input() itemsPerRow = 5;
@Input() overscan = 5;
@Input() showCaptions = false;
@Output() itemClick = new EventEmitter<GalleryItem>();
@Output() itemLoad = new EventEmitter<GalleryItem>();
@Output() itemError = new EventEmitter<{ item: GalleryItem; error: any }>();
@ViewChild('scrollContainer') scrollContainer!: ElementRef<HTMLDivElement>;
@ViewChild('viewport') viewport!: ElementRef<HTMLDivElement>;
visibleItems: Array<GalleryItem & { index: number; row: number; col: number }> = [];
totalHeight = 0;
scrollTop = 0;
containerHeight = 0;
private destroy$ = new Subject<void>();
private resizeObserver?: ResizeObserver;
constructor(private cdr: ChangeDetectorRef) {}
ngOnInit(): void {
this.calculateLayout();
}
ngAfterViewInit(): void {
this.setupScrollHandling();
this.setupResizeHandling();
this.updateVisibleItems();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
}
private calculateLayout(): void {
const rowHeight = this.itemHeight + this.gap + (this.showCaptions ? 30 : 0);
const totalRows = Math.ceil(this.items.length / this.itemsPerRow);
this.totalHeight = totalRows * rowHeight;
}
private setupScrollHandling(): void {
fromEvent(this.scrollContainer.nativeElement, 'scroll')
.pipe(
debounceTime(16), // ~60fps
takeUntil(this.destroy$)
)
.subscribe(() => {
this.scrollTop = this.scrollContainer.nativeElement.scrollTop;
this.updateVisibleItems();
});
}
private setupResizeHandling(): void {
this.resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
this.containerHeight = entry.contentRect.height;
this.updateVisibleItems();
}
});
this.resizeObserver.observe(this.scrollContainer.nativeElement);
}
private updateVisibleItems(): void {
const rowHeight = this.itemHeight + this.gap + (this.showCaptions ? 30 : 0);
const startRow = Math.floor(this.scrollTop / rowHeight);
const endRow = Math.ceil((this.scrollTop + this.containerHeight) / rowHeight);
// Add overscan
const visibleStartRow = Math.max(0, startRow - this.overscan);
const visibleEndRow = Math.min(
Math.ceil(this.items.length / this.itemsPerRow),
endRow + this.overscan
);
this.visibleItems = [];
for (let row = visibleStartRow; row < visibleEndRow; row++) {
for (let col = 0; col < this.itemsPerRow; col++) {
const index = row * this.itemsPerRow + col;
if (index < this.items.length) {
this.visibleItems.push({
...this.items[index],
index,
row,
col
});
}
}
}
this.cdr.markForCheck();
}
getItemStyle(item: { row: number; col: number }): any {
const rowHeight = this.itemHeight + this.gap + (this.showCaptions ? 30 : 0);
return {
position: 'absolute',
left: `${item.col * (this.itemWidth + this.gap)}px`,
top: `${item.row * rowHeight}px`,
width: `${this.itemWidth}px`,
height: `${this.itemHeight}px`
};
}
getLoadingStrategy(item: { row: number }): 'lazy' | 'eager' {
const currentRow = Math.floor(this.scrollTop / (this.itemHeight + this.gap));
const distance = Math.abs(item.row - currentRow);
return distance <= 2 ? 'eager' : 'lazy';
}
onItemClick(item: GalleryItem): void {
this.itemClick.emit(item);
}
onItemLoad(item: GalleryItem): void {
this.itemLoad.emit(item);
}
onItemError(item: GalleryItem, error: any): void {
this.itemError.emit({ item, error });
}
scrollToItem(index: number): void {
const row = Math.floor(index / this.itemsPerRow);
const rowHeight = this.itemHeight + this.gap + (this.showCaptions ? 30 : 0);
const targetScrollTop = row * rowHeight;
this.scrollContainer.nativeElement.scrollTo({
top: targetScrollTop,
behavior: 'smooth'
});
}
}
<!-- components/virtual-gallery/virtual-gallery.component.html -->
<div #scrollContainer class="virtual-gallery-container">
<div #viewport class="gallery-viewport" [style.height.px]="totalHeight">
<div
*ngFor="let item of visibleItems; trackBy: trackByItemId"
class="gallery-item"
[ngStyle]="getItemStyle(item)"
(click)="onItemClick(item)"
>
<app-responsive-image
[src]="item.src"
[alt]="item.alt"
[loading]="getLoadingStrategy(item)"
[priority]="item.index < 10 ? 'high' : 'normal'"
[aspectRatio]="'1/1'"
[objectFit]="'cover'"
(imageLoad)="onItemLoad(item)"
(imageError)="onItemError(item, $event)"
></app-responsive-image>
<div *ngIf="showCaptions && item.caption" class="item-caption">
{{ item.caption }}
</div>
</div>
</div>
</div>
// components/virtual-gallery/virtual-gallery.component.scss
.virtual-gallery-container {
height: 400px;
overflow-y: auto;
border: 1px solid #e0e0e0;
border-radius: 8px;
}
.gallery-viewport {
position: relative;
}
.gallery-item {
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
&:hover {
transform: scale(1.05);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
}
.item-caption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
color: white;
padding: 8px;
font-size: 12px;
text-align: center;
}
Performance Monitoring Service
// services/image-performance.service.ts
import { Injectable, Inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { BehaviorSubject, Observable } from 'rxjs';
export interface ImagePerformanceMetrics {
totalImages: number;
loadedImages: number;
failedImages: number;
averageLoadTime: number;
largestContentfulPaint: number;
cumulativeLayoutShift: number;
formatDistribution: Record<string, number>;
sizeDistribution: Record<string, number>;
}
export interface ImageLoadEvent {
src: string;
loadTime: number;
format: string;
size: { width: number; height: number };
fromCache: boolean;
error?: string;
}
@Injectable({
providedIn: 'root'
})
export class ImagePerformanceService {
private metrics$ = new BehaviorSubject<ImagePerformanceMetrics>({
totalImages: 0,
loadedImages: 0,
failedImages: 0,
averageLoadTime: 0,
largestContentfulPaint: 0,
cumulativeLayoutShift: 0,
formatDistribution: {},
sizeDistribution: {}
});
private loadEvents: ImageLoadEvent[] = [];
private lcpObserver?: PerformanceObserver;
private clsObserver?: PerformanceObserver;
constructor(@Inject(PLATFORM_ID) private platformId: Object) {
if (isPlatformBrowser(this.platformId)) {
this.initializePerformanceObservers();
}
}
getMetrics(): Observable<ImagePerformanceMetrics> {
return this.metrics$.asObservable();
}
trackImageLoad(event: ImageLoadEvent): void {
this.loadEvents.push(event);
this.updateMetrics();
}
trackImageError(src: string, error: string): void {
const errorEvent: ImageLoadEvent = {
src,
loadTime: 0,
format: 'unknown',
size: { width: 0, height: 0 },
fromCache: false,
error
};
this.loadEvents.push(errorEvent);
this.updateMetrics();
}
generatePerformanceReport(): any {
const metrics = this.metrics$.value;
const recommendations = this.generateRecommendations(metrics);
return {
...metrics,
recommendations,
timestamp: new Date().toISOString(),
loadEvents: this.loadEvents.slice(-100) // Last 100 events
};
}
clearMetrics(): void {
this.loadEvents = [];
this.metrics$.next({
totalImages: 0,
loadedImages: 0,
failedImages: 0,
averageLoadTime: 0,
largestContentfulPaint: 0,
cumulativeLayoutShift: 0,
formatDistribution: {},
sizeDistribution: {}
});
}
private initializePerformanceObservers(): void {
// Largest Contentful Paint Observer
this.lcpObserver = new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
const lastEntry = entries[entries.length - 1] as any;
if (lastEntry && lastEntry.element?.tagName === 'IMG') {
const currentMetrics = this.metrics$.value;
this.metrics$.next({
...currentMetrics,
largestContentfulPaint: lastEntry.startTime
});
}
});
try {
this.lcpObserver.observe({ entryTypes: ['largest-contentful-paint'] });
} catch (error) {
console.warn('LCP observer not supported:', error);
}
// Cumulative Layout Shift Observer
this.clsObserver = new PerformanceObserver((entryList) => {
let clsValue = this.metrics$.value.cumulativeLayoutShift;
for (const entry of entryList.getEntries()) {
if (!(entry as any).hadRecentInput) {
clsValue += (entry as any).value;
}
}
const currentMetrics = this.metrics$.value;
this.metrics$.next({
...currentMetrics,
cumulativeLayoutShift: clsValue
});
});
try {
this.clsObserver.observe({ entryTypes: ['layout-shift'] });
} catch (error) {
console.warn('CLS observer not supported:', error);
}
}
private updateMetrics(): void {
const successfulLoads = this.loadEvents.filter(e => !e.error);
const failedLoads = this.loadEvents.filter(e => e.error);
const formatDistribution: Record<string, number> = {};
const sizeDistribution: Record<string, number> = {};
successfulLoads.forEach(event => {
formatDistribution[event.format] = (formatDistribution[event.format] || 0) + 1;
const sizeCategory = this.categorizeSize(event.size);
sizeDistribution[sizeCategory] = (sizeDistribution[sizeCategory] || 0) + 1;
});
const averageLoadTime = successfulLoads.length > 0
? successfulLoads.reduce((sum, e) => sum + e.loadTime, 0) / successfulLoads.length
: 0;
this.metrics$.next({
totalImages: this.loadEvents.length,
loadedImages: successfulLoads.length,
failedImages: failedLoads.length,
averageLoadTime,
largestContentfulPaint: this.metrics$.value.largestContentfulPaint,
cumulativeLayoutShift: this.metrics$.value.cumulativeLayoutShift,
formatDistribution,
sizeDistribution
});
}
private categorizeSize(size: { width: number; height: number }): string {
const area = size.width * size.height;
if (area < 50000) return 'small';
if (area < 200000) return 'medium';
if (area < 500000) return 'large';
return 'extra-large';
}
private generateRecommendations(metrics: ImagePerformanceMetrics): string[] {
const recommendations: string[] = [];
if (metrics.averageLoadTime > 2000) {
recommendations.push('Consider using more aggressive image compression or modern formats');
}
if (metrics.failedImages / metrics.totalImages > 0.05) {
recommendations.push('High image failure rate detected - check image URLs and error handling');
}
if (metrics.cumulativeLayoutShift > 0.1) {
recommendations.push('Add explicit width/height attributes to prevent layout shifts');
}
if (metrics.largestContentfulPaint > 2500) {
recommendations.push('Optimize LCP images with preloading and better compression');
}
const modernFormats = ['avif', 'webp'];
const modernFormatUsage = modernFormats.reduce(
(sum, format) => sum + (metrics.formatDistribution[format] || 0), 0
);
if (modernFormatUsage / metrics.totalImages < 0.5) {
recommendations.push('Increase usage of modern image formats (WebP, AVIF)');
}
return recommendations;
}
}
Router Integration and Preloading
// services/image-preloader.service.ts
import { Injectable } from '@angular/core';
import { Router, NavigationEnd, ActivatedRoute } from '@angular/router';
import { filter, switchMap } from 'rxjs/operators';
import { ImageOptimizationService } from './image-optimization.service';
interface RouteImageConfig {
images: string[];
priority: 'low' | 'normal' | 'high';
preloadDistance: number;
}
@Injectable({
providedIn: 'root'
})
export class ImagePreloaderService {
private routeImageMap = new Map<string, RouteImageConfig>();
private preloadedRoutes = new Set<string>();
constructor(
private router: Router,
private imageService: ImageOptimizationService
) {
this.setupRoutePreloading();
}
registerRouteImages(route: string, config: RouteImageConfig): void {
this.routeImageMap.set(route, config);
}
preloadImagesForRoute(route: string): Promise<void[]> {
const config = this.routeImageMap.get(route);
if (!config || this.preloadedRoutes.has(route)) {
return Promise.resolve([]);
}
this.preloadedRoutes.add(route);
const preloadPromises = config.images.map(imageSrc =>
this.imageService.preloadImage(imageSrc, {
priority: config.priority
}).toPromise()
);
return Promise.all(preloadPromises);
}
private setupRoutePreloading(): void {
this.router.events
.pipe(
filter(event => event instanceof NavigationEnd),
switchMap((event: NavigationEnd) => {
return this.preloadImagesForRoute(event.url);
})
)
.subscribe({
next: () => console.log('Route images preloaded'),
error: (error) => console.warn('Route preloading failed:', error)
});
}
}
// Route configuration example
// app-routing.module.ts
const routes: Routes = [
{
path: 'gallery/:id',
component: GalleryComponent,
data: {
preloadImages: true,
imageConfig: {
priority: 'high',
preloadDistance: 5
}
}
}
];
Testing Image Optimization
When implementing advanced image optimization in Angular applications, comprehensive testing ensures that performance improvements work correctly across different scenarios and browser environments. I often use tools like ConverterToolsKit during development to generate test images in various formats and sizes, helping validate that the optimization strategies function properly before deploying to production.
// image-optimization.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { PLATFORM_ID } from '@angular/core';
import { ImageOptimizationService } from './image-optimization.service';
describe('ImageOptimizationService', () => {
let service: ImageOptimizationService;
let mockPlatformId: string;
beforeEach(() => {
mockPlatformId = 'browser';
TestBed.configureTestingModule({
providers: [
{ provide: PLATFORM_ID, useValue: mockPlatformId }
]
});
service = TestBed.inject(ImageOptimizationService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should detect format support', async () => {
spyOn(service as any, 'testFormatSupport').and.returnValues(
Promise.resolve(true), // WebP
Promise.resolve(false), // AVIF
Promise.resolve(false) // HEIC
);
await (service as any).detectFormatSupport();
service.getFormatSupport().subscribe(support => {
expect(support.webp).toBe(true);
expect(support.avif).toBe(false);
expect(support.heic).toBe(false);
});
});
it('should generate optimized URLs', async () => {
const src = 'test-image.jpg';
const config = { quality: 90, width: 800 };
const optimizedUrl = await service.getOptimizedImageUrl(src, config);
expect(optimizedUrl).toContain('test-image.jpg');
expect(optimizedUrl).toContain('q=90');
expect(optimizedUrl).toContain('w=800');
});
it('should cache images correctly', async () => {
const src = 'test-image.jpg';
// First call should generate URL
const url1 = await service.getOptimizedImageUrl(src);
// Second call should return cached result
const url2 = await service.getOptimizedImageUrl(src);
expect(url1).toBe(url2);
const cacheStats = service.getCacheStats();
expect(cacheStats.entries).toBe(1);
});
it('should implement LRU cache eviction', async () => {
// Override cache size for testing
(service as any).maxCacheSize = 2;
await service.getOptimizedImageUrl('image1.jpg');
await service.getOptimizedImageUrl('image2.jpg');
await service.getOptimizedImageUrl('image3.jpg');
const cacheStats = service.getCacheStats();
expect(cacheStats.entries).toBe(2);
});
});
// responsive-image.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ChangeDetectionStrategy } from '@angular/core';
import { ResponsiveImageComponent } from './responsive-image.component';
import { ImageOptimizationService } from '../../services/image-optimization.service';
describe('ResponsiveImageComponent', () => {
let component: ResponsiveImageComponent;
let fixture: ComponentFixture<ResponsiveImageComponent>;
let mockImageService: jasmine.SpyObj<ImageOptimizationService>;
beforeEach(async () => {
const spy = jasmine.createSpyObj('ImageOptimizationService', [
'getOptimizedImageUrl',
'generateResponsiveSrcSet'
]);
await TestBed.configureTestingModule({
imports: [ResponsiveImageComponent],
providers: [
{ provide: ImageOptimizationService, useValue: spy }
]
})
.overrideComponent(ResponsiveImageComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default }
})
.compileComponents();
fixture = TestBed.createComponent(ResponsiveImageComponent);
component = fixture.componentInstance;
mockImageService = TestBed.inject(ImageOptimizationService) as jasmine.SpyObj<ImageOptimizationService>;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should initialize with required inputs', () => {
component.src = 'test-image.jpg';
component.alt = 'Test image';
expect(component.src).toBe('test-image.jpg');
expect(component.alt).toBe('Test image');
});
it('should generate responsive srcset', async () => {
mockImageService.generateResponsiveSrcSet.and.returnValue(
Promise.resolve('image-400.webp 400w, image-800.webp 800w')
);
component.src = 'test-image.jpg';
await component.ngOnInit();
expect(mockImageService.generateResponsiveSrcSet).toHaveBeenCalled();
});
it('should handle image load events', () => {
spyOn(component.imageLoad, 'emit');
const mockEvent = new Event('load');
component.onImageLoad(mockEvent);
expect(component.isLoaded).toBe(true);
expect(component.isLoading).toBe(false);
expect(component.imageLoad.emit).toHaveBeenCalledWith(mockEvent);
});
it('should handle image error events', () => {
spyOn(component.imageError, 'emit');
const mockEvent = new Event('error');
component.onImageError(mockEvent);
expect(component.hasError).toBe(true);
expect(component.isLoading).toBe(false);
expect(component.imageError.emit).toHaveBeenCalledWith(mockEvent);
});
});
Integration with Angular Universal (SSR)
// app.server.module.ts
import { NgModule } from '@angular/core';
import { ServerModule } from '@angular/platform-server';
import { AppModule } from './app.module';
import { AppComponent } from './app.component';
import { ImageOptimizationService } from './services/image-optimization.service';
import { SSRImageOptimizationService } from './services/ssr-image-optimization.service';
@NgModule({
imports: [AppModule, ServerModule],
providers: [
{
provide: ImageOptimizationService,
useClass: SSRImageOptimizationService
}
],
bootstrap: [AppComponent]
})
export class AppServerModule {}
// services/ssr-image-optimization.service.ts
import { Injectable } from '@angular/core';
import { ImageOptimizationService, ImageOptimizationConfig } from './image-optimization.service';
import { Observable, of } from 'rxjs';
@Injectable()
export class SSRImageOptimizationService extends ImageOptimizationService {
async getOptimizedImageUrl(
src: string,
config: ImageOptimizationConfig = {}
): Promise<string> {
// During SSR, return placeholder or original image
if (config.priority === 'high') {
// For critical images, generate optimized URL during SSR
return this.generateSSROptimizedUrl(src, config);
}
// For non-critical images, return placeholder
return config.placeholder || src;
}
generateResponsiveSrcSet(
src: string,
sizes: number[],
config: ImageOptimizationConfig = {}
): Observable<string> {
// Generate basic srcset for SSR
const srcSetEntries = sizes.map(size => `${src}?w=${size} ${size}w`);
return of(srcSetEntries.join(', '));
}
private generateSSROptimizedUrl(src: string, config: ImageOptimizationConfig): string {
const params = new URLSearchParams({
src: encodeURIComponent(src),
f: 'webp', // Default to WebP for SSR
q: (config.quality || 80).toString()
});
return `/api/images/optimize?${params.toString()}`;
}
}
Progressive Web App Integration
// services/image-cache.service.ts
import { Injectable } from '@angular/core';
import { SwUpdate } from '@angular/service-worker';
@Injectable({
providedIn: 'root'
})
export class ImageCacheService {
private readonly CACHE_NAME = 'optimized-images-v1';
constructor(private swUpdate: SwUpdate) {
this.setupCacheStrategy();
}
async cacheImage(url: string): Promise<void> {
if (!('caches' in window)) return;
try {
const cache = await caches.open(this.CACHE_NAME);
await cache.add(url);
} catch (error) {
console.warn('Failed to cache image:', error);
}
}
async getCachedImage(url: string): Promise<Response | undefined> {
if (!('caches' in window)) return undefined;
try {
const cache = await caches.open(this.CACHE_NAME);
return await cache.match(url);
} catch (error) {
console.warn('Failed to retrieve cached image:', error);
return undefined;
}
}
async clearImageCache(): Promise<void> {
if (!('caches' in window)) return;
try {
await caches.delete(this.CACHE_NAME);
} catch (error) {
console.warn('Failed to clear image cache:', error);
}
}
private setupCacheStrategy(): void {
if (this.swUpdate.isEnabled) {
// Register cache strategy for images
this.registerImageCacheStrategy();
}
}
private registerImageCacheStrategy(): void {
// This would typically be handled by the service worker
// but can be coordinated from the main thread
navigator.serviceWorker.ready.then(registration => {
if (registration.active) {
registration.active.postMessage({
type: 'SETUP_IMAGE_CACHE',
cacheName: this.CACHE_NAME
});
}
});
}
}
Conclusion
Angular’s powerful architecture enables sophisticated image optimization strategies that go far beyond basic lazy loading. The techniques covered here leverage Angular’s strengths to deliver exceptional image performance:
Advanced Framework Integration:
- Custom directives for intelligent lazy loading with intersection observers
- Services for centralized image optimization and caching management
- Change detection optimization with OnPush strategy and memoization
- SSR-aware optimization for universal applications
Performance-First Architecture:
- Virtual scrolling for large image galleries with minimal DOM impact
- Network-aware quality adjustment and format selection
- LRU caching with configurable expiration and size limits
- Core Web Vitals monitoring and performance regression detection
Developer Experience:
- Type-safe interfaces and comprehensive error handling
- Extensive testing strategies for image optimization features
- Progressive Web App integration with offline caching
- Router integration for route-based image preloading
Production-Ready Features:
- Format detection with graceful fallbacks for older browsers
- Responsive image generation with breakpoint-aware sizing
- Error boundaries and fallback strategies for failed loads
- Performance monitoring with actionable optimization recommendations
Key Implementation Strategies:
- Start with services for centralized optimization logic and caching
- Use directives for reusable DOM manipulation and event handling
- Implement progressive enhancement to improve perceived performance
- Monitor performance metrics to validate optimization effectiveness
- Test thoroughly across different Angular versions and browser environments
The techniques demonstrated here scale from small Angular applications to enterprise-level projects. They provide a robust foundation for delivering optimized image experiences that adapt to user conditions while maintaining Angular’s development principles.
Modern web applications demand fast, responsive image loading regardless of device or network conditions. These advanced Angular image optimization strategies ensure your applications meet those expectations while leveraging Angular’s powerful ecosystem and maintaining excellent developer experience.
What Angular image optimization challenges have you encountered? Have you implemented similar services or directives, or found other creative solutions for image performance? Share your experiences and optimization techniques in the comments!
This content originally appeared on DEV Community and was authored by Hardi