Web APIs for Complex UI Patterns
| Problem | API |
|---|---|
| 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).
- 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.
- 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.
- Idle polling: timers waste CPU checking unchanged elements. E.g., a 100 ms timer schedules about 100 checks per target in ten idle seconds.
- Forced layout: alternating style changes and geometry reads can repeatedly recalculate layout. E.g., changing
width, then immediately callinggetBoundingClientRect().
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.
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.
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();
hellotriggers a text-change report but fails the command check, so nothing is replaced.- Editing a text node's
.dataischaracterData; setting an element'stextContentreplaces children and ischildList. - Replacing the text with a heading does not loop here:
childListis 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.
0 comments · Not liked ♡
Recent changes · newest firstNo 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:
| Approach | Performance / cost | JS callback | Element-size tracking | Use when |
|---|---|---|---|---|
| CSS media queries | Browser-managed styling; no JS measurement loop | No, in CSS itself | No: width queries use the viewport | Styles depend on viewport size |
| CSS container queries | Browser-managed styling; no JS measurement loop | No, in CSS itself | Yes: a container controls descendant styles | Styles depend on a component's available space |
Window resize | Frequent handlers and manual measurements can be costly | Yes | No: window notification only | JavaScript needs to respond to window resizing |
| Timer + measurements | Repeats measurements even when nothing changes | Yes | Manual polling | A fallback must detect changes without a suitable event |
| ResizeObserver | Removes manual polling; callbacks and redraws still cost time | Yes | Yes | JavaScript 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-boxincludes padding and borders;content-boxexcludes them.- In horizontal writing,
inlineSizeis width andblockSizeis 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.
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.