import { Injectable, NgZone } from '@angular/core';
import { Observable, Subject } from 'rxjs';
export interface ObserverOptions {
rootMargin?: string;
threshold?: number | number[];
}
@Injectable({ providedIn: 'root' })
export class IntersectionObserverService {
private observers = new Map<string, IntersectionObserver>();
private subjects = new Map<Element, Subject<IntersectionObserverEntry>>();
constructor(private zone: NgZone) {}
get supported(): boolean {
return typeof window !== 'undefined' && 'IntersectionObserver' in window;
}
observe(el: Element, options: ObserverOptions = {}): Observable<IntersectionObserverEntry> {
const subject = new Subject<IntersectionObserverEntry>();
this.subjects.set(el, subject);
this.resolveObserver(options).observe(el);
return subject.asObservable();
}
unobserve(el: Element, options: ObserverOptions = {}): void {
this.resolveObserver(options).unobserve(el);
const subject = this.subjects.get(el);
if (subject) {
subject.complete();
this.subjects.delete(el);
}
}
private resolveObserver(options: ObserverOptions): IntersectionObserver {
const key = `${options.rootMargin ?? '0px'}|${options.threshold ?? 0}`;
let observer = this.observers.get(key);
if (!observer) {
observer = new IntersectionObserver(
entries => this.dispatch(entries),
{ rootMargin: options.rootMargin ?? '0px', threshold: options.threshold ?? 0 }
);
this.observers.set(key, observer);
}
return observer;
}
private dispatch(entries: IntersectionObserverEntry[]): void {
// Re-enter Angular so downstream template bindings are checked.
this.zone.run(() => {
for (const entry of entries) {
this.subjects.get(entry.target)?.next(entry);
}
});
}
}
import {
AfterViewInit, Directive, ElementRef, Input, OnDestroy, Renderer2
} from '@angular/core';
import { Subject } from 'rxjs';
import { filter, take, takeUntil } from 'rxjs/operators';
import { IntersectionObserverService } from './intersection-observer.service';
@Directive({ selector: 'img[appLazyImg]' })
export class LazyImgDirective implements AfterViewInit, OnDestroy {
@Input('appLazyImg') src!: string;
@Input() lazySrcset?: string;
@Input() rootMargin = '200px';
private destroy$ = new Subject<void>();
constructor(
private host: ElementRef<HTMLImageElement>,
private renderer: Renderer2,
private observerService: IntersectionObserverService
) {}
ngAfterViewInit(): void {
const el = this.host.nativeElement;
if (!this.observerService.supported) {
this.load(el);
return;
}
this.observerService
.observe(el, { rootMargin: this.rootMargin })
.pipe(
filter(entry => entry.isIntersecting),
take(1),
takeUntil(this.destroy$)
)
.subscribe(() => {
this.load(el);
this.observerService.unobserve(el, { rootMargin: this.rootMargin });
});
}
private load(el: HTMLImageElement): void {
if (this.lazySrcset) {
this.renderer.setAttribute(el, 'srcset', this.lazySrcset);
}
this.renderer.setAttribute(el, 'src', this.src);
this.renderer.addClass(el, 'is-loaded');
}
ngOnDestroy(): void {
this.observerService.unobserve(this.host.nativeElement, { rootMargin: this.rootMargin });
this.destroy$.next();
this.destroy$.complete();
}
}
<div class="gallery">
<figure *ngFor="let photo of photos" class="gallery__item">
<img
class="gallery__img"
width="400"
height="300"
src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0i...blur-placeholder"
[appLazyImg]="photo.url"
[lazySrcset]="photo.srcset"
rootMargin="300px"
[alt]="photo.caption"
/>
<figcaption>{{ photo.caption }}</figcaption>
</figure>
</div>
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
&__img {
width: 100%;
height: auto;
filter: blur(12px);
opacity: 0.6;
transition: filter 0.4s ease, opacity 0.4s ease;
&.is-loaded {
filter: none;
opacity: 1;
}
}
}
This snippet shows a reusable Angular attribute directive that defers loading image sources until each image actually scrolls into view, a common technique for reducing initial page weight and improving Largest Contentful Paint on image-heavy lists and grids.
The core idea in LazyImgDirective is to separate the placeholder from the real source. The element renders immediately with a lightweight src (a blur or spinner data URI), while the true URL is bound to a custom [appLazyImg] input and only assigned once the browser reports the element is visible. IntersectionObserver does the visibility bookkeeping natively and off the main layout thread, so no manual scroll listeners or getBoundingClientRect polling are needed.
Instead of every directive instance creating its own observer, IntersectionObserverService maintains a single shared observer per rootMargin/threshold configuration and maps each observed Element back to a Subject. This matters because thousands of individual observers are wasteful, whereas one observer watching many targets is what the API is designed for. Callers observe an element to get an Observable<IntersectionObserverEntry> and unobserve it to release the mapping.
In LazyImgDirective, ngAfterViewInit subscribes to that stream, filters on isIntersecting, and takes only the first hit with take(1) since an image never needs re-loading once fetched. On that event it uses Renderer2 to set the real src and srcset, adds a loaded class, and stops observing. Renderer2 is used rather than touching nativeElement.src directly so the directive stays compatible with server-side rendering and platform abstractions.
A rootMargin of 200px deliberately triggers loading slightly before the image enters view, hiding the network latency so pictures appear already decoded. The takeUntil(this.destroy$) pattern guarantees the subscription is torn down in ngOnDestroy, and the directive also calls unobserve to avoid leaking DOM references. One pitfall worth noting: environments without IntersectionObserver (older browsers, some test runners) need a polyfill or an eager fallback, which the service checks for. The HTML template tab shows the natural usage — a placeholder src plus the lazy binding — demonstrating how little boilerplate the consuming code requires.
Related snips
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Rails.application.configure do
config.after_initialize do
Bullet.enable = true
Bullet.alert = false
Bullet.bullet_logger = true
Bullet.console = true
N+1 query detection with Bullet gem
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
json.array! @posts do |post|
json.cache! ['v1', post], expires_in: 1.hour do
json.id post.id
json.title post.title
json.excerpt post.excerpt
json.published_at post.published_at
Fragment caching for expensive JSON serialization
package deps
import (
"net"
"net/http"
"time"
HTTP client tuned for production: timeouts, transport, and connection reuse
Share this code
Here's the card — post it anywhere.