Skip to main content

Web APIs for Complex UI Patterns

ProblemAPI
Detect when an element enters or leaves the viewport or a scroll container—for example, to load more posts near the bottom of a feed.IntersectionObserver
Detect DOM changes, such as edited text, changed attributes, or added and removed nodes.MutationObserver
Detect when an element changes size, even if the browser window does not resize—for example, when a sidebar makes a chart narrower.ResizeObserver

IntersectionObserver: visibility​

Before: scroll listeners or polling​

const marker = document.querySelector('#bottom-marker');

function checkPosition() {
// DOM element method: reads its current position and size relative to the viewport.
const rect = marker.getBoundingClientRect();
// Check vertical overlap with the viewport (does not check left/right overlap).
console.log(
rect.top < window.innerHeight && // Element's top is above the viewport's bottom.
rect.bottom > 0 // Element's bottom is below the viewport's top.
);
}

// Alternative 1: check on scroll.
window.addEventListener('scroll', checkPosition);
// Alternative 2: const timer = setInterval(checkPosition, 100);
// Cleanup: remove the listener or clearInterval(timer).
  1. Incomplete visibility check: false positives can trigger unnecessary loading or rendering. E.g., an element off-screen to the right still passes the vertical check.
  2. Frequent scroll callbacks: repeated measurements and heavy handlers use main-thread time and can drop frames. E.g., checking 100 elements on every scroll event.
  3. Idle polling: timers waste CPU checking unchanged elements. E.g., a 100 ms timer schedules about 100 checks per target in ten idle seconds.
  4. Forced layout: alternating style changes and geometry reads can repeatedly recalculate layout. E.g., changing width, then immediately calling getBoundingClientRect().

With IntersectionObserver​

Example: Show a loading indicator when at least half of a bottom marker enters the viewport. Run the script after the HTML.

<div style="height: 120vh">Scroll past the posts ↓</div>
<div id="bottom-marker" style="height: 160px; border: 4px solid gold;">Bottom marker</div>
<p id="loading-status" role="status">Waiting…</p>
const marker = document.getElementById('bottom-marker');
const status = document.getElementById('loading-status');

const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const loading = entry.isIntersecting && entry.intersectionRatio >= 0.5;
status.textContent = loading ? 'Loading…' : 'Waiting…';
// If loading is true, fetch more posts from an API or perform another action.
// Before fetching, check that no request is in progress and more pages exist.
}
},
{ root: null, threshold: 0.5 } // Viewport; report crossings of 50% overlap.
);

observer.observe(marker);
// On cleanup: observer.disconnect();

This replaces repeated position checks. Reports are asynchronous; a real feed also needs a loading guard and a check for remaining pages.

Instagram-style demo​

This demo displays loading above 50% overlap; it does not fetch posts.

Scroll and watch the yellow element.

A miniature social feed · loading above 50%

The yellow element below the posts is watched by IntersectionObserver. Scroll to the bottom. When more than 50% of this element is inside the feed, the demo shows loading.

little moments5 posts
slow.morningsMelbourne, Australia
#1
slow.mornings A little pause before the day begins.Demo post 1 · generated locally
weekend.wandererSomewhere outdoors
#2
weekend.wanderer Taking the long way home.Demo post 2 · generated locally
after.hoursCity walks
#3
after.hours One more walk, one more view.Demo post 3 · generated locally
slow.morningsMelbourne, Australia
#4
slow.mornings A little pause before the day begins.Demo post 4 · generated locally
weekend.wandererSomewhere outdoors
#5
weekend.wanderer Taking the long way home.Demo post 5 · generated locally
OBSERVED ELEMENTIntersectionObserver watches this elementMore than 50% in view → loading

0.0% inside the viewing area

intersectionRatio: 0.000 · isIntersecting: false

How the loading trigger works
// Frequent reports for the percentage display, including just above 50%.
const thresholds = [
...Array.from({length: 101}, (_, i) => i / 100),
0.500001,
].sort((a, b) => a - b);

