Mert Özen Further With Every Line
Frontend JavaScript Advanced — #12

JavaScript Observer APIs: Intersection, Mutation and Resize Observer

Mert Özen Nov 3, 2026 9 min 6 views
JavaScript Observer APIs: Intersection, Mutation and Resize Observer

We build lazy loading and infinite scroll with IntersectionObserver, watch DOM changes with MutationObserver and element size with ResizeObserver, and learn how to clean observers up properly.

Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).

Predict First

In the code below we never scroll the page and no element moves. We just create an observer and ask it to watch document.body:

const observer = new IntersectionObserver((entries) => {
  console.log("callback ran:", entries.length);
});

observer.observe(document.body);
console.log("observe called");

What does this code print to the console? Does the callback run at all, and if it does, before or after which line? Make a guess before you keep reading. We'll check the answer together once we've met IntersectionObserver.

Getting Told Instead of Asking

The Web Workers we saw earlier took heavy computation off the main thread and moved it to another one. Observers deal with a different problem: work the main thread does for nothing. The classic way to find out whether an image has entered the screen was to listen for the scroll event and measure its position with getBoundingClientRect() every time. An event that fires dozens of times per second while scrolling, plus a measurement that forces layout on each one. It's the textbook case of the layout thrashing we discussed in the performance post.

Think of it like waiting for a delivery. Opening the door every five minutes to check whether the package has arrived is tiring and pointless. Telling the doorman "ring me when it arrives" is far smarter. Observer APIs are that doorman. You tell the browser what you want to watch; it already computes that information during its layout and paint steps, and it calls you when something changes.

The three observers are used in very similar ways. You create the observer with a callback, give it a target with observe(), and cut the link with disconnect() when you're done. The callback receives a list of records rather than a single one, because the browser collects changes and delivers them in batches. Once you know one of them well, the other two fall into place.

IntersectionObserver: Is It on Screen?

IntersectionObserver tells you whether an element intersects the viewport, or a scrollable container you choose. Its best-known use is lazy loading: loading an image only when the user gets close to it. Save the file below, put any two images named photo-1.jpg and photo-2.jpg next to it, and open it in your browser:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Lazy load</title>
  <style>
    .spacer { height: 150vh; }
    img { display: block; width: 300px; height: 200px; margin: 24px auto; background: #ddd; }
  </style>
</head>
<body>
  <p>Scroll down.</p>
  <div class="spacer"></div>
  <img data-src="photo-1.jpg" alt="First photo">
  <img data-src="photo-2.jpg" alt="Second photo">
  <script>
    const observer = new IntersectionObserver((entries, obs) => {
      for (const entry of entries) {
        if (!entry.isIntersecting) continue;
        const img = entry.target;
        img.src = img.dataset.src;
        obs.unobserve(img);
        console.log("Loaded:", img.alt);
      }
    }, { rootMargin: "200px" });

    document.querySelectorAll("img[data-src]").forEach((img) => observer.observe(img));
  </script>
</body>
</html>

Keep the Network tab open in DevTools while you scroll. The image requests don't go out when the page loads; they go out as the images approach the screen. The real address waits in data-src; when the element enters the visible area we move it into src and stop watching that image, because this only needs to happen once.

Let's look at the options in the second argument. root decides what the intersection is calculated against; its default is null, meaning the viewport. rootMargin grows or shrinks that area just like a CSS margin: with "200px" we hear about the image 200 pixels before it reaches the screen, so by the time the user gets there it has already arrived. threshold says how much of the element must be visible. The default 0 means a single pixel is enough. 0.5 means half, 1 means all of it; pass an array like [0, 0.25, 0.5, 0.75, 1] and you get a separate notification at each step.

Every entry passed to the callback has useful fields: target is the watched element, isIntersecting tells you whether it intersects right now, intersectionRatio is the visible fraction, and boundingClientRect is the element's position. The browser has done the measuring for you.

Now back to the opening question. The callback does run, without anything being scrolled. When you call observe(), the browser computes the target's current state and reports it once. That report arrives asynchronously, so observe called is printed first, followed by callback ran: 1. This is exactly why the lazy-load example checks isIntersecting: without it, we'd think an image far below the screen had "appeared" and load it right away.

A quick note: if all you want is to defer images, HTML's own loading="lazy" attribute does it without any JavaScript. Use IntersectionObserver when you want to control the loading logic yourself, or for jobs that aren't about images: counting whether an ad was really seen, animating a card in as it enters the screen, pausing a video that is out of view.

Infinite Scroll: A Sentinel at the End of the List

For infinite scroll you don't need to watch every item in the list. You put an empty sentinel element at the very bottom; when the sentinel becomes visible, you load the next page. A complete example:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Infinite scroll</title>
  <style>
    li { padding: 24px; border-bottom: 1px solid #ccc; }
    #sentinel { padding: 16px; text-align: center; }
  </style>
</head>
<body>
  <ul id="list"></ul>
  <div id="sentinel">Loading...</div>
  <script>
    const list = document.getElementById("list");
    const sentinel = document.getElementById("sentinel");
    let page = 0;
    let loading = false;

    async function nextPage() {
      loading = true;
      await new Promise((resolve) => setTimeout(resolve, 500)); // fake network delay
      for (let i = 1; i <= 20; i++) {
        const li = document.createElement("li");
        li.textContent = `Item ${page * 20 + i}`;
        list.append(li);
      }
      page++;
      loading = false;

      if (page === 5) {
        observer.disconnect();
        sentinel.textContent = "That's all.";
      }
    }

    const observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting && !loading) nextPage();
    });

    observer.observe(sentinel);
  </script>
</body>
</html>

The sentinel is visible as soon as the page opens, so the first 20 items arrive immediately. As you scroll down, the sentinel enters the screen again and a new page is appended. The loading flag stops the same page from being requested twice while a request is in flight. On the fifth page the data runs out, so we shut the observer down completely. In a real application the fake delay would be replaced by the fetch call we saw earlier.

MutationObserver: Did the DOM Change?

MutationObserver watches for changes in the DOM tree: children being added or removed, attribute changes, text changes. It's a perfect fit for noticing that code you didn't write has touched the DOM, giving behavior to elements that are added later, or saving a draft whenever the content of an editor changes. Paste this into your browser console and try it:

const list = document.createElement("ul");
document.body.append(list);

const observer = new MutationObserver((records) => {
  console.log("Record count:", records.length);
  for (const r of records) {
    console.log(r.type, r.addedNodes.length, r.attributeName);
  }
});

observer.observe(list, { childList: true, attributes: true, subtree: true });

for (let i = 0; i < 3; i++) {
  list.append(document.createElement("li"));
}
list.setAttribute("data-state", "full");
console.log("Loop done");

Loop done is printed first, then the callback runs once and brings four records: three childList and one attributes. Four separate changes happened, yet the callback wasn't called four times. MutationObserver collects records and runs the callback as a microtask. If you remember the event loop post, microtasks are processed right after the current synchronous code finishes and before the screen is painted. So you get all the changes in a single batch and the user never sees the intermediate states.

The second argument of observe() says what to watch, and you must pick at least one of: childList (adding/removing direct children), attributes, or characterData (the content of a text node). If you pass none of them you get a TypeError. subtree: true extends the watch to the whole subtree. attributeFilter: ["class", "hidden"] watches only specific attributes, and attributeOldValue: true adds the previous value to each record as oldValue. In the records you'll find the type, target, addedNodes, removedNodes and attributeName fields.

ResizeObserver: Did the Size Change?

The resize event on window only reports the window size. But an element can grow or shrink while the window stays exactly the same: a side menu opens, content loads into it, the layout changes. ResizeObserver gives you size information per element. In the file below, grab the box by its bottom-right corner and resize it:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>ResizeObserver</title>
  <style>
    #box { width: 400px; height: 150px; padding: 12px; border: 2px solid #333; resize: both; overflow: auto; }
    #box.narrow { background: #fde68a; }
  </style>
