Ein scroll-Listener auf window kann während eines einzigen Swipes hunderte Male auslösen. Wer bei jedem dieser Events getBoundingClientRect() für jede Karte in einem Feed aufruft, braucht nur fünfzig Bilder, um die Seite ins Stottern zu bringen. Die Intersection Observer API nimmt diese Prüfung aus dem Scroll-Pfad: Sie registrieren, welche Elemente beobachtet werden sollen, und der Browser meldet sich, wenn sich deren Sichtbarkeit tatsächlich ändert.

Wie IntersectionObserver funktioniert

Ein Observer beobachtet ein oder mehrere Target-Elemente relativ zu einem Root (standardmäßig der Viewport) und ruft einen Callback auf, sobald ein Target einen Sichtbarkeitsschwellenwert überschreitet.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    console.log(entry.target, entry.isIntersecting, entry.intersectionRatio);
  }
}, {
  root: null,         // null = der Browser-Viewport
  rootMargin: "0px",  // vergrößert oder verkleinert die Box des Root vor dem Test
  threshold: 0,       // löst aus, sobald 1px sichtbar ist
});

document.querySelectorAll(".card").forEach((el) => observer.observe(el));

Jede Entry enthält isIntersecting (ein Boolean), intersectionRatio (wie viel vom Target innerhalb des Root liegt, von 0 bis 1) und das eigene boundingClientRect des Targets. Der Callback läuft asynchron. Der Browser berechnet die Intersections einmal pro Frame, als Teil seiner eigenen Render-Arbeit, sodass Ihr Code beim Scrollen nie eine Layout-Neuberechnung erzwingt.

threshold akzeptiert auch ein Array. [0, 0.5, 1] löst den Callback getrennt bei 0 %, 50 % und 100 % Sichtbarkeit aus — so verschicken Sie ein “als gelesen markiert bei halbem Scroll”-Event, ohne drei Observer anzulegen.

Bilder mit IntersectionObserver lazy laden

Der klassische Anwendungsfall: ein Bild erst herunterladen, kurz bevor es in den Viewport eintritt.

1
<img class="lazy" data-src="/media/hero-photo.jpg" alt="Bergkamm bei Sonnenuntergang" width="1200" height="800">
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const imgObserver = new IntersectionObserver((entries, obs) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;
    const img = entry.target;
    img.src = img.dataset.src;
    img.classList.remove("lazy");
    obs.unobserve(img); // mit diesem hier fertig
  }
}, { rootMargin: "200px 0px" }); // beginnt 200px vor der Sichtbarkeit zu laden

document.querySelectorAll("img.lazy").forEach((img) => imgObserver.observe(img));

rootMargin macht hier den entscheidenden Unterschied. Ein positiver Wert vergrößert die effektive Box des Root, sodass der Callback auslöst, während das Bild noch 200px unterhalb des sichtbaren Bereichs liegt, statt erst beim ersten sichtbaren Pixel. Geben Sie außerdem width und height weiterhin explizit im Tag an: Das reserviert den Platz, damit das Layout nicht springt, wenn das echte Bild eintrifft — eine eigene Ursache für Layout-Shift, die die meisten Lazy-Loading-Demos ignorieren.

Bevor Sie das alles schreiben, prüfen Sie, ob <img loading="lazy"> ausreicht. Jeder aktuelle Browser unterstützt es nativ, ohne JavaScript. Die Observer-Variante lohnt sich erst, wenn Sie etwas brauchen, das das Attribut nicht kann: einen eigenen Placeholder-Swap, Lazy-Video oder -iframes, oder Hintergrundbilder, die aus CSS gesetzt werden.

Infinite Scroll mit einem Sentinel-Element bauen

Infinite Scroll beobachtet nicht den bereits geladenen Inhalt. Es beobachtet ein leeres Element unterhalb der Liste und lädt nach, wenn dieses Sentinel sichtbar wird.

1
2
<ul id="results"></ul>
<div id="sentinel"></div>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const sentinel = document.querySelector("#sentinel");
let page = 1;

const scrollObserver = new IntersectionObserver(async (entries) => {
  if (!entries[0].isIntersecting) return;
  scrollObserver.unobserve(sentinel); // Pause, während diese Seite lädt
  await loadNextPage(page++);
  scrollObserver.observe(sentinel);   // beobachtet wieder für die nächste
}, { rootMargin: "400px" });

