Przejdź do treści
Wydajność 3 min czytania

Lazy loading obrazów bez psucia CLS - praktyczny przewodnik

Lazy loading to standard od 2020. `loading="lazy"` w `<img>` działa w każdej przeglądarce. Ale źle skonfigurowany lazy loading potrafi zepsuć dwa kluczowe Core Web Vitals - LCP (jeśli zaczniesz lazy-load'ować pierwszy obraz) i CLS (jeśli nie zarezerwujesz miejsca). Ten artykuł to konkretny przewodnik.

Jakub Owsianka Autor
Zaktualizowano:
Okladka artykulu: Lazy loading obrazów bez psucia CLS - praktyczny przewodnik (kategoria: Wydajność)
Okladka artykulu: Lazy loading obrazów bez psucia CLS - praktyczny przewodnik (kategoria: Wydajność)
Spis treści (8)

W skrócie

  • 1. loading="lazy" dla obrazów poza viewport'em - natywny atrybut HTML
  • 2. LCP element NIGDY lazy-loaded - używaj fetchpriority="high"
  • 3. Zawsze width i height (lub aspect-ratio w CSS) - przeciwdziała CLS
  • 4. Pierwsze 4-6 obrazów eager, reszta lazy
  • 5. IntersectionObserver dla zaawansowanych use-case'ów (custom triggers)

Natywne lazy loading

Najprostsze:

<img src="produkt.webp" alt="..." width="400" height="400" loading="lazy">

Browser:

  • Pobiera tylko gdy obraz jest blisko viewport'u
  • Threshold dystansu - zarządzany przez browser (~300-1000 px below fold)

Wsparcie: wszystkie nowoczesne przeglądarki od 2020 (Chrome, Firefox, Safari, Edge).

Kiedy NIE używać:

  • LCP element (główne zdjęcie produktu, hero banner)
  • Above-the-fold (widoczne natychmiast po load)
  • Krytyczne dla SEO obrazy

Width / height - CLS prevention

Browser potrzebuje wymiarów ZANIM obraz się załaduje, żeby zarezerwować miejsce. Bez wymiarów:

  • Browser renderuje stronę bez obrazu (wysokość 0)
  • Obraz się ładuje, layout się przesuwa
  • CLS score wzrasta

Trzy podejścia:

1. Atrybuty HTML (klasyczne):

<img src="..." alt="..." width="400" height="400">

2. CSS aspect-ratio (nowoczesne):

<img src="..." alt="..." style="aspect-ratio: 1/1; width: 100%;">

3. CSS containers:

<div class="image-container" style="aspect-ratio: 4/3;">
  <img src="..." alt="..." style="width: 100%; height: 100%; object-fit: cover;">
</div>

Wybór: dla responsywnych - CSS aspect-ratio. Dla fixed sizes - atrybuty HTML.

Pierwsze N eager, reszta lazy

Klasyczny wzorzec dla PLP:

<!-- Pierwsze 6 produktów (above the fold) - eager -->
<img src="produkt-1.webp" alt="..." width="200" height="200" loading="eager" fetchpriority="high">
<img src="produkt-2.webp" alt="..." width="200" height="200" loading="eager">
...
<img src="produkt-6.webp" alt="..." width="200" height="200" loading="eager">

<!-- Pozostałe - lazy -->
<img src="produkt-7.webp" alt="..." width="200" height="200" loading="lazy">
...

Liczba „eager" obrazów zależy od:

  • Mobile: 4-6 (mniejszy viewport)
  • Desktop: 6-12 (więcej widocznych)
  • Czy thumbnaile na PLP czy hero banner

srcset i sizes - responsywność

Klient na mobile (375px szerokości) nie potrzebuje obrazu 1200×1200. Marnujesz bandwidth.

<img
  src="produkt-400.webp"
  srcset="
    produkt-200.webp 200w,
    produkt-400.webp 400w,
    produkt-800.webp 800w,
    produkt-1200.webp 1200w
  "
  sizes="
    (max-width: 480px) 200px,
    (max-width: 768px) 400px,
    (max-width: 1200px) 600px,
    800px
  "
  alt="..."
  width="800"
  height="800"
  loading="lazy"
>

Browser wybiera odpowiedni wariant na podstawie:

  • Viewport (z sizes)
  • DPR (device pixel ratio - Retina/2x dostaje 2x większy)
  • Bandwidth (jeśli Save Data)

Picture z fallback formatów

Połączenie srcset i picture dla maksymalnej kompresji:

<picture>
  <source
    type="image/avif"
    srcset="produkt-400.avif 400w, produkt-800.avif 800w"
    sizes="(max-width: 768px) 400px, 800px"
  >
  <source
    type="image/webp"
    srcset="produkt-400.webp 400w, produkt-800.webp 800w"
    sizes="(max-width: 768px) 400px, 800px"
  >
  <img
    src="produkt-800.jpg"
    alt="..."
    width="800"
    height="800"
    loading="lazy"
  >
</picture>

Browser próbuje formaty w kolejności:

  • AVIF (najlepsza kompresja, Chrome/Firefox)
  • WebP (dobra, wszędzie)
  • JPG (fallback, każdy browser)

IntersectionObserver - custom triggers

Czasem natywne loading="lazy" nie wystarcza. Use-case'y:

  • Custom threshold (nie domyślne 300-1000px)
  • Trigger przed scrollowaniem (np. po 3s)
  • Lazy load JSON / data, nie tylko obrazów
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src;
      observer.unobserve(img);
    }
  });
}, {
  rootMargin: '200px 0px',  // 200px poza viewport
  threshold: 0
});

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