const observer = new IntersectionObserver((entries) => {
const entry = entries[entries.length - 1];
setRatio(entry.intersectionRatio);
setLoading(entry.isIntersecting && entry.intersectionRatio > 0.5);
}, { root: scrollPanel, threshold: thresholds });

observer.observe(bottomElement);

// On cleanup: observer.disconnect();

Comparison​

IntersectionObserver removes repeated manual position checks and intersection calculations. The work you do after receiving a notification—such as rendering posts—still takes time. It improves detection, but does not make rendering faster.

Before: scroll listener or polling

With IntersectionObserver

Both paths still render 20 posts. IntersectionObserver replaces the manual detection work before that step.

MutationObserver: DOM changes​

Before: repeatedly compare DOM snapshots​

const card = document.querySelector('#card');
let previous = card.innerHTML;

const timer = setInterval(() => {
const current = card.innerHTML; // Serialize the subtree even if unchanged.
if (current !== previous) {
console.log('Card changed');
previous = current;
}
}, 100);

// Cleanup: clearInterval(timer);

Larger subtrees mean more repeated string creation and comparison. Changes reversed between checks can be missed. MutationObserver reports selected mutations instead. If you control the update, use your own callback, an input event for user editing, or React state when it already holds the information.

With MutationObserver​

Example: Convert the text h1, h2, or h3 into a heading. contenteditable lets the user type; the observer detects changes.

<div id="card">
<section contenteditable="true">hello</section>
</div>
const card = document.querySelector('#card');
const supportedTags = new Set(['h1', 'h2', 'h3']);

const observer = new MutationObserver((records) => {
for (const record of records) {
if (record.type !== 'characterData') continue;

const textNode = record.target;
const tag = textNode.data.trim().toLowerCase();

if (!supportedTags.has(tag)) continue;

const heading = document.createElement(tag);
heading.textContent = 'New heading';

textNode.replaceWith(heading);
}
});

observer.observe(card, {
characterData: true, // Report edits to existing text nodes.
subtree: true, // Include text nodes nested inside the card.
});

// On cleanup: observer.disconnect();
  • hello triggers a text-change report but fails the command check, so nothing is replaced.
  • Editing a text node's .data is characterData; setting an element's textContent replaces children and is childList.
  • Replacing the text with a heading does not loop here: childList is disabled. Pasting or replacing all content may also produce child-list changes, which this small example does not handle.

Other observation options:

observer.observe(card, {
attributes: true, // Attribute changes, such as class or style.
childList: true, // Child nodes added or removed.
characterData: true, // Existing text-node contents changed.
subtree: true, // Extend these types to all descendants.
});

Instagram-style demo​

Edit a post. Watch the DOM change.

Add a comment, edit the caption, or like the post. The yellow area is watched by MutationObserver.

little moments
slow.morningsMelbourne, Australia
OBSERVED ELEMENT · caption & comments

0 comments · Not liked ♡

Recent changes · newest first

No changes yet. Try the post controls.

See the observer code
const observer = new MutationObserver((records) => {
for (const record of records) {
console.log(record.type);
// childList: a comment was added or removed
// characterData: the caption's text node changed
// attributes: the post's data-liked attribute changed
}
});

observer.observe(captionAndComments, {
childList: true,
characterData: true,
attributes: true,
attributeFilter: ['data-liked'],
subtree: true,
});

// On cleanup: observer.disconnect();

ResizeObserver: element dimensions​

Before: measure on window resize​

const box = document.querySelector('.box');

function checkSize() {
// DOM element method: reads its current position and size relative to the viewport.
const { width, height } = box.getBoundingClientRect();
console.log(width, height); // Run application logic with these dimensions.
}

checkSize();
window.addEventListener('resize', checkSize);
// Cleanup: window.removeEventListener('resize', checkSize);

This provides a callback, but only window resizing triggers it. A sidebar or content change can resize the box without notifying this listener. Polling catches more causes but keeps measuring while idle. Debouncing reduces callback work at the cost of delayed updates; it does not add element tracking.

Which approach should you use?​