scrollObserver.observe(sentinel);

Das Paar unobserve/observe ist für langsame Netzwerke da. Ohne es kann ein Nutzer, der schnell genug scrollt, den Callback erneut auslösen, bevor die erste Anfrage aufgelöst ist, und zwei Ergebnisseiten landen in der falschen Reihenfolge.

loadNextPage ist ein gewöhnlicher fetch-Aufruf und braucht deshalb dieselbe response.ok-Prüfung wie jede andere Anfrage. Der Leitfaden zur Fehlerbehandlung mit der Fetch API erklärt, was zu tun ist, wenn diese Anfrage mit 404 antwortet oder das Netzwerk mitten im Scroll abbricht. Lassen Sie das Sentinel im Fehlerfall unbeobachtet und zeigen Sie eine Retry-Schaltfläche. Es sofort wieder zu beobachten bringt Sie in eine Schleife aus fehlschlagenden Anfragen.

Der alte Weg war ein gedrosselter oder entprellter scroll-Handler, der getBoundingClientRect() auf dem Sentinel aufrief. Wie der Vergleich von Debounce und Throttle zeigt, pollen beide Techniken weiterhin, nur seltener. Ein Observer pollt überhaupt nicht. Die Prüfung liegt beim Browser, und Ihr Code reagiert nur noch auf das Ergebnis.

Wann der IntersectionObserver-Callback läuft

Nicht in der Microtask-Queue. Wenn Sie gelesen haben, wie async/await mit der Event Loop zusammenspielt, gehen Sie nicht davon aus, dass der Callback sich wie die Fortsetzung einer Promise verhält. Der Browser plant ihn innerhalb seiner Render-Pipeline ein, rund um den Paint. Deshalb ist er günstig genug für eine scrollende Seite — und genau deshalb das falsche Werkzeug für Logik, die vor dem nächsten Repaint laufen muss.

Häufige Fallstricke bei IntersectionObserver

Es prüft eine Bounding Box, keine Pixel. Ein Element, das als 100 % intersecting gemeldet wird, kann trotzdem vollständig hinter einem opaken Modal oder einem darüber gemalten Sticky-Header verschwinden. Die API beantwortet “liegt diese Box in jener Box”, nicht “kann der Nutzer das sehen”.

Ein Observer pro Element summiert sich. new IntersectionObserver() für jede von zweihundert Karten aufzurufen, erzeugt zweihundert einzelne Observer. Legen Sie einen pro Verhalten an (einen für alle Lazy-Bilder, einen für das Sentinel) und rufen Sie .observe() für jedes Target auf, das ihn braucht. Der Callback erhält ohnehin Entries für jedes Target, das sich geändert hat.

jsdom implementiert es nicht. In einer Jest- oder Vitest-Suite, die auf jsdom läuft, ist IntersectionObserver undefined, solange Sie es nicht mocken oder ein Polyfill installieren. Mocken Sie den Konstruktor und rufen Sie den Callback selbst mit gefälschten Entries auf, um die Lazy-Loading-Logik isoliert zu testen. Versuchen Sie nicht, echtes Scrollen in einem Unit-Test zu simulieren.

Beobachten, bevor das Element existiert. In einem Framework, das asynchron rendert, schlägt observer.observe(el) auf einer noch null-Ref lautlos fehl: kein Fehler, keine Warnung, das Element wird nie beobachtet. Beobachten Sie innerhalb des Hooks, der läuft, nachdem der DOM-Knoten eingehängt wurde (useEffect in React, mounted in Vue).

Wann IntersectionObserver statt loading=“lazy” verwenden

Für einfache <img>-Tags fügen Sie loading="lazy" hinzu und schreiben kein JavaScript. Nutzen Sie IntersectionObserver für das, was ein Attribut nicht ausdrücken kann: Paginierungs-Trigger, Autoplay für Video beim Sichtbarwerden, “gesehen”-Events für Analytics, Placeholder-zu-Bild-Swaps. Ein guter erster Schritt: Suchen Sie in Ihrer Codebasis den scroll-Listener, der getBoundingClientRect() aufruft, und ersetzen Sie ihn durch einen einzigen gemeinsamen Observer.

Verwandte Artikel