JavaScript and TypeScript course Β· Module 5: Advanced JavaScript
Web APIs: Intersection Observer and Resize Observer
In this lesson8
Dr. Alan Grant stands before the panoramic window of the Jurassic Park control center. "Our monitors display hundreds of elements simultaneously - camera feeds, charts, sector maps," he says. "But loading everything at once kills performance. We need a way to react to what the user actually sees and how the page layout changes." The answer is Intersection Observer and Resize Observer - modern Web APIs for observing DOM elements.
The Problem: Manual Visibility Tracking
The traditional approach to checking whether an element is visible on screen requires listening to the scroll event and manually calculating positions:
1// Old approach - slow and complex
2window.addEventListener("scroll", () => {
3 const images = document.querySelectorAll("img[data-src]");
4
5 images.forEach(img => {
6 const rect = img.getBoundingClientRect();
7 const isVisible = rect.top < window.innerHeight && rect.bottom > 0;
8
9 if (isVisible) {
10 img.src = img.dataset.src; // Load the image
11 }
12 });
13});
14// Problem: the scroll event fires dozens of times per second!
15// getBoundingClientRect() may force a synchronous layout recalculation (reflow)The handler runs dozens of times per second and each time recalculates the positions of all images on the main thread, the same one that handles scrolling and clicks. Images that have already loaded keep being checked, because nobody removes their data-src attribute.
Intersection Observer - Observing Visibility
IntersectionObserver lets you asynchronously observe when an element enters or leaves the visible area (the viewport) or another element. It is a sensor on the edge of the enclosure: you don't stare at the monitor nonstop, you get a signal when something crosses the line:
1// Creating an observer
2const observer = new IntersectionObserver((entries) => {
3 entries.forEach(entry => {
4 // entry.isIntersecting - whether the element is visible
5 // entry.intersectionRatio - what fraction of the element is visible (0-1)
6 // entry.target - the observed DOM element
7
8 if (entry.isIntersecting) {
9 console.log(`Element ${entry.target.id} is now visible!`);
10 console.log(`Visibility: ${(entry.intersectionRatio * 100).toFixed(0)}%`);
11 }
12 });
13}, {
14 // Configuration options
15 root: null, // null = browser viewport
16 rootMargin: "0px", // margin around root (like CSS margin)
17 threshold: 0.5 // callback when 50% of element is visible
18});
19
20// Start observing
21const dinoCard = document.querySelector("#dino-card");
22observer.observe(dinoCard);
23
24// Stop observing
25// observer.unobserve(dinoCard);
26// observer.disconnect(); // Stops all observationsroot is the reference area, rootMargin grows or shrinks it like a CSS margin, and threshold says how much of the element must be visible for the signal to fire. The function receives an array of entries, because one observer watches many elements. It also fires once right after observe(), with the current state - which is why you always check isIntersecting.
Lazy Loading Images
The most popular use of Intersection Observer is lazy loading - loading images only when the user scrolls to them:
1class LazyImageLoader {
2 constructor(options = {}) {
3 this.observer = new IntersectionObserver(
4 (entries) => this.handleIntersection(entries),
5 {
6 rootMargin: options.rootMargin || "200px", // Load 200px before entering view
7 threshold: 0
8 }
9 );
10 }
11
12 handleIntersection(entries) {
13 entries.forEach(entry => {
14 if (entry.isIntersecting) {
15 const img = entry.target;
16
17 // Replace placeholder with the real image
18 img.src = img.dataset.src;
19 img.classList.add("loaded");
20
21 // Stop observing the loaded image
22 this.observer.unobserve(img);
23
24 console.log(`Loaded image: ${img.dataset.src}`);
25 }
26 });
27 }
28
29 observe(selector) {
30 const images = document.querySelectorAll(selector);
31 images.forEach(img => this.observer.observe(img));
32 console.log(`Observing ${images.length} images`);
33 }
34
35 destroy() {
36 this.observer.disconnect();
37 }
38}
39
40// Usage
41const loader = new LazyImageLoader({ rootMargin: "300px" });
42loader.observe("img[data-src]");The default rootMargin makes an image start loading 200 pixels before it enters the screen, and in the usage example even 300. After loading, unobserve() detaches the image, so the observer doesn't work for nothing. For ordinary images the HTML attribute loading="lazy" is enough today - use the observer when you need your own logic.
Infinite Scroll
Intersection Observer is great for implementing infinite scrolling. Instead of observing every card, we watch a single invisible marker at the end of the list, the sentinel:
1class InfiniteScroll {
2 constructor(containerSelector, loadMore) {
3 this.container = document.querySelector(containerSelector);
4 this.loadMore = loadMore;
5 this.isLoading = false;
6 this.page = 1;
7
8 // Create a "sentinel" element at the end of the list
9 this.sentinel = document.createElement("div");
10 this.sentinel.className = "scroll-sentinel";
11 this.container.appendChild(this.sentinel);
12
13 // Observe the sentinel
14 this.observer = new IntersectionObserver(
15 (entries) => {
16 if (entries[0].isIntersecting && !this.isLoading) {
17 this.loadNextPage();
18 }
19 },
20 { rootMargin: "100px" }
21 );
22
23 this.observer.observe(this.sentinel);
24 }
25
26 async loadNextPage() {
27 this.isLoading = true;
28 console.log(`Loading page ${this.page}...`);
29
30 try {
31 const items = await this.loadMore(this.page);
32
33 items.forEach(item => {
34 const element = document.createElement("div");
35 element.className = "dino-card";
36 element.textContent = `${item.name} (${item.species})`;
37 // Insert BEFORE the sentinel
38 this.container.insertBefore(element, this.sentinel);
39 });
40
41 this.page++;
42 } catch (error) {
43 console.error("Loading error:", error);
44 } finally {
45 this.isLoading = false;
46 }
47 }
48}When the sentinel gets close to the screen, we load the next page and insert the cards before it, so it always stays at the end. The isLoading flag prevents loading the same page twice, and finally clears it even after an error. Thanks to textContent instead of innerHTML, text from the server is never treated as HTML code.
Data Validation and Tests
Data from loadMore() comes from outside, so before it reaches the page, run it through a validation pipeline:
- Schema validation - is the response an array of objects with
nameandspeciesfields - Type validation - are those fields strings
- Business rule validation - does the species exist in the park registry
- Sanitization - cleaning the data for safety before it is displayed
Code with observers is tested in layers: the jsdom environment has no IntersectionObserver, so in unit tests you replace it with a mock, integration tests check how the classes work together, and real scrolling is verified by end-to-end tests in a browser and by performance tests.
Resize Observer - Observing Size Changes
ResizeObserver notifies you when an element changes its dimensions. It is invaluable for responsive components, and the usage pattern is the same as above:
1// Creating a Resize Observer
2const resizeObserver = new ResizeObserver((entries) => {
3 entries.forEach(entry => {
4 const { width, height } = entry.contentRect;
5 console.log(`Element ${entry.target.id} changed size: ${width}x${height}`);
6 });
7});
8
9// Observe an element
10const mapContainer = document.querySelector("#park-map");
11resizeObserver.observe(mapContainer);contentRect describes the content area, without the border and padding. It is an older field kept for compatibility - the newer contentBoxSize and borderBoxSize are more precise. Unlike the window's resize event, the observer reacts to a change in the size of a specific element, whatever its cause.
Responsive Component with Resize Observer
A size observer lets a grid of cards match its number of columns to its own width rather than the width of the whole window:
1class ResponsiveDinoGrid {
2 constructor(containerSelector) {
3 this.container = document.querySelector(containerSelector);
4 this.currentLayout = null;
5
6 this.observer = new ResizeObserver((entries) => {
7 const entry = entries[0];
8 const width = entry.contentRect.width;
9 this.updateLayout(width);
10 });
11
12 this.observer.observe(this.container);
13 }
14
15 updateLayout(width) {
16 let newLayout;
17
18 if (width < 400) {
19 newLayout = "single-column";
20 this.container.style.gridTemplateColumns = "1fr";
21 } else if (width < 800) {
22 newLayout = "two-columns";
23 this.container.style.gridTemplateColumns = "1fr 1fr";
24 } else {
25 newLayout = "multi-column";
26 this.container.style.gridTemplateColumns = "repeat(auto-fill, minmax(250px, 1fr))";
27 }
28
29 if (newLayout !== this.currentLayout) {
30 this.currentLayout = newLayout;
31 console.log(`Layout changed to: ${newLayout} (width: ${width}px)`);
32 }
33 }
34
35 destroy() {
36 this.observer.disconnect();
37 }
38}The grid switches layouts at 400 and 800 pixels, and it prints a message only when the layout actually changes. When it is only about appearance, consider CSS container queries, and keep the observer for logic that CSS cannot express.
Combining Both Observers
The two observers complement each other nicely. The monitoring panel turns on only the visible cameras and matches the image quality to the size of each tile:
1// Jurassic Park monitoring panel system
2class MonitoringPanel {
3 constructor(panelSelector) {
4 this.panel = document.querySelector(panelSelector);
5 this.cameras = [];
6
7 // Intersection Observer - enable/disable cameras based on visibility
8 this.visibilityObserver = new IntersectionObserver(
9 (entries) => this.handleVisibility(entries),
10 { threshold: 0.1 }
11 );
12
13 // Resize Observer - adjust image quality based on panel size
14 this.resizeObserver = new ResizeObserver(
15 (entries) => this.handleResize(entries)
16 );
17 }
18
19 addCamera(cameraElement) {
20 this.cameras.push(cameraElement);
21 this.visibilityObserver.observe(cameraElement);
22 this.resizeObserver.observe(cameraElement);
23 }
24
25 handleVisibility(entries) {
26 entries.forEach(entry => {
27 const cameraId = entry.target.dataset.cameraId;
28
29 if (entry.isIntersecting) {
30 console.log(`Camera ${cameraId}: ACTIVE (visible)`);
31 entry.target.classList.add("active");
32 } else {
33 console.log(`Camera ${cameraId}: PAUSED (out of view)`);
34 entry.target.classList.remove("active");
35 }
36 });
37 }
38
39 handleResize(entries) {
40 entries.forEach(entry => {
41 const { width } = entry.contentRect;
42 const cameraId = entry.target.dataset.cameraId;
43
44 let quality;
45 if (width < 200) quality = "low";
46 else if (width < 500) quality = "medium";
47 else quality = "high";
48
49 entry.target.dataset.quality = quality;
50 console.log(`Camera ${cameraId}: quality = ${quality} (width: ${width}px)`);
51 });
52 }
53
54 destroy() {
55 this.visibilityObserver.disconnect();
56 this.resizeObserver.disconnect();
57 }
58}Each camera is watched by both observers at once, and destroy() detaches them with disconnect(). Without it, the observers would keep working even though the panel is long gone.
Summary
Dr. Grant sums up: "Observers are like our sensor system in the park - we don't have to manually check every camera all the time:"
- IntersectionObserver - reacts when an element enters or exits the visible area
- Lazy loading - load images/content only when the user scrolls to them
- Infinite scroll - automatically load more data as the user scrolls
- ResizeObserver - reacts to changes in DOM element dimensions
- Responsive components - adjust layout without media queries
Both APIs are asynchronous and far more efficient than manually listening to scroll or resize events. The browser calculates intersections and sizes itself and calls your callback only when something changes. The callback itself still runs on the main thread, though, so it should be short. You will meet observers again in the location about lazy loading images, routes and components, and in the lab below you can try them out right away.
Remember: observers are sensors on the edge of the enclosure - they speak up only when something really crosses the line.
Code for this lesson: index.js
1// Web APIs: Intersection Observer and Resize Observer
2// Jurassic Park - Monitoring system
3
4console.log("=== INTERSECTION OBSERVER ===");
5
6// Simulated IntersectionObserver (in the browser it works with the DOM)
7// Showing the concept - what the API looks like
8
9// Creating the observer
10// const observer = new IntersectionObserver((entries) => {
11// entries.forEach(entry => {
12// if (entry.isIntersecting) {
13// console.log("Element visible:", entry.target.id);
14// }
15// });
16// }, {
17// root: null, // the browser viewport
18// rootMargin: "0px", // margin
19// threshold: 0.5 // 50% visibility
20// });
21
22// === LAZY LOADING ===
23console.log("\n=== LAZY LOADING (concept) ===");
24
25class LazyImageLoader {
26 constructor(rootMargin = "200px") {
27 this.loadedImages = [];
28 this.rootMargin = rootMargin;
29 // In the browser: this.observer = new IntersectionObserver(...)
30 }
31
32 // Simulated image loading
33 loadImage(imageData) {
34 console.log(`Loading image: ${imageData.src}`);
35 this.loadedImages.push(imageData.src);
36 return { ...imageData, loaded: true };
37 }
38
39 getStats() {
40 return {
41 loaded: this.loadedImages.length,
42 images: this.loadedImages
43 };
44 }
45}
46
47const loader = new LazyImageLoader("300px");
48loader.loadImage({ src: "/cameras/sector-a.jpg", alt: "Sector A" });
49loader.loadImage({ src: "/cameras/sector-b.jpg", alt: "Sector B" });
50console.log("Statistics:", loader.getStats());
51
52// === INFINITE SCROLL ===
53console.log("\n=== INFINITE SCROLL (simulation) ===");
54
55class InfiniteScroll {
56 constructor(loadMore) {
57 this.loadMore = loadMore;
58 this.page = 0;
59 this.items = [];
60 this.isLoading = false;
61 }
62
63 async loadNextPage() {
64 if (this.isLoading) return;
65 this.isLoading = true;
66 this.page++;
67 console.log(`Loading page ${this.page}...`);
68
69 const newItems = await this.loadMore(this.page);
70 this.items.push(...newItems);
71 this.isLoading = false;
72 return newItems;
73 }
74
75 getAll() {
76 return [...this.items];
77 }
78}
79
80// Simulated API
81const fetchDinosaurs = (page) => {
82 return Promise.resolve([
83 { name: `Dino-${page}A`, species: "T-Rex" },
84 { name: `Dino-${page}B`, species: "Velociraptor" },
85 { name: `Dino-${page}C`, species: "Triceratops" },
86 ]);
87};
88
89const scroll = new InfiniteScroll(fetchDinosaurs);
90
91// Loading pages
92async function demo() {
93 await scroll.loadNextPage();
94 await scroll.loadNextPage();
95 await scroll.loadNextPage();
96 console.log("All loaded:", scroll.getAll().length, "dinosaurs");
97 console.log("Items:", scroll.getAll().map(d => d.name));
98}
99
100demo();
101
102// === RESIZE OBSERVER ===
103console.log("\n=== RESIZE OBSERVER (concept) ===");
104
105class ResponsiveDinoGrid {
106 constructor() {
107 this.currentLayout = null;
108 }
109
110 updateLayout(width) {
111 let newLayout;
112 if (width < 400) newLayout = "single-column";
113 else if (width < 800) newLayout = "two-columns";
114 else newLayout = "multi-column";
115
116 if (newLayout !== this.currentLayout) {
117 this.currentLayout = newLayout;
118 console.log(`Layout: ${newLayout} (width: ${width}px)`);
119 }
120 return newLayout;
121 }
122}
123
124const grid = new ResponsiveDinoGrid();
125grid.updateLayout(300); // single-column
126grid.updateLayout(600); // two-columns
127grid.updateLayout(1200); // multi-column
128
129// TODO: Create a class MonitoringPanel
130// that combines IntersectionObserver (turning cameras on)
131// with ResizeObserver (adjusting the quality)
132console.log("\n=== Exercise ===");
133console.log("Create the MonitoringPanel - see the TODO in the code");Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What is IntersectionObserver used for in JavaScript?
2. Why is IntersectionObserver better than listening to the scroll event for implementing lazy loading?
Hands-on tasks in the game
- Code editor
Implement lazy loading of images with IntersectionObserver and a responsive grid with ResizeObserver.
- Horizontal ordering
Arrange the elements for creating and using a ResizeObserver:
- Vertical ordering
Order the application testing stages from unit to system level:
- Vertical ordering
Order the data validation pipeline: