completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

Observers

Learn the browser observer APIs for reacting to visibility, DOM mutations, element resizing, and performance entries without constant polling.

You will be able to
  • 01
    Know when an element can be seenUse IntersectionObserver with a root, a threshold, and a root margin to load images late and to grow endless lists.
  • 02
    Read page changes in batchesUse MutationObserver options and records, know when its callback runs, and stop it with takeRecords() and disconnect().
  • 03
    Watch sizes and timingsUse ResizeObserver for one element's size and PerformanceObserver for 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.

Definition

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.

The four observers in this lesson, and what each one watches
ObserverWhat does it watch?What does your callback get?Where is it used?
IntersectionObserverWhether 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.
MutationObserverChanges to elements: new or removed children, attributes, or text.MutationRecord objects.Watching widgets you didn't write, and tools such as text editors.
ResizeObserverChanges to the size of one element.ResizeObserverEntry objects.Parts of a page that change their layout by their own width, such as charts.
PerformanceObserverTiming 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?

INTERACTIVE

IntersectionObserver 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 root to 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.

Load an image only when it can be seenPop out in the code editor (opens in a new tab)JavaScript
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:

  • isIntersecting is true when any part of the target is inside the root. Use it for a simple yes-or-no choice.
  • intersectionRatio is how much of the target is inside, from 0 (none) to 1 (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.

A card sits half inside the viewport and half below it. The observer reports intersectionRatio 0.5 and isIntersecting true.viewportcardinsideoutsideratio: 0.5isIntersecting: true
Half of the card is inside the viewport, so intersectionRatio is 0.5. Any part inside makes isIntersecting true.
Real-life analogyIntersectionObserver is a lifeguard

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 as 80px 0px

Three settings matter most:

  • threshold says 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.
  • rootMargin makes the root bigger or smaller, in px or %. 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.

Load the next page when the bottom marker showsPop out in the code editor (opens in a new tab)JavaScript
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.

Visibility lab: thresholds, root margin, late loading, and a sentinel
Watch cards inside a scroll boxPop out in the code editor (opens in a new tab)JavaScript
const observer = new IntersectionObserver(callback, {  root: scrollBox,  threshold: [0, 0.5, 1],  rootMargin: "40px 0px",});observer.observe(card);observer.observe(sentinel);
What the real observer reports0 log lines
    Try it yourself

    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.

    The scroll box is the root. The lab makes its cards with DOM methods inside an empty element, so React doesn't touch the cards the observer watches.

    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 THROUGH

    A 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 class or data-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.

    Get the names of new list itemsPop out in the code editor (opens in a new tab)JavaScript
    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.

    Real-life analogyMutationObserver is a clerk with a notebook

    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 MutationRecord objects 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.

    Step 1: the script makes three changes, childList, attributes, and characterData. Step 2: after the script ends, the callback runs once and gets all 3 records.1. the script changes 3 thingschildListattributescharacterData2. then one callbackcallback(records)callback: 3 recordsafter sync: end
    The three changes are written down as records while the script runs. When the script ends, the callback runs once and gets all three.
    Step through a copy of a MutationObserver batch
    Step 0 of 9Ready
    Your turn: follow the blue line

    Guess the order first. Then step through this copy of how a MutationObserver batch works.

    Running in
    1. script
    Next: line 1
    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(" | "));
    CallStoreChangeResultBlue line: runs next. Ran: already executed.
    Recent returnsNothing yet. Start with the blue line.
    Observer options

    Guess first: does the callback line come before or after sync: end?

    A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
    Mutation log: options and records in batches
    A copy of how a batch worksPop out in the code editor (opens in a new tab)JavaScript
    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(" | "));
    The watched card and each callback0 callbacks
      Try it yourself

      Turn options on or off. Then make one change, or three changes at once.

      The lab makes the watched card inside an empty element. The log shows the real record types your browser reports.

      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

      INTERACTIVE

      A 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.

      Pick a layout from the element's widthPop out in the code editor (opens in a new tab)JavaScript
      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.

      Left: a wide card fills the page, and the layout is row. Right: a sidebar opens in the same size page, the card shrinks to 320 pixels, and the layout is stacked.beforepagecardwide card"row"a sidebar openspagesidebarcardcard: 320px"stacked"
      The window stays the same size, so window gets no resize event. The card itself shrinks to 320px; ResizeObserver reports that, and layoutFor(320) gives "stacked".
      Real-life analogyResizeObserver is a tailor

      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: contentRect or borderBoxSize
      In real life: Measuring again after the person moves
      In JavaScript: A callback after the browser works out the layout again
      Resize lab: the element's width, not the window's
      Read an element's sizePop out in the code editor (opens in a new tab)JavaScript
      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" });
      A box that picks its layout by width320px wide
      Narrow layout

      React state changes the class here. The observer callback doesn't.

      Try it yourself

      Move the slider. ResizeObserver reports the element's new size after layout.

      The callback only reads the size and shows it. It never changes the size of the box it watches, so it can't start an endless loop.

      Each entry can give you the size in two ways:

      • contentRect.width is the width of the content box: the inside area, without padding or border. Every browser that has ResizeObserver gives you this.
      • borderBoxSize[0].inlineSize is 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

      LAB

      The 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.
      • resource and navigation: 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.

      Real-life analogyPerformanceObserver is a race timekeeper

      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
      Two marks, work:start and work:end, make one measure entry called demo work. The observer's callback gets that entry.markwork:startmarkwork:endtwo marksdemo workmeasure entrycallback(list)gets the entry
      performance.mark() makes the two marks. performance.measure() turns them into one entry, demo work, and the observer's callback gets it.
      Performance lab: marks, measures, and the types this browser has
      Time a task with marks and a measurePop out in the code editor (opens in a new tab)JavaScript
      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");
      Timing entries0 measures

      Entry types this browser has: Checking once the page loads...

        Try it yourself

        Run the task. It makes two marks and one measure entry.

        Each browser has its own list of entry types. The lab reads the list only after the page loads in your browser, so the server and the browser show the same first view.

        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?

        SORT

        The 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.

        Which observer fits the task?
        • 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-expanded attribute 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
        Try it yourself
        0 of 8 correct

        Read each task. Ask what kind of change it waits for. Then pick Visibility, Page changes, Element size, or Timings.

        Choose a category for every card. You can change an answer at any time; Reset clears them all.

        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.

        Count an article as seen only oncePop out in the code editor (opens in a new tab)JavaScript
        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.

        Cleanup rule

        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. IntersectionObserver calls it when the browser next checks positions. MutationObserver calls it as a microtask after the script ends. ResizeObserver calls it after layout. PerformanceObserver calls 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 EXERCISES
        Exercise 1 · Warm-upPredict the visible ratio

        Read the program and predict the number it prints.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        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 }));

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 2 · PracticePredict the order, like a mutation callback

          A MutationObserver callback runs as a microtask. queueMicrotask() schedules a function the same way, so this small program has the same order.

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          const log = [];
          log.push("sync");
          queueMicrotask(() => log.push("observer"));
          log.push("done");
          queueMicrotask(() => console.log(log.join(", ")));

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 3 · PracticeFind the ResizeObserver bug

            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.

            Answer, then press Check. Spacing and letter case don’t matter.

              Exercise 4 · PracticeFormat a performance entry

              Read the code without running it. What is the entry type?

              Starter codePop out in the code editor (opens in a new tab)JavaScript
              const entry = { name: "demo", entryType: "measure", duration: 4.567, startTime: 12.34 };
              console.log(entry.entryType + " " + entry.name + ": " + entry.duration.toFixed(2) + "ms");

              Answer, then press Check. Spacing and letter case don’t matter.

                Exercise 5 · ChallengePick observers for a dashboard

                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.

                  Check your understanding

                  8 QUESTIONS
                  Observers quiz · 8 questionsScore: first tries count
                  1. Question 1 of 8What does root: null mean for an IntersectionObserver?

                    Choose an answer to see the explanation.

                  2. Question 2 of 8Which threshold makes the callback run when an element becomes at least half visible?

                    Choose an answer to see the explanation.

                  3. Question 3 of 8This code copies how a mutation batch works. What does it print?

                    Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                    const 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.

                  4. Question 4 of 8Which MutationObserver method gives you the waiting records right away and empties the list?

                    Choose an answer to see the explanation.

                  5. Question 5 of 8When does a ResizeObserver call its callback?

                    Choose an answer to see the explanation.

                  6. Question 6 of 8Which box does a ResizeObserver watch if you don't choose one?

                    Choose an answer to see the explanation.

                  7. Question 7 of 8What does this code print?

                    Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                    const 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.

                  8. Question 8 of 8Which PerformanceObserver entry 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.
                  • IntersectionObserver watches whether a target is inside a root. threshold can be a list, and rootMargin lets you start loading early.
                  • MutationObserver saves records of page changes and hands them over in one batch, as a microtask, after the script ends.
                  • ResizeObserver watches one element's size, after layout and before paint. Don't resize the watched element inside its callback.
                  • PerformanceObserver watches timing entries. Check supportedEntryTypes before 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.

                  CompleteFrontend Clear concepts. Working examples.