Ленивая загрузка изображений: разбор на живом проекте
Каталог из 400 карточек тянул 18 МБ картинок и на мобильном открывался семь секунд. Разбираем, как loading=lazy, IntersectionObserver и content-visibility снизили это до полутора секунд и где каждый способ ломается.

Каталог интернет-магазина отдавал главную страницу с 60 карточками товаров, а всего в бесконечной ленте подгружалось до 400 изображений. На мобильном 4G первая отрисовка занимала около 7 секунд, LCP держался на 4,2 секунды, а браузер честно скачивал все 18 МБ картинок ещё до того, как пользователь долистывал до второго экрана. Задача звучала просто: не грузить то, что не видно. На практике это три разных механизма с разной ценой, и наивное решение через один атрибут закрывает не весь фронт.
Разбор — на живом каталоге на вёрстке без фреймворка, HTML + чистый JS, тестировалось в Chrome 120 и Safari 17. Инвалидацию CDN-кеша и оптимизацию самих картинок (WebP, ресайз) здесь не трогаем — считаем, что изображения уже сжаты, речь только о моменте их загрузки.
Что именно тормозило
Браузер обнаруживает <img> в HTML на этапе парсинга и — если ничего не сказано — ставит запрос в очередь сразу, независимо от того, попадёт ли картинка в вьюпорт. При 400 изображениях это 400 сетевых запросов, конкурирующих за те же соединения, что и критичный CSS. Отсюда и семь секунд: полезные ресурсы ждали в очереди за картинками из подвала.
Три инструмента решают это по-разному:
- Атрибут
loading="lazy"— нативная ленивая загрузка изображений и iframe. Ноль JS. - IntersectionObserver — ручная подгрузка чего угодно (картинки, HTML-фрагменты, скрипты) при попадании в область видимости.
- CSS
content-visibility: auto— пропуск отрисовки и вёрстки блоков за экраном. Это не про сеть, а про рендеринг.
Подход 1. loading="lazy" — минимум усилий
Первое, что попробовали, — навесить атрибут на все карточные картинки:
<img src="/img/product-1024.webp"
width="300" height="400"
loading="lazy"
alt="Кроссовки Runner X">
Браузер сам решает, когда картинка достаточно близко к вьюпорту, и откладывает запрос. Атрибуты width и height здесь не косметика: без них браузер не знает высоту блока, не резервирует место, и при подгрузке страница дёргается — это и есть скачок CLS.
Результат на нашем каталоге: первый экран стал грузить только видимые ~12 картинок вместо 400. LCP упал с 4,2 до 2,6 секунды, суммарный трафик первого экрана — с 18 МБ до примерно 1,4 МБ.
Одна строчка атрибута сняла 90% лишних запросов. Всё, что было дальше, — борьба за оставшиеся полторы секунды и за плавность.
Где ломается
Главная ловушка — loading="lazy" на картинке первого экрана, особенно на LCP-элементе. Браузер отложит её загрузку, LCP ухудшится. Правило простое: всё, что видно без скролла, грузим обычно (или с fetchpriority="high"), lazy — только ниже сгиба.
Второе: атрибут работает для <img> и <iframe>, но не для фоновых картинок в CSS (background-image) — их нативно отложить нельзя. Для фонов нужен либо IntersectionObserver, либо переверстать в <img>.
Подход 2. IntersectionObserver — когда нативного мало
В каталоге были блоки, которые нельзя закрыть атрибутом: карусель отзывов с фоновыми изображениями и подгрузка следующей страницы товаров. Здесь понадобился IntersectionObserver — API, который асинхронно сообщает, что элемент пересёк заданную границу вьюпорта, без ручного слушателя scroll.
Для фоновых картинок держим реальный URL в data-bg и подставляем при появлении:
// Chrome 120, Safari 17 — поддержка полная
const observer = new IntersectionObserver((entries, obs) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const block = entry.target;
block.style.backgroundImage = `url(${block.dataset.bg})`;
obs.unobserve(block); // один раз загрузили — перестаём следить
}
}, {
// начинаем грузить за 200px до появления, чтобы не мигало
rootMargin: '200px 0px'
});
document.querySelectorAll('[data-bg]').forEach(el => observer.observe(el));
Ключевой параметр — rootMargin. С нулём картинка начинает грузиться ровно в момент появления, и пользователь видит пустой блок долю секунды. Значение 200px 0px даёт браузеру фору: загрузка стартует, когда до блока остаётся 200 пикселей скролла. На быстром флике вверх это разница между плавной лентой и лесенкой из пустых прямоугольников.
Где ломается
- Забыли
unobserve— коллбэк продолжает срабатывать при каждом пересечении, на длинной ленте это лишняя работа на каждом скролле. - Наблюдатель создаётся в цикле — распространённая ошибка: один
IntersectionObserverна все элементы, а не по экземпляру на каждый. Один наблюдатель тянет тысячи целей нормально. - Нет фолбэка на no-JS — если скрипт не загрузился, фоновые картинки не появятся вовсе. Для критичного контента дублируйте в
<noscript>или отдавайте фон сразу.
Подход 3. content-visibility — про рендеринг, не про сеть
После первых двух шагов сеть перестала быть узким местом, но прокрутка длинной ленты на слабом Android подтормаживала: браузер держал в дереве отрисовки все 400 карточек, даже невидимые. Тут помогает CSS:
.product-card {
content-visibility: auto;
/* резерв под размер, пока блок не отрисован — иначе прыгает скролл */
contain-intrinsic-size: auto 420px;
}
content-visibility: auto говорит браузеру пропускать вёрстку и отрисовку блока, пока тот за пределами вьюпорта. contain-intrinsic-size подсказывает предполагаемую высоту, чтобы полоса прокрутки не скакала при появлении содержимого.
Это не заменяет ленивую загрузку картинок: сетевой запрос всё равно управляется loading="lazy". content-visibility экономит время на layout и paint — то есть главный поток, а не трафик.
Где ломается
Поиск по странице (Ctrl+F) и якорные ссылки могут вести себя неожиданно: содержимое пропущенных блоков в старых сборках не всегда индексировалось для поиска. В актуальных Chrome это исправлено, но проверьте в Safari — поддержка появилась позже (17.4), в более ранних версиях свойство игнорируется, и деградация мягкая: просто нет ускорения.
Что выбирать под задачу
| Задача | Инструмент | Цена |
|---|---|---|
Картинки <img> ниже сгиба | loading="lazy" | Ноль JS. Нужны width/height |
| Фоны, iframe, подгрузка HTML | IntersectionObserver | JS + фолбэк на no-JS |
| Тормозит прокрутка длинного списка | content-visibility | Нужен contain-intrinsic-size, разная поддержка |
| LCP-картинка первого экрана | обычная загрузка + fetchpriority="high" | Никакой ленивости — станет хуже |
Критерий простой: если проблема в трафике и запросах — loading="lazy" и IntersectionObserver; если в плавности прокрутки при уже загруженном контенте — content-visibility. Часто нужны оба слоя одновременно, как в нашем каталоге.
Итог замеров
Условия: Chrome 120, эмуляция Fast 4G, каталог 400 карточек, картинки WebP уже сжаты.
- Трафик первого экрана: 18 МБ → 1,4 МБ
- LCP: 4,2 с → 1,6 с
- Число запросов до первого экрана: ~400 → ~15
Цифры справедливы для этого проекта и этих условий — свои мерьте на реальных данных, а не переносите как норму.
Что делать дальше
После внедрения смотрите в проде на три показателя: LCP и CLS в Chrome UX Report или через web-vitals в реальных сессиях, и долю картинок, которые всё-таки грузятся раньше времени. Проверьте, что LCP-элемент не попал под lazy — это самая частая регрессия. Следующий шаг после ленивой загрузки — приоритизация запросов через fetchpriority и предзагрузка LCP-картинки через <link rel="preload">: это уже отдельная тема про порядок ресурсов, здесь мы её не разворачивали.
Редактор изображений: Убрать лишнее, заменить фон, добавить объект, расширить кадр — на вашем изображении.
Спросить за 20 ₽Источники: MDN — IntersectionObserver API, web.dev — Browser-level image lazy loading
Частые вопросы
У меня Safari старее 17.4 — content-visibility не работает?
Что если IntersectionObserver не сработал и фоновые картинки не появились?
Почему после loading=lazy страница стала дёргаться при скролле?
Можно ли ленить фоновую картинку через loading=lazy?
Ленивая загрузка мешает индексации картинок поисковиком?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.