The course slide's comparison, with its performance claims made precise:

ApproachPerformance / costJS callbackElement-size trackingUse when
CSS media queriesBrowser-managed styling; no JS measurement loopNo, in CSS itselfNo: width queries use the viewportStyles depend on viewport size
CSS container queriesBrowser-managed styling; no JS measurement loopNo, in CSS itselfYes: a container controls descendant stylesStyles depend on a component's available space
Window resizeFrequent handlers and manual measurements can be costlyYesNo: window notification onlyJavaScript needs to respond to window resizing
Timer + measurementsRepeats measurements even when nothing changesYesManual pollingA fallback must detect changes without a suitable event
ResizeObserverRemoves manual polling; callbacks and redraws still cost timeYesYesJavaScript needs an element's dimensions
/* Viewport-based styling: no JavaScript callback. */
@media (max-width: 600px) {
.toolbar { flex-direction: column; }
}

/* Container-based styling: responds even if the window stays unchanged. */
.panel { container-type: inline-size; }
@container (max-width: 400px) {
.toolbar { flex-direction: column; }
}

The toolbar must be a descendant of the query container and use flex layout for flex-direction to apply. CSS queries are efficient styling tools, not inferior versions of ResizeObserver. A separate matchMedia() change listener can run JavaScript when a media condition switches, but does not measure individual elements. Container queries · matchMedia

The slide's “10× faster” is not a general guarantee. Window resize does not bubble; the potential cost comes from repeated handler work and measurements. Use ResizeObserver when JavaScript is needed; CSS alone is enough for style-only changes. Window resize behavior

With ResizeObserver​

Example: Round a box when both dimensions are below 150 pixels, even if the browser window stays the same size.

<div class="box">Resize me</div>
.box {
box-sizing: border-box;
width: 220px;
height: 180px;
min-width: 80px;
min-height: 80px;
padding: 8px;
border: 4px solid transparent;
overflow: auto;
resize: both;
}
const roundedState = new WeakMap();

const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const box = entry.target;
const { inlineSize, blockSize } = entry.borderBoxSize[0];
const shouldRound = inlineSize < 150 && blockSize < 150;

// Resizing can keep producing callbacks without changing this condition.
if (roundedState.get(box) === shouldRound) continue;
roundedState.set(box, shouldRound);

box.style.borderRadius = shouldRound ? '100%' : '0';
box.style.borderColor = shouldRound ? 'royalblue' : 'transparent';
}
});

for (const box of document.querySelectorAll('.box')) {
observer.observe(box, { box: 'border-box' });
}

// On cleanup: observer.disconnect();
  • border-box includes padding and borders; content-box excludes them.
  • In horizontal writing, inlineSize is width and blockSize is height. Vertical writing swaps those axes.
  • The saved state skips repeated style writes. A fixed border width avoids changing the measured size when its color changes.
  • Equal dimensions produce a circle; unequal dimensions produce an ellipse. Use CSS container queries if only styling is needed.

Instagram-style demo​

This demo reports the content-box dimensions while CSS adjusts the layout.

Resize the post. Watch its dimensions change.

Move the slider to resize this post without resizing the browser. ResizeObserver measures the yellow frame’s content box.

little moments
slow.morningsMelbourne, Australia
slow.mornings A little pause before the day begins.

Waiting for the first report…

contentRect.width: —

contentRect.height: —

The image scales and the caption wraps as the post narrows. Available space may limit its width; measurements exclude the yellow border.

See the observer code
const observer = new ResizeObserver(([entry]) => {
const {width, height} = entry.contentRect;
setSize({width, height});
});

observer.observe(postElement);

// CSS scales the image and wraps the caption.
// ResizeObserver reports the resulting content size.
// On cleanup: observer.disconnect();

Performance takeaway​

At a nominal 100 ms interval, polling schedules 100 checks in ten idle seconds per target. Observers remove that application polling, but do not eliminate browser work or guarantee faster callbacks. Compare equivalent behavior with browser performance measurements; there is no fixed speedup or automatic memory saving.