HTML:

<img data-src="produkt.webp" src="placeholder.webp" alt="..." width="400" height="400">

Placeholdery - UX podczas ładowania

Pusta przestrzeń wygląda źle. Placeholder daje wrażenie ciągłości:

1. LQIP (Low Quality Image Placeholder). Małe (50×50 px), rozmazane (blur), ten sam aspect ratio. Klient widzi mglistą wersję, potem ostre zdjęcie.

<img
  src="produkt-50-blur.webp"
  data-src="produkt-800.webp"
  alt="..."
  width="800"
  height="800"
  style="filter: blur(20px); transform: scale(1.05);"
  loading="lazy"
>

2. Color placeholder. Dominujący kolor obrazu jako background. Mniej dramatyczne niż blur.

<div style="background-color: #c4a583; aspect-ratio: 1/1;">
  <img src="..." alt="..." width="800" height="800" loading="lazy">
</div>

3. Skeleton loader. Animowany szary placeholder (klasyczny shimmer effect).

<div class="skeleton" style="aspect-ratio: 1/1; background: linear-gradient(90deg, #eee 0%, #ddd 50%, #eee 100%); background-size: 200% 100%; animation: skeleton 1.5s infinite;">
  <img src="..." alt="..." width="800" height="800" loading="lazy" onload="this.parentElement.classList.add('loaded')">
</div>

Najczęstsze błędy

1. loading="lazy" na LCP element.

Najczęstszy błąd. Główne zdjęcie produktu na PDP, hero banner - eager, nie lazy. fetchpriority="high".

2. Brak width / height.

CLS rośnie. Zawsze atrybuty lub CSS aspect-ratio.

3. Lazy loading wszystkiego (też above-the-fold).

Browser zaczeka aż user scrollne - opóźnia LCP. Powyższy fold: eager.

4. Custom IntersectionObserver gdy natywne lazy wystarczy.

Komplikacja bez wartości. Tylko jeśli masz specyficzne wymagania (custom threshold, custom trigger) - IntersectionObserver.

5. Background-image w CSS lazy-loadowane przez JS.

Background images nie wspierają loading="lazy". Trzeba JS (Intersection Observer). Alternatywa: zamiast background-image, użyj <img> z object-fit.

6. Pre-loading wszystkich obrazów po scroll.

Jak user zacznie scrollować, JS preloaduje 100 obrazów na raz. To anty-pattern - pobiera bandwidth bez wartości. Lazy = pobierz gdy realnie potrzebny.

FAQ

Czy lazy loading działa dla <picture> elementów? Tak. loading="lazy" na <img> wewnątrz <picture> działa normalnie.

Czy lazy loading wpływa na SEO? Pozytywnie (lepsze LCP, lepsze CWV). Google indeksuje lazy-loaded obrazy bez problemu (od 2019 Googlebot wspiera).

Czy mogę lazy-load'ować iframe? Tak. <iframe loading="lazy"> - wsparcie od Chrome 77+. Dobra praktyka dla embed YouTube, Trusted Shops widget, etc.

Co z background-image lazy? Natywnie nie. Trzeba JS. Lub przeprojektuj - zastąp <div style="background-image: ..."> na <img> z object-fit: cover.

Czy lazy loading psuje Pinterest, Facebook share previews? Może, jeśli OpenGraph image jest lazy. Standardowo OG image nie jest na stronie, jest w meta tag, więc bez problemu.

Co dalej

O autorze

Jakub Owsianka

Architekt rozwiązania w WiseB2B - silniku platform B2B. Zaczynał po stronie biznesu (własne sklepy), potem deweloper, dziś projektuje wdrożenia dla sklepów z katalogami w dziesiątkach tysięcy SKU. W ostatnich latach wdrożył AI-development w zespole i funkcjonalności oparte o AI bezpośrednio w silniku sklepu.

Masz pytanie do tego artykułu?

Dodatkowy kontekst, problem z własnym wdrożeniem, druga opinia - napisz wprost. Odpowiadam osobiście w 1-2 dni robocze.