karawaci.kode

2026-08-12 · 8 min

Lazy Loading yang Benar: Intersection Observer vs Native loading="lazy"

Dua fitur yang menyelesaikan masalah yang sama sering membuat developer salah pilih karena memilih berdasarkan familiaritas, bukan kecocokan kasus. Di production kami, saya pernah melihat implementasi Intersection Observer manual di halaman blog statis yang tidak butuh lebih dari loading="lazy", dan sebaliknya loading="lazy" dipasang di image gallery dengan animasi reveal yang tidak pernah bisa berfungsi dengan benar karena tidak ada callback.

Tulisan ini membahas keduanya secara mekanik, lalu memberikan keputusan konkret: kapan masing-masing masuk akal.

Cara Kerja Native loading=“lazy”

loading="lazy" adalah atribut HTML yang dikendalikan penuh oleh browser. Tidak ada JavaScript yang terlibat. Browser menentukan sendiri threshold — seberapa jauh elemen dari viewport sebelum mulai dimuat.

<img
  src="foto-produk.jpg"
  alt="Kemeja batik tulis"
  width="800"
  height="600"
  loading="lazy"
/>

Di Chrome, threshold ini tidak tetap. Browser menyesuaikannya berdasarkan tipe koneksi:

  • Koneksi cepat (4G): gambar dimuat saat berada ~1250px di bawah viewport
  • Koneksi lambat (2G/3G): threshold diperlebar hingga ~2500px agar gambar sudah siap sebelum user menggulir sampai ke sana

Angka ini sengaja besar. Browser tidak mau Anda melihat gambar kosong saat menggulir — ia ingin gambar sudah tersedia sebelum elemen masuk layar. Ini keputusan UX yang sudah dipertimbangkan pihak browser, bukan keteledoran.

Satu hal kritis yang sering terlewat: loading="lazy" tidak berlaku untuk gambar di atas fold. Browser mendeteksi gambar mana yang kemungkinan terlihat saat halaman pertama dimuat dan tidak menerapkan lazy loading padanya, meskipun atribut dipasang. Ini perilaku yang benar — Largest Contentful Paint bergantung pada gambar di atas fold yang dimuat secepat mungkin.

Cara Kerja Intersection Observer

Intersection Observer adalah API JavaScript yang memberi Anda callback setiap kali elemen memotong (bersinggungan dengan) root yang ditentukan — biasanya viewport, tapi bisa container scroll mana saja.

const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src;
        observer.unobserve(img); // stop observing setelah dimuat
      }
    });
  },
  {
    rootMargin: '200px 0px', // mulai load 200px sebelum masuk viewport
    threshold: 0,            // callback segera saat satu piksel masuk
  }
);

// Observe semua gambar dengan data-src
document.querySelectorAll('img[data-src]').forEach((img) => {
  observer.observe(img);
});

Pola ini menggunakan data-src sebagai placeholder — src asli dikosongkan atau diisi placeholder, kemudian di-swap saat callback terpicu. Di sinilah Anda punya kontrol penuh: threshold, timing, efek, dan callback.

Untuk framework seperti React atau Astro, pola yang lebih bersih menggunakan hook:

// hooks/useIntersectionObserver.ts
import { useEffect, useRef, useState } from 'react';

interface Options extends IntersectionObserverInit {
  freezeOnceVisible?: boolean;
}

export function useIntersectionObserver(
  options: Options = {},
): [React.RefObject<Element>, boolean] {
  const { threshold = 0, root = null, rootMargin = '0px', freezeOnceVisible = true } = options;
  const ref = useRef<Element>(null);
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true);
          if (freezeOnceVisible) observer.unobserve(el);
        }
      },
      { threshold, root, rootMargin },
    );

    observer.observe(el);
    return () => observer.disconnect();
  }, [threshold, root, rootMargin, freezeOnceVisible]);

  return [ref, isVisible];
}

Penggunaannya:

function LazySection({ children }: { children: React.ReactNode }) {
  const [ref, isVisible] = useIntersectionObserver({ rootMargin: '300px' });

  return (
    <div
      ref={ref as React.RefObject<HTMLDivElement>}
      className={`transition-opacity duration-500 ${isVisible ? 'opacity-100' : 'opacity-0'}`}
    >
      {isVisible ? children : <Skeleton />}
    </div>
  );
}

Perbedaan Mekanik yang Penting

Ada tiga perbedaan yang menentukan pilihan:

1. Kontrol threshold

loading="lazy" tidak bisa dikonfigurasi dari JavaScript. Browser yang memutuskan. Intersection Observer memberi Anda rootMargin (perluasan area deteksi) dan threshold (persentase elemen yang harus terlihat sebelum callback).

2. Jenis konten

loading="lazy" hanya bekerja untuk <img> dan <iframe>. Intersection Observer bekerja untuk elemen apa pun — div, section, komponen React, video, canvas. Kalau Anda ingin lazy load komponen JavaScript bukan sekadar gambar, loading="lazy" tidak relevan.

3. Callback dan efek

loading="lazy" diam. Tidak ada event yang bisa didengarkan saat gambar mulai atau selesai dimuat (kecuali event load standar yang selalu ada). Intersection Observer memberi Anda callback yang tepat saat elemen masuk/keluar viewport — cocok untuk animasi, analytics “berapa banyak user melihat section ini”, atau deferring render komponen berat.

