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.
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
widthiheight(lubaspect-ratiow 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
- LCP PDP konkretnie: LCP karty produktu
- Core Web Vitals pełne: Core Web Vitals
- CDN dla image optimization: CDN dla katalogów
- Pillar wydajności: Wydajność e-commerce
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.