Un listener scroll su window può scattare centinaia di volte durante un singolo swipe. Se ad ogni evento esegui getBoundingClientRect() su tutte le card di un feed, bastano cinquanta immagini per inchiodare la pagina. La Intersection Observer API toglie quel controllo dal percorso dello scroll: registri quali elementi osservare e il browser ti avvisa quando la loro visibilità cambia davvero.

Come funziona IntersectionObserver

Un observer osserva uno o piĂą elementi target rispetto a un root (di default il viewport) e invoca una callback ogni volta che un target supera una soglia di visibilitĂ .

 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 = il viewport del browser
  rootMargin: "0px",  // ingrandisce o riduce il box del root prima del test
  threshold: 0,       // scatta non appena è visibile 1px
});

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

Ogni entry include isIntersecting (un booleano), intersectionRatio (quanto del target si trova dentro il root, da 0 a 1) e il boundingClientRect del target. La callback è asincrona. Il browser calcola le intersezioni una volta per frame, come parte del proprio lavoro di rendering, quindi il tuo codice non forza mai un recalcolo del layout mentre l’utente scorre.

threshold accetta anche un array. [0, 0.5, 1] fa scattare la callback separatamente a 0%, 50% e 100% di visibilitĂ , il modo giusto per inviare un evento “segnato come letto a metĂ  scroll” senza creare tre observer.

Come fare il lazy loading delle immagini con IntersectionObserver

Il caso d’uso classico: non scaricare un’immagine finchĂ© non sta per entrare nel viewport.

1
<img class="lazy" data-src="/media/hero-photo.jpg" alt="Cresta montuosa al tramonto" 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); // con questa abbiamo finito
  }
}, { rootMargin: "200px 0px" }); // inizia a caricare 200px prima che sia visibile

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

rootMargin è ciò che rende tutto questo istantaneo. Un valore positivo ingrandisce il box effettivo del root, quindi la callback scatta mentre l’immagine è ancora 200px sotto la piega, invece che al primo pixel visibile. Tieni anche width e height espliciti sul tag: riservano lo spazio così il layout non salta quando l’immagine reale arriva, una causa separata di layout shift che la maggior parte delle demo sul lazy loading ignora.

Prima di scrivere tutto questo, verifica se <img loading="lazy"> basta. Ogni browser moderno lo supporta nativamente, senza JavaScript. La versione con l’observer ha senso solo quando serve qualcosa che l’attributo non può fare: uno swap di placeholder personalizzato, video o iframe lazy, o immagini di sfondo impostate da CSS.

Come costruire uno scroll infinito con un elemento sentinella

Lo scroll infinito non osserva il contenuto giĂ  caricato. Osserva un elemento vuoto sotto la lista e carica altro quando quella sentinella diventa visibile.

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); // pausa mentre carica questa pagina
  await loadNextPage(page++);
  scrollObserver.observe(sentinel);   // riprende a osservare per la prossima
}, { rootMargin: "400px" });

scrollObserver.observe(sentinel);

La coppia unobserve/observe serve per le reti lente. Senza di essa, un utente che scorre velocemente può far scattare di nuovo la callback prima che la prima richiesta sia risolta, e due pagine di risultati arrivano fuori ordine.

loadNextPage è una normale chiamata fetch, quindi ha bisogno dello stesso controllo response.ok di qualsiasi altra richiesta. La guida alla gestione degli errori della Fetch API copre cosa fare quando quella richiesta risponde 404 o la rete si interrompe a metà scroll. In caso di fallimento, lascia la sentinella non osservata e mostra un pulsante di retry. Osservarla di nuovo subito ti mette in un ciclo di richieste che falliscono.

Il vecchio modo di farlo era un handler scroll con throttle o debounce che chiamava getBoundingClientRect() sulla sentinella. Come mostra il confronto tra debounce e throttle, entrambe le tecniche continuano a fare polling, solo meno spesso. Un observer non fa polling per niente. Il browser ha il controllo e il tuo codice reagisce al risultato.

Quando scatta la callback di IntersectionObserver

Non sulla coda dei microtask. Se hai letto come async/await interagisce con l’event loop, non aspettarti che la callback si comporti come la continuazione di una promise. Il browser la pianifica dalla pipeline di rendering, intorno al paint. Per questo è abbastanza leggera da poter essere usata su una pagina che scorre, e anche per questo è lo strumento sbagliato per la logica che deve essere eseguita prima del prossimo repaint.

Insidie comuni di IntersectionObserver

Testa un bounding box, non i pixel. Un elemento segnalato come intersecante al 100% può comunque essere completamente nascosto dietro un modale opaco o sotto un header sticky. L’API risponde a “questo box è dentro quel box”, non a “l’utente può vedere questo”.

Un observer per elemento si accumula. Chiamare new IntersectionObserver() per ognuna di duecento card crea duecento observer separati. Crea un observer per comportamento (uno per tutte le immagini lazy, uno per la sentinella) e chiama .observe() su ogni target che ne ha bisogno. La callback riceve giĂ  le entry per qualunque target sia cambiato.

jsdom non lo implementa. In una suite Jest o Vitest che gira su jsdom, IntersectionObserver è undefined a meno che non lo mocki o installi un polyfill. Mocka il costruttore e invoca tu stesso la callback con entry finte per testare la logica di lazy loading in isolamento. Non provare a simulare uno scroll reale in un unit test.

Osservare prima che l’elemento esista. In un framework che renderizza in modo asincrono, chiamare observer.observe(el) su un ref ancora null fallisce in silenzio: nessun errore, nessun warning, l’elemento non viene mai osservato. Osserva dentro l’hook che gira dopo che il nodo DOM è stato montato (useEffect in React, mounted in Vue).

Quando usare IntersectionObserver invece di loading=“lazy”

Per i tag <img> semplici, aggiungi loading="lazy" e non scrivere JavaScript. Usa IntersectionObserver per quello che un attributo non può esprimere: trigger di paginazione, autoplay dei video quando visibili, eventi “visto” per l’analytics, swap da placeholder a immagine. Una buona prima mossa è cercare nel tuo codice il listener scroll che chiama getBoundingClientRect() e sostituirlo con un unico observer condiviso.

Articoli correlati