Observers
Learn the browser observer APIs for reacting to visibility, DOM mutations, element resizing, and performance entries without constant polling.
- 01Know when an element can be seenUse
IntersectionObserverwith a root, a threshold, and a root margin to load images late and to grow endless lists. - 02Read page changes in batchesUse
MutationObserveroptions and records, know when its callback runs, and stop it withtakeRecords()anddisconnect(). - 03Watch sizes and timingsUse
ResizeObserverfor one element's size andPerformanceObserverfor timing entries, and check what the browser supports first.
Watchers, not loops
Web pages often need to notice when something changes. For example:
- A card scrolls into view.
- A widget from another company adds a new element to the page.
- A box on the page gets wider.
- The browser writes down how long a task took.
You could check for these changes again and again with a timer. This is called polling. Polling wastes work, because most checks find nothing new. It can also check at the wrong moment.
The browser has a better tool: observers. You make an observer and give it a callback. A callback is a function you hand over, so the browser can call it later. Then you tell the observer what to watch. When the thing changes, the browser calls your callback. It passes a list of small objects that describe what changed. Some observers call these objects entries; one calls them records.
Each observer watches one kind of change.
An observer is a browser object that watches something for one kind of change. When the change happens, the browser calls your callback a little later, with entries or records that describe it.
This table lists the four observers in this lesson. Read one row per observer.
| Observer | What does it watch? | What does your callback get? | Where is it used? |
|---|---|---|---|
IntersectionObserver | Whether an element can be seen inside an area, such as the screen. | IntersectionObserverEntry objects. | Loading images late, showing items as you scroll, and endless lists. |
MutationObserver | Changes to elements: new or removed children, attributes, or text. | MutationRecord objects. | Watching widgets you didn't write, and tools such as text editors. |
ResizeObserver | Changes to the size of one element. | ResizeObserverEntry objects. | Parts of a page that change their layout by their own width, such as charts. |
PerformanceObserver | Timing records that the browser writes down. | PerformanceEntry objects. | Measuring how long your own code takes. |
The lesson builds each observer with a real lab. Before you choose one, say what kind of change you care about. Then pick the observer that watches that kind of change.
IntersectionObserver: is it on screen?
INTERACTIVEIntersectionObserver tells you when an element moves into or out of an area. The element you watch is the target. The area is the root.
- With
root: null, the root is the viewport: the part of the page you can see in the browser window. - You can also set
rootto a box on the page that scrolls. Then the root is that box.
"Intersect" means "overlap". The target intersects the root when some part of it is inside the root. Many sites use this to load images only when they come near the screen, to show items as you scroll, and to load more of a long list.
function shouldLoadImage(entry) {
return entry.isIntersecting ? "load image" : "wait";
}
console.log(shouldLoadImage({ isIntersecting: true }));Your callback gets one entry for each target that changed. An entry has two useful properties:
isIntersectingistruewhen any part of the target is inside the root. Use it for a simple yes-or-no choice.intersectionRatiois how much of the target is inside, from0(none) to1(all). Use it when the amount matters.
Line 2 returns "load image" when the entry is intersecting, and "wait" when it is not. Line 4 passes an entry with isIntersecting: true, so it prints load image.
intersectionRatio is 0.5. Any part inside makes isIntersecting true.A lifeguard watches one swimming area. She doesn't measure every swimmer every second. She only calls out when a swimmer she watches goes far enough into the area, or comes out of it. An IntersectionObserver does the same job for elements.
- In real life: The swimming area
- In JavaScript: The
root: the viewport or a scrolling box - In real life: A swimmer the lifeguard watches
- In JavaScript: A target element passed to
observe() - In real life: How much of the swimmer is inside the area
- In JavaScript:
intersectionRatio - In real life: Call out when half the swimmer is inside
- In JavaScript:
threshold: 0.5 - In real life: Move the rope to make the area bigger or smaller
- In JavaScript:
rootMargin, such as80px 0px
Three settings matter most:
thresholdsays how much of the target must be inside before your callback runs. It is a number from 0 to 1, or a list such as[0, 0.5, 1]. With a list, the callback runs each time the target passes one of those points.rootMarginmakes the root bigger or smaller, inpxor%. A bigger root lets you start loading a little before the target reaches the screen.- Right after you call
observe(), the browser schedules the callback once, before the user scrolls. That first entry tells you where the target is now.
The next example is for an endless list. At the bottom of the list sits a small, empty element. It is called a sentinel, which means a guard. When the sentinel comes into view, the user has reached the end, so it is time to load the next page of items.
function nextPage(entry, page, loading) {
if (!entry.isIntersecting || loading) return "wait";
return "load page " + (page + 1);
}
console.log(nextPage({ isIntersecting: true }, 1, false));Line 2 returns "wait" if the sentinel can't be seen, or if a page is already loading. The loading check matters. Without it, one visible sentinel could start several requests for the same page before the first one finishes. Line 5 passes a visible sentinel, page 1, and loading set to false, so it prints load page 2.
const observer = new IntersectionObserver(callback, { root: scrollBox, threshold: [0, 0.5, 1], rootMargin: "40px 0px",});observer.observe(card);observer.observe(sentinel);Scroll the box. The observer reports each card once right after observe(), then each time a card passes a threshold. Cards loaded: 0. Cards on the page: 8.
In the lab, each card starts as a grey placeholder. When a card comes into the scroll box, the code fills it with a colored block. The sentinel at the bottom adds more cards when it shows. That is an endless list: you watch one small marker instead of working out scroll positions yourself.
MutationObserver: changes to the page, in batches
STEP THROUGHA mutation is a change. MutationObserver watches an element, called the target node, and tells you when it changes. It can see three kinds of change:
- children were added or removed;
- an attribute changed, such as
classordata-state; - the text inside changed.
It does not replace normal events like click. If your own code makes the change, you already know about it. Use a MutationObserver when code you didn't write changes the page, or when a tool needs to check changes after they happen.
Each change becomes a record: a MutationRecord object that describes one change. A record for new children has an addedNodes list with the new elements in it.
function addedItemNames(records) {
return records
.flatMap((record) => [...record.addedNodes])
.map((item) => item.textContent);
}
console.log(addedItemNames([{ addedNodes: [{ textContent: "Tea" }] }]));Line 3 joins the addedNodes of every record into one list. Line 4 keeps only each item's text. Line 6 passes one record with one new item, Tea, so it prints ["Tea"]. In a real list, you may also want to skip things that are not elements.
Workers are fixing up a building. A clerk writes every change in a notebook. The clerk doesn't run to you after each nail. When the workers stop for a break, the clerk hands you the whole page at once.
- In real life: The building
- In JavaScript: The target node you observe
- In real life: A new room, a new sign, or a changed name plate
- In JavaScript: A child, attribute, or text change
- In real life: The lines in the clerk's notebook
- In JavaScript: The
MutationRecordobjects waiting in a list - In real life: The clerk hands you the page when the workers pause
- In JavaScript: One callback after the current script ends
When you call observe(), you pass an options object. It says which changes become records:
childList: children added or removed.attributes: attribute changes.characterData: text changes.subtree: watch the children's children too, all the way down, not only the target.attributeOldValue: also save the value the attribute had before the change.
You must turn on at least one of childList, attributes, or characterData. If you set attributeOldValue: true and leave out attributes, the browser turns attributes on for you. Text usually sits inside the target's children, so characterData often needs subtree: true to see it. Turn on only what you need: fewer options mean fewer records to read.
Here is the key timing rule. The observer doesn't call you after each change. It saves the records in a list while your script runs. When the script ends, it calls your callback once, with all of the records. The callback runs as a microtask: a small job the browser runs right after the current script ends, before anything else.
Guess the order first. Then step through this copy of how a MutationObserver batch works.
script
log.push("sync: start");const records = [];records.push("childList: added item");records.push("attributes: data-state old=closed");records.push("characterData: title changed");log.push("sync: end");log.push("callback: " + records.length + " records");console.log(log.join(" | "));const log = [];log.push("sync: start");const records = [];records.push("childList: added item");records.push("attributes: data-state old=closed");records.push("characterData: title changed");log.push("sync: end");log.push("callback: " + records.length + " records");console.log(log.join(" | "));Turn options on or off. Then make one change, or three changes at once.
You can learn more about this timing in the Microtasks in depth lesson. Two more methods help when you are done:
takeRecords()gives you the records that are still waiting, right now, and empties the list. The callback won't get those records.disconnect()stops the observer. It no longer watches any element, and the callback no longer runs.
A safe way to stop is: call const pending = observer.takeRecords(), handle those records, then call observer.disconnect(). That way, you don't lose changes without noticing.
ResizeObserver: the size of one element
INTERACTIVEA part of the page can get narrower even when the browser window stays the same size. For example:
- a sidebar opens next to it;
- its text wraps onto more lines;
- the grid it sits in changes;
- a font finishes loading.
None of these has to fire the window's resize event, because the window didn't change. ResizeObserver watches the element itself. The browser first works out the size and position of everything on the page; this step is called layout. Then it draws the pixels; this is called paint. ResizeObserver calls you after layout and before paint, so the size you read is up to date.
function layoutFor(width) {
return width < 360 ? "stacked" : "row";
}
console.log(layoutFor(320));Line 2 returns "stacked" when the width is less than 360, and "row" otherwise. Line 4 passes 320, so it prints stacked. A real component would read the width in the observer, then use this choice to set a class.
window gets no resize event. The card itself shrinks to 320px; ResizeObserver reports that, and layoutFor(320) gives "stacked".A good tailor measures the jacket, not the room. A ResizeObserver does the same: it watches the one element you care about, even when the window stays the same size.
- In real life: The jacket being fitted
- In JavaScript: The element you observe
- In real life: A new measurement
- In JavaScript:
contentRectorborderBoxSize - In real life: Measuring again after the person moves
- In JavaScript: A callback after the browser works out the layout again
const observer = new ResizeObserver((entries) => { for (const entry of entries) { console.log(entry.contentRect.width); console.log(entry.borderBoxSize?.[0]?.inlineSize); }});observer.observe(panel, { box: "border-box" });React state changes the class here. The observer callback doesn't.
Move the slider. ResizeObserver reports the element's new size after layout.
Each entry can give you the size in two ways:
contentRect.widthis the width of the content box: the inside area, without padding or border. Every browser that hasResizeObservergives you this.borderBoxSize[0].inlineSizeis the width of the border box: the content plus padding and border. It is newer, and it is a list, so you read the first item with[0].
By default, the observer watches the content box. You can pass { box: "border-box" } to observe() to watch the border box instead. There is also device-pixel-content-box, but not every browser has it, so check first. Pick the size that fits your layout choice.
In the callback, read the size, then set state or a class. Don't change the size of the element you are watching inside its own callback. That would cause another size change, then another callback, again and again.
PerformanceObserver: timing records from the browser
LABThe browser keeps a list of timing records called the Performance Timeline. Each record is an entry. There are several kinds, called entry types:
mark: a named moment in time that you save.measure: the time between two marks.resourceandnavigation: how long files and page loads took.- Some extra types that only some browsers have.
PerformanceObserver calls your callback when new entries are added. The easiest type to start with is measure, because you make it yourself. You call performance.mark() at the start and at the end of a task. Then performance.measure() makes one entry with the time between them.
A race timekeeper doesn't make the runner faster. She only writes down times on cards. A PerformanceObserver is the same: it reads timing data. It doesn't make your code faster by itself.
- In real life: The start and finish flags
- In JavaScript:
performance.mark()calls - In real life: A card with one runner's time
- In JavaScript: A
PerformanceEntry - In real life: Handing each card to the coach
- In JavaScript: The observer's callback gets the entries
- In real life: Cards written before the coach arrived
- In JavaScript:
buffered: true, when the browser supports it
performance.mark() makes the two marks. performance.measure() turns them into one entry, demo work, and the observer's callback gets it.const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { console.log(entry.name + ": " + entry.duration.toFixed(2) + "ms"); }});observer.observe({ type: "measure", buffered: true });performance.mark("work:start");// do a small taskperformance.mark("work:end");performance.measure("demo work", "work:start", "work:end");Entry types this browser has: Checking once the page loads...
Run the task. It makes two marks and one measure entry.
Not every browser has every entry type. For example, longtask, layout-shift, and largest-contentful-paint are mostly found in Chrome and browsers built on it. Checking what a browser has before you use it is called feature detection. Read PerformanceObserver.supportedEntryTypes once the page has loaded in the browser, and observe only the types in that list.
Which observer?
SORTThe names are long, but the choice is short. Ask one question: did something come into view, did the page change, did an element change size, or did the browser record a time? Sort each task below by its answer.
- Load a card's image when the card scrolls near the visible part of a list
- Add another page of items when a marker at the bottom comes into view
- Notice that a widget added a new child element
- Do something when the
aria-expandedattribute changes - Change a card's layout when its own width drops below 360px
- Measure an editor pane again after the user drags its edge
- Collect the times made by
performance.measure() - Check if the browser has Largest Contentful Paint entries, then watch them
Read each task. Ask what kind of change it waits for. Then pick Visibility, Page changes, Element size, or Timings.
Where you'll use this
Observers help most when the browser already knows the answer, and your code would only be guessing.
Use IntersectionObserver to:
- load images and videos just before they reach the screen;
- highlight a link in a table of contents when its heading comes into view;
- add more rows when a sentinel shows.
Clean up when a job is done. Once an image has loaded, call unobserve(image) to stop watching it, or disconnect() to stop watching everything.
Use MutationObserver where your code meets code you didn't write. Examples are a rich text editor, a widget from another company, or a script that fixes attributes after a library changes the page. Watch the smallest element you can, and turn on only the options you need. Watching the whole page with subtree: true can give you a flood of records.
Use ResizeObserver for parts of the page that change their layout based on their own width. It works well with the Sizes, scrolling & coordinates lesson. You still read real sizes, but the observer tells you when to read them.
Use PerformanceObserver to measure your own code with marks and measures. Use the extra entry types only after feature detection.
The next example counts each article as "seen" only once. It uses a Set: a collection that holds each value at most once. seen.has(id) asks if the id is already there, and seen.add(id) puts it in.
const seen = new Set();
function recordSeen(entry, id) {
if (entry.isIntersecting && !seen.has(id)) {
seen.add(id);
return "seen: " + id;
}
return "skip";
}
console.log(recordSeen({ isIntersecting: true }, "article-1"));Line 3 checks two things: the article can be seen, and its id is not in seen yet. If both are true, line 4 saves the id and line 5 returns "seen: article-1". If the same article leaves and comes back, its id is already in the set, so line 7 returns "skip". Line 9 prints seen: article-1.
For every observer you make, plan how it will stop. Stop it when the element is gone, when the part of the page that made it is removed, or when its one-time job is done.
Common misconceptions
- “Observers call my callback right away.” They don't. Each one calls it a little later.
IntersectionObservercalls it when the browser next checks positions.MutationObservercalls it as a microtask after the script ends.ResizeObservercalls it after layout.PerformanceObservercalls it after new entries are recorded. - “Threshold 1 means the target can be seen at all.” No. It means the whole target is inside the root. To react when any part comes in, use 0 or a small number.
- “MutationObserver is a click listener.” No. Use events for things the user does. Use mutation records for changes to the page, after they happen.
- “The window's resize event covers every size change.” No. A card can change size while the window stays the same.
- “Every performance entry type works in every browser.” No. Some types only exist in some browsers. Use feature detection.
- “Calling disconnect is optional.” Not on pages that stay open a long time. Without it, old callbacks can keep running, and old elements can stay in memory.
Practice exercises
5 EXERCISESRead the program and predict the number it prints.
function ratio(target, root) {
const right = Math.min(target.left + target.width, root.left + root.width);
const left = Math.max(target.left, root.left);
const bottom = Math.min(target.top + target.height, root.top + root.height);
const top = Math.max(target.top, root.top);
const area = Math.max(0, right - left) * Math.max(0, bottom - top);
return area / (target.width * target.height);
}
console.log(ratio({ left: 0, top: 50, width: 100, height: 100 }, { left: 0, top: 0, width: 100, height: 100 }));0.5The overlap is 100 wide and 50 tall, so 5,000 pixels can be seen. The target has 10,000 pixels. 5,000 divided by 10,000 is 0.5.
A MutationObserver callback runs as a microtask. queueMicrotask() schedules a function the same way, so this small program has the same order.
const log = [];
log.push("sync");
queueMicrotask(() => log.push("observer"));
log.push("done");
queueMicrotask(() => console.log(log.join(", ")));sync, done, observerThe script adds sync, schedules a microtask, and adds done. Then the first microtask adds observer, and the second one prints the joined list.
A callback reads entry.contentRect.width for panel. Then it sets panel.style.width right away. Should a callback change the size of the element it is watching? Answer yes or no.
const observer = new ResizeObserver((entries) => {
const width = entries[0].contentRect.width;
panel.classList.toggle("is-narrow", width < 360);
});Don't write panel.style.width = ... inside the observer that watches panel. Read the size, then set a class or state with care.
Read the code without running it. What is the entry type?
const entry = { name: "demo", entryType: "measure", duration: 4.567, startTime: 12.34 };
console.log(entry.entryType + " " + entry.name + ": " + entry.duration.toFixed(2) + "ms");measureThe program prints measure demo: 4.57ms. The line starts with the entry type, measure.
A dashboard has three parts. Pick an observer for each one:
- a chart that can change size;
- an activity feed that loads more items as you scroll;
- an ad slot where another company's code adds elements later.
// Chart panel
new ResizeObserver(updateChart).observe(chartPanel);
// More feed items
new IntersectionObserver(loadMore, { root: feed }).observe(sentinel);
// Ad slot from another company
new MutationObserver(auditAdSlot).observe(adSlot, { childList: true });The dashboard waits for three kinds of change, so it uses three observers. When each observer has one job, it is easier to clean up and to fix.
Check your understanding
8 QUESTIONSQuestion 1 of 8What does
root: nullmean for anIntersectionObserver?Choose an answer to see the explanation.
Question 2 of 8Which threshold makes the callback run when an element becomes at least half visible?
Choose an answer to see the explanation.
Question 3 of 8This code copies how a mutation batch works. What does it print?
Read the code, then predictconst log = []; log.push("sync: start"); const records = []; records.push("childList"); records.push("attributes"); log.push("sync: end"); log.push("callback: " + records.length); console.log(log.join(" | "));Choose an answer to see the explanation.
Question 4 of 8Which
MutationObservermethod gives you the waiting records right away and empties the list?Choose an answer to see the explanation.
Question 5 of 8When does a
ResizeObservercall its callback?Choose an answer to see the explanation.
Question 6 of 8Which box does a
ResizeObserverwatch if you don't choose one?Choose an answer to see the explanation.
Question 7 of 8What does this code print?
Read the code, then predictconst entry = { name: "demo", entryType: "measure", duration: 4.567, startTime: 12.34 }; console.log(entry.entryType + " " + entry.name + ": " + entry.duration.toFixed(2) + "ms at " + entry.startTime.toFixed(1) + "ms");Choose an answer to see the explanation.
Question 8 of 8Which
PerformanceObserverentry types should you check for first, because not every browser has them?Choose an answer to see the explanation.
Key takeaways
- Use an observer when the browser can tell you about a change. It does less work than checking again and again with a timer.
IntersectionObserverwatches whether a target is inside a root.thresholdcan be a list, androotMarginlets you start loading early.MutationObserversaves records of page changes and hands them over in one batch, as a microtask, after the script ends.ResizeObserverwatches one element's size, after layout and before paint. Don't resize the watched element inside its callback.PerformanceObserverwatches timing entries. ChecksupportedEntryTypesbefore you use the types that only some browsers have.
An observer is a browser watcher with one job: you say what counts as a change, start watching, handle the entries that arrive later, and disconnect when you are done.
Up next: Animation with JavaScript.