Типовая страница каталога или новостного раздела легко набирает пять мегабайт, и львиная доля веса приходится на картинки, которые читатель увидит через минуту, а то и не увидит вовсе. Атрибут loading="lazy" закрывает вопрос одной строчкой в HTML: браузер откладывает сетевой запрос до момента, когда изображение приблизится к видимой области. Приём давно числится в базовом наборе советов по Core Web Vitals и на длинных страницах экономит целые мегабайты. Но у механизма есть слепая зона, о которой вспоминают уже после жалоб на просевшую метрику Largest Contentful Paint. Одна и та же строчка кода разгружает хвост страницы и одновременно отнимает сотни миллисекунд у баннера первого экрана. Ниже описаны механика атрибута, выгода в конкретных цифрах и рабочая схема, при которой ленивая загрузка помогает, а не портит результат.
Нативная ленивая загрузка работает без JavaScript и подключается одним атрибутом
Раньше отложенную загрузку строили на скриптах с IntersectionObserver, сторонних библиотеках и обработчиках события scroll. Кода требовалось много, сюрпризов ещё больше: сама библиотека весила десятки килобайт и подключалась с задержкой. С 2019 года эту работу выполняет сам браузер. Chrome подхватил атрибут loading в версии 76, Edge в 79, Firefox в 75, Safari подключился в версии 15.4. Поддержка сегодня близка к 96 процентам, поэтому запасной вариант на JavaScript остался нужен разве что легаси-проектам.
Выглядит внедрение скромно:
<img src="/catalog/sofa-gray.webp" alt="Серый диван в интерьере" width="800" height="600" loading="lazy">
Атрибут понимает три значения. auto оставляет решение браузеру и работает по умолчанию, eager загружает файл сразу после разбора разметки, lazy откладывает запрос до приближения картинки к видимой области. То же значение работает и для встраиваемых iframe, что удобно для карт, видео и опросов в середине материала.
Границы у механизма честные. Атрибут не сжимает файлы, не превращает JPEG в WebP и не подбирает размеры под экран, он лишь сдвигает момент сетевого запроса и декодирования. Сжатие остаётся задачей разработчика, а ленивая загрузка убирает из стартового этапа то, чего первый экран не касается. Рядом живёт соседний атрибут decoding="async", выносящий декодирование из основного потока, и связка двух свойств заметно снижает нагрузку при открытии страницы.
Расстояние предварительной загрузки зависит от браузера и типа сети
Ключевой вопрос механики звучит просто: в какой момент браузер решает, что картинку пора грузить. За это отвечает порог дистанции, расстояние от видимой области, при котором стартует сетевой запрос. Значения зашиты в движке и различаются по браузерам и сетям. В Chrome и Edge порог равен 1250 пикселям на быстрых подключениях, а на медленных, где заранее угадать прокрутку труднее, доходит до 2500 пикселей. Safari и Firefox держат собственные значения, но логика везде одна: картинка должна успеть прийти к моменту, когда читатель до неё доскроллит.
Из порога следует практический вывод. Изображение сразу под первым экраном загрузится почти мгновенно, потому что попадает в зону предзагрузки ещё на старте, и атрибут на нём почти ничего не экономит. Зато карточка из середины длинной страницы останется нетронутой, пока читатель не пройдёт половину списка, а глубинные блоки не запросятся вовсе, если человек закрыл вкладку после первых строк. Порог адаптивен: один и тот же сайт на смартфоне в метро и на домашнем оптоволокне ведёт себя по-разному, и в этой гибкости главная сила механизма. Ручной скрипт с фиксированным отступом в 200 пикселей такого себе позволить не мог.
Экономия трафика и разгрузка основного потока в цифрах
Теперь о том, ради чего всё затевалось. Типовая страница каталога выглядит так: 40 изображений суммарным весом 4,8 мегабайта, из них первому экрану нужны три картинки на 260 килобайт. Без ленивой загрузки браузер, разглядев в разметке все сорок тегов img, заводит запрос по каждому, и по HTTP/2 они уходят параллельно. Стартовая загрузка весит около 5,1 мегабайта вместе со стилями и скриптами.
После включения loading="lazy" картина меняется. Сразу запрашиваются только три нужные картинки, стартовый объём падает примерно до 700 килобайт, экономия переваливает за 80 процентов. Читатель, просмотревший каталог целиком, в итоге скачает те же 4,8 мегабайта, но читатель, ушедший после первого экрана, заплатит за трафик лишь около 700 килобайт вместо пяти мегабайт. При двухстах тысячах просмотров в месяц разница измеряется сотнями гигабайт на стороне сервера и заметными суммами на стороне мобильных читателей с тарифицированным трафиком.
Второй эффект менее заметен на глаз, но для Core Web Vitals он весит больше. Каждое изображение после загрузки декодируется, декодирование идёт в основном потоке и конкурирует с выполнением скриптов и построением интерфейса. На среднем смартфоне декод одного снимка занимает от 15 до 40 миллисекунд, сорок снимков блокируют поток суммарно на 600-1500 миллисекунд. Ленивая загрузка рассредоточивает декодирования по моментам прокрутки, когда интерфейс уже построен и поток свободнее. Стартовая очередь короче, лабораторный Total Blocking Time ниже, а метрике Interaction to Next Paint достаётся больше запаса на обработку нажатий.
Ловушка с hero изображением замедляет Largest Contentful Paint
А теперь о слепой зоне. Largest Contentful Paint фиксирует момент отрисовки самого крупного элемента видимой области, и очень часто им оказывается hero изображение, широкий баннер под шапкой. Порог качества жёсткий, не больше 2,5 секунды на уровне 75 перцентиля загрузок. Сюда и попадает типичная ошибка: разработчик ставит loading="lazy" всем картинкам разом, включая баннер.
Ломает метрику механика предварительного сканирования. До конца разбора HTML браузер гоняет по сырой разметке быстрый сканер, который заранее находит ресурсы и заводит запросы, не дожидаясь построения страницы. Изображения с loading="lazy" сканер пропускает намеренно: их судьба зависит от позиции прокрутки, о которой на этапе сырой разметки ничего не известно. Запрос стартует только после того, как парсер дойдёт до тега, а движок вычислит раскладку и убедится, что картинка рядом с видимой областью. На широком канале задержка почти незаметна, но на мобильных сетях каждая стадия обходится дорого: в лабораторных прогонах на троттлинге Slow 4G атрибут на hero изображении отодвигает момент запроса на 300-700 миллисекунд, на Fast 3G счёт переваливает за секунду. Исключений для первого экрана нет, пропуск сканера работает для всех.
Вторая беда кроется в приоритете. Запросы ленивых картинок браузер помечает низким, и баннер встаёт в очередь за стилями, шрифтами и скриптами, хотя именно он и есть ключевой элемент отрисовки. Арифметика выходит удручающая. Пример с баннером в 160 килобайт WebP: при обычной загрузке метрика LCP в лаборатории держится около 2,3 секунды, с ленивым атрибутом уезжает к 3,0 секунды. Страница экономит мегабайты внизу разметки и одновременно теряет проходной балл Core Web Vitals, потому что порог 2,5 секунды остаётся позади. В отчёте виден парадокс: вес первой загрузки упал, а оценка просела.
Правильная схема это eager для hero и lazy для остального
Рабочее правило умещается в одну строку: всё, что попадает в первый экран, грузится обычным способом, всё ниже него получает loading="lazy". Порядок внедрения выглядит так:
- выделить hero изображение и остальные картинки первого экрана, им атрибут loading не назначается вовсе либо выставляется eager;
- добавить hero тегу fetchpriority="high" и явные размеры width с height, чтобы сразу зарезервировать место в раскладке;
- присвоить loading="lazy" изображениям ниже первого экрана, включая карусели и длинные списки карточек;
- выставить каждой картинке width и height либо CSS свойство aspect-ratio, чтобы отложенная загрузка не двигала раскладку и не портила CLS;
- перепроверить первый экран на смартфоне: на узких дисплеях в видимую область помещается меньше контента, а кандидатов на ленивую загрузку прячется больше.
Код главного баннера после правок выглядит так:
<img src="/hero/summer.webp" alt="Летняя коллекция" width="1600" height="800" fetchpriority="high" decoding="async">
Рядом в head добавляют предварительную загрузку:
<link rel="preload" href="/hero/summer.webp" as="image" type="image/webp" fetchpriority="high">
Если баннер адаптивный и собран на srcset, у link есть парные атрибуты imagesrcset и imagesizes, иначе предзагрузка и тег img выберут разные файлы, и браузер скачает картинку дважды, о чём предупредит консоль. Остальным изображениям достаточно ленивого атрибута с явными размерами:
<img src="/catalog/plate.webp" alt="Тарелка ручной работы" width="600" height="450" loading="lazy" decoding="async">
Атрибут fetchpriority high ускоряет главный ресурс страницы
С 2022 года у разработчика есть прямой способ сказать браузеру, какой ресурс главнее прочих. Атрибут fetchpriority принимает значения high, low и auto, Chrome и Edge поддерживают его с версии 101, позже подключились Safari и Firefox. Без подсказки изображения стартуют с низким приоритетом, и браузер поднимает его только после построения раскладки, убедившись, что картинка в видимой области. Для баннера это поздно, очередь к тому моменту уже собрана. fetchpriority="high" двигает запрос в голову очереди с первой секунды, ещё до того, как парсер встретит сам тег.
Связка preload с fetchpriority="high" даёт самый быстрый сценарий: запрос баннера уходит почти одновременно со стилями, и на Slow 4G метрика LCP возвращается к отметке 2,1 секунды при том же весе файлов. Оговорка простая: высокий приоритет не безлимитная кнопка. Назначать его полагается одному ресурсу страницы, самому крупному элементу первого экрана. Десять тегов с fetchpriority="high" просто вернут исходную очередь с другим ярлыком, и баннер снова будет ждать своей очереди.
Отдельный случай касается hero картинки, вставленной фоном через CSS. Атрибут loading на неё не действует вообще, браузер узнаёт о файле после загрузки и разбора стилей, что отнимает у LCP ещё больше времени. Здесь предварительная загрузка через link rel="preload" остаётся единственным рычагом, и забыть о ней дороже всего.
Частые ошибки при внедрении ленивой загрузки изображений
Первое место в антирейтинге занимает атрибут на первом экране: баннер, первый слайд карусели, крупное превью в шапке карточки товара. История разобрана выше, но промах повторяется так часто, что его выносят в отдельный пункт релизного чеклиста. Вторая по частоте ошибка связана с отсутствием размеров. Ленивая картинка без width и height сначала не занимает места, а после прихода файла сдвигает текст вниз. Баннер шириной 1600 и высотой 800 пикселей без резервирования места даёт сдвиг раскладки с показателем CLS в диапазоне 0,15-0,25 при проходном пороге 0,1, и отложенная загрузка вместо ускорения приносит штраф.
Третья ошибка кроется в рассинхроне предзагрузки. Тег link с rel="preload" без атрибутов imagesrcset и imagesizes при адаптивном баннере скачивает один файл, а img выбирает другой, и трафик удваивается вместо экономии. Четвёртая связана с легаси: старая JavaScript библиотека и нативный атрибут работают одновременно, два механизма спорят за одну картинку, скрипт подставляет адрес через IntersectionObserver, атрибут ждёт своего порога, и итог непредсказуем. Пятая касается забытой проверки на смартфоне: мобильный экран вмещает меньше картинок, и часть контента, который на мониторе грузился мгновенно, на телефоне уезжает глубже в зону ленивой загрузки либо наоборот.
Инженерная правда в том, что у ленивой загрузки нет дурной репутации, есть узкие места. Одна строчка в шаблоне списка товаров экономит мегабайты и снижает нагрузку на основной поток, а один баннер требует противоположного обращения: обычная загрузка, высокий приоритет, предзагрузка и зарезервированное место в раскладке. Пара часов работы по схеме выше превращает loading="lazy" из лотереи в предсказуемый инструмент: вес первой отрисовки падает на 70-80 процентов, а Largest Contentful Paint остаётся в зелёной зоне. Дальше остаётся прогнать страницу через лабораторные тесты, сверить полевые отчёты Core Web Vitals после релиза и не трогать то, что уже работает.