Benchmark Praktis

Di klien Jakarta yang punya halaman listing properti dengan 80+ gambar thumbnail, saya bandingkan tiga pendekatan:

PendekatanImplementasiLCPTotal bytes dimuat saat FCP
Tanpa lazy loading<img src="..."> langsung2.1s4.2MB
loading="lazy" + ukuran eksplisitatribut HTML saja1.3s380KB
Intersection Observer manualthreshold 300px1.4s310KB
Intersection Observer manualthreshold 0px1.6s290KB

Perbedaan antara loading="lazy" dan Intersection Observer di sini kecil — 70KB dan 100ms. Tapi perhatikan baris terakhir: threshold terlalu kecil (0px, artinya hanya dimuat saat benar-benar masuk viewport) justru memperburuk pengalaman scroll karena gambar belum siap saat user tiba.

loading="lazy" menang di sini bukan karena lebih canggih, tapi karena threshold defaultnya yang besar justru tepat untuk kasus ini.

Kapan Pakai Mana

Gunakan loading="lazy" jika:

  • Konten adalah gambar atau iframe statis dalam halaman konten (artikel, listing, galeri)
  • Anda tidak butuh callback atau animasi saat elemen muncul
  • Ingin zero JavaScript untuk feature ini
  • SSR/SSG — atribut ini bekerja dari HTML tanpa hydration
  • Target browser modern (Chrome 77+, Firefox 75+, Safari 15.4+) — coverage sudah >95% global
<!-- Ini sudah optimal. Tidak perlu tambah JavaScript. -->
<img
  src="/images/produk-batik.webp"
  alt="Batik tulis motif parang"
  width="600"
  height="400"
  loading="lazy"
  decoding="async"
/>

Gunakan Intersection Observer jika:

  • Lazy loading komponen React/Vue/Astro (bukan hanya gambar)
  • Perlu animasi masuk saat elemen masuk viewport
  • Container scroll bukan viewport (panel, dialog, sidebar)
  • Butuh analytics visibility (berapa lama section terlihat)
  • Perlu threshold yang sangat spesifik — misalnya mulai prefetch data 500px sebelum elemen terlihat
  • Placeholder blur yang butuh transisi halus dengan kontrol timing
// Contoh: defer render komponen berat
function HeavyWidget() {
  const [ref, isVisible] = useIntersectionObserver({ rootMargin: '400px' });

  return (
    <div ref={ref as React.RefObject<HTMLDivElement>}>
      {isVisible ? <ActualHeavyWidget /> : <WidgetSkeleton />}
    </div>
  );
}

Trade-off yang Perlu Diakui

loading="lazy" punya satu kelemahan riil: tidak ada kontrol atas threshold. Kalau Anda membangun aplikasi yang perlu prefetch konten berdasarkan prediksi scroll (misalnya memuat halaman berikutnya saat user sudah 80% halaman ini), loading="lazy" tidak bisa melakukan itu.

Intersection Observer punya biaya setup — lebih banyak JavaScript, lebih banyak kode yang harus dimaintain, dan risiko memory leak kalau lupa memanggil unobserve atau disconnect. Saya pernah menemukan observer yang tidak pernah di-disconnect di halaman SPA karena komponen unmount tanpa cleanup — hasilnya akumulasi observer yang tidak pernah berhenti berjalan sepanjang sesi.

Satu pattern yang juga sering diabaikan: gabungan keduanya valid. Pakai loading="lazy" untuk semua gambar, tapi tambah Intersection Observer untuk animasi reveal. Gambar sudah dimuat browser tanpa JavaScript, sementara Observer hanya mengurus visibilitas CSS.

<img
  src="foto.webp"
  alt="..."
  width="400"
  height="300"
  loading="lazy"
  class="opacity-0 transition-opacity duration-300"
  data-reveal="true"
/>
const revealObserver = new IntersectionObserver((entries) => {
  entries.forEach(({ target, isIntersecting }) => {
    if (isIntersecting) {
      target.classList.replace('opacity-0', 'opacity-100');
      revealObserver.unobserve(target);
    }
  });
}, { threshold: 0.1 });

document.querySelectorAll('[data-reveal]').forEach((el) => revealObserver.observe(el));

Browser menangani loading, JavaScript hanya mengurus presentasi.

Verdict

Untuk gambar di halaman konten: mulai dengan loading="lazy". Tambahkan width, height, dan decoding="async". Tidak perlu JavaScript. Browser melakukan pekerjaan lebih baik dari kebanyakan implementasi manual karena punya konteks koneksi yang tidak kita miliki.

Pindah ke Intersection Observer ketika kebutuhan Anda melampaui gambar — komponen berat yang perlu di-defer, animasi yang butuh callback, container scroll kustom, atau visibility tracking. Di production kami, sekitar 80% kasus cukup diselesaikan atribut HTML satu kata. Sisanya butuh Observer, dan keduanya bisa hidup berdampingan di halaman yang sama tanpa konflik.

Yang paling mahal bukan memilih yang salah — tapi memilih yang lebih kompleks tanpa alasan, lalu mewarisi maintenance burden itu ke tim selanjutnya.

Ditulis oleh Reza Pradipta