</head>
<body>
  <div id="box">Grab the bottom-right corner to resize.</div>
  <p id="info"></p>
  <script>
    const box = document.getElementById("box");
    const info = document.getElementById("info");

    const observer = new ResizeObserver((entries) => {
      for (const entry of entries) {
        const content = entry.contentBoxSize[0].inlineSize;
        const withBorder = entry.borderBoxSize[0].inlineSize;
        info.textContent = `Content: ${Math.round(content)}px, with border: ${Math.round(withBorder)}px`;
        entry.target.classList.toggle("narrow", content < 300);
      }
    });

    observer.observe(box);
  </script>
</body>
</html>

The info line fills in as soon as the page opens, because ResizeObserver, like IntersectionObserver, reports the initial state right away. contentBoxSize gives you the content area without padding and border, while borderBoxSize gives you the box including the border. Both are arrays, because an element can be split into several fragments in a multi-column layout; in everyday use [0] is enough. inlineSize means width in horizontally written languages and blockSize means height. The contentRect you'll see in older examples still works, but these two fields are clearer in new code.

Cleaning Up Observers

In the memory management post we saw how forgotten listeners and timers turn into leaks. Observers belong to the same family. As long as an observer is running, its callback and everything that callback holds through its closure stay alive. Especially if you're watching a permanent target like document.body, the callback keeps running even after the part of the UI you cared about has long been removed.

There are three simple rules. For one-off jobs, like lazy loading, release the target with unobserve(target) once the job is done. When a section of the page is removed, shut down the observer you created for it with disconnect(). MutationObserver has no unobserve; it's all or nothing, so you use disconnect(). If you need records that haven't reached the callback yet before you disconnect, takeRecords() hands them to you:

function watchSize(element, handle) {
  const observer = new ResizeObserver((entries) => handle(entries[0]));
  observer.observe(element);
  return () => observer.disconnect(); // cleanup function
}

const stop = watchSize(document.body, (entry) => {
  console.log("Width:", entry.contentBoxSize[0].inlineSize);
});

// When this section is removed from the page:
stop();

Having the setup function return a cleanup function is a good habit. Whoever sets up the observer also hands over the instructions for tearing it down, so nobody forgets.

Common Pitfalls

The most common mistake is not accounting for the initial notification. IntersectionObserver and ResizeObserver are called once at the moment of observe(); if your callback doesn't check isIntersecting or the actual size, you end up reacting to something that never happened.

The second is changing the DOM you're watching inside a MutationObserver callback. Add a child in the callback and that produces a new record, the callback runs again, adds another one. Either do the work once behind a condition, or call disconnect() before the change and observe() again afterwards.

ResizeObserver has a similar trap: if you change the size of the same element inside the callback, you'll see "ResizeObserver loop completed with undelivered notifications" in the console. To avoid an infinite loop, the browser postpones those notifications to the next frame. Change only colors, classes or content based on the size; if you do touch the size itself, make sure it can't create a loop.

In infinite scroll, if the sentinel is still visible after new items have been added, on a very tall screen for example, no new notification arrives. IntersectionObserver reports when the state changes, and "it was visible, it's still visible" isn't a change. One fix is to call observer.unobserve(sentinel) when loading finishes, immediately followed by observer.observe(sentinel); that gives you a fresh initial notification.

If you set threshold: 1 and watch an element taller than the viewport, the callback never arrives when you expect it, because that element can never be fully visible. Pick a smaller threshold for long content. Finally, opening a MutationObserver on document.body with subtree: true and watching everything is expensive on large pages; keep the target narrow and use attributeFilter when you can.

A Small Experiment

★☆☆ In the lazy-load example, set rootMargin first to "0px" and then to "600px". Watch when the image requests go out in the Network tab and explain the difference in your own words.

★★☆ Predict the output before running it: create a MutationObserver that watches a div with { attributes: true, attributeOldValue: true }. Then, in a loop, set div.dataset.color to "a", "b" and "c" in turn. How many times does the callback run, how many records arrive, and what are the oldValue values?

★★★ Build a reading indicator: watch every h2 in a long article with IntersectionObserver and show the heading of the section currently on screen in a fixed bar at the top of the page. Add a button that removes the article from the DOM and cleans up the observer when clicked.

With observers, we got the browser to tell us what's happening on the page. In the next post we turn to data that comes from outside the page: files the user picks or drags in, Blobs, and binary data read byte by byte.