Изображения: главный вес страницы

Картинки — самый тяжёлый тип ресурса на средней странице и обычно тот самый элемент, по которому браузер меряет LCP. Разберём, как отдать их в разы легче и при этом не испортить метрику.

LCP-картинка — изображение, которое браузер счёл самым крупным видимым элементом первого экрана. Скорость её загрузки почти всегда и есть ваш Largest Contentful Paint.

Зачем вообще этим заниматься

Откройте DevTools, вкладку Network, поставьте фильтр Img и посмотрите на сумму внизу. По данным HTTP Archive изображения год за годом держат первое место по весу среди всех типов ресурсов — порядка мегабайта на медианной странице. JavaScript вреднее для отзывчивости, но по байтам лидируют картинки.

И это самая дешёвая оптимизация из всех, что есть. Не нужно переписывать код, менять фреймворк и спорить с бэкендом. Нужно поменять формат, добавить пару атрибутов и один раз настроить сборку. Типичный результат на реальном проекте: hero-баннер был 1,8 МБ PNG на 4000 пикселей ширины, стал 90 КБ AVIF на 1600 — LCP упал с 4,2 до 1,6 секунды.

Почти весь лишний вес берётся из четырёх ошибок: фотографию сохранили в PNG, отдают одну картинку на все экраны, не сжали, не зарезервировали место. Разберём по порядку.

Форматы: что чем заменять

ФорматДля чегоВес относительно JPEG
JPEGфото, если нужен максимально совместимый фолбэкбазовая точка отсчёта
PNGтолько скриншоты интерфейса, пиксель-арт, прозрачность без градиентовдля фото — в разы тяжелее, так делать нельзя
WebPуниверсальная замена JPEG и PNGобычно на 25–35% легче
AVIFфото и сложные градиенты, лучшее сжатиечасто вдвое легче при том же качестве
SVGиконки, логотипы, схемывекторный, вес зависит от числа узлов

WebP и AVIF поддерживают все актуальные браузеры, так что <picture> с фолбэком нужен уже скорее для спокойствия, чем по необходимости. Но если хочется страховки — вот честная запись, где браузер сам берёт первый формат, который понимает:

<picture>
  <source srcset="/img/hero.avif" type="image/avif">
  <source srcset="/img/hero.webp" type="image/webp">
  <img src="/img/hero.jpg" width="1200" height="675" alt="Панель управления сервисом">
</picture>

Порядок <source> важен: браузер идёт сверху вниз и останавливается на первом подходящем type. Поставите WebP выше AVIF — AVIF никто никогда не увидит.

Адаптивные картинки: srcset и sizes

Одна картинка на все устройства — это либо мыло на ретине, либо 1600 пикселей, отправленные на телефон, где место под неё 360. Решение — дать браузеру набор вариантов и подсказать, какой ширины будет слот.

<img
  src="/img/hero-800.jpg"
  srcset="/img/hero-400.jpg 400w,
          /img/hero-800.jpg 800w,
          /img/hero-1600.jpg 1600w"
  sizes="(max-width: 600px) 100vw, 800px"
  width="800"
  height="450"
  fetchpriority="high"
  alt="Скриншот дашборда с графиком продаж">

Построчно:

  • srcset с дескриптором w — список файлов и их настоящая ширина в пикселях. Не «размер на экране», а ширина самого файла.
  • sizes — обещание браузеру: «на экранах уже 600px картинка займёт всю ширину окна, иначе — ровно 800 CSS-пикселей».
  • width и height — резерв места, про них ниже.
  • fetchpriority="high" — «это герой, качай первым».
  • src — фолбэк для древних браузеров, туда кладём средний вариант.

Дальше браузер считает сам: берёт ширину слота из sizes, умножает на плотность экрана (DPR) и выбирает первого кандидата, который не меньше нужного. Логику легко воспроизвести — запустите этот код и посмотрите, что выберется:

const candidates = [400, 800, 1600];

function pick(cssWidth, dpr) {
  const need = cssWidth * dpr;
  return candidates.find((w) => w >= need) ?? candidates[candidates.length - 1];
}

console.log(pick(360, 2)); // телефон с ретиной: нужно 720
console.log(pick(800, 1)); // обычный ноутбук
console.log(pick(800, 2)); // ноутбук с ретиной
console.log(pick(1200, 3)); // ничего подходящего нет, берём максимум

Результат:

800
800
1600
1600

Отсюда главная ловушка sizes: если вы соврали браузеру, он честно скачает не то. Классика — оставить sizes="100vw" на картинке, которая на самом деле лежит в колонке шириной 320px. На ноутбуке с ретиной браузер посчитает 1440 × 2 = 2880 и утащит самый большой файл, чтобы показать его в четверть размера.

Ленивая загрузка — и почему не для героя

Атрибут loading="lazy" откладывает запрос, пока картинка не окажется недалеко от области видимости. Одна строчка, поддерживают все браузеры, и на длинной странице она экономит мегабайты:

<img src="/img/otzyv-3.webp" width="600" height="400"
     loading="lazy" decoding="async" alt="Отзыв клиента">

А теперь — почему её категорически нельзя вешать на LCP-картинку, хотя соблазн «полайзить всё разом» огромен.

Браузер начинает качать ресурсы ещё до того, как построит DOM: за это отвечает preload scanner — он бежит по сырому HTML и вытаскивает адреса картинок, скриптов и стилей. Но loading="lazy" запрещает ему трогать такую картинку: решение «пора грузить» принимается только после того, как посчитан layout, а layout невозможен без CSS. В итоге вы своими руками отодвигаете запрос героя на конец цепочки. На реальных проектах это стабильно 300–800 мс к LCP.

Правило простое: всё, что попадает в первый экран, грузится eager (это значение по умолчанию, писать его не нужно), герою добавляем fetchpriority="high". Всё, что ниже сгиба, — lazy.

Размеры: как не заработать CLS

Если у <img> нет размеров, до загрузки файла браузер считает его высоту нулевой. Картинка приезжает — и весь текст под ней прыгает вниз. Это Cumulative Layout Shift, и лечится он атрибутами width и height: браузер выводит из них aspect-ratio и держит место заранее.

Атрибуты не мешают адаптивности, если в CSS есть привычная пара строк:

img {
  max-width: 100%;
  height: auto;
}

Без height: auto атрибут высоты зафиксирует картинку жёстко и она сплющится. Ставьте настоящие пропорции исходника: width="800" height="450" для 16:9, а не круглые числа наугад.

Сжатие: конкретные команды

Качество 100 не нужно никому. Для фотографий комфортный коридор — 75–82; для картинок под ретину (которые всё равно ужимаются вдвое) можно смело брать 60, разницы не увидит никто.

# WebP
cwebp -q 80 hero.jpg -o hero.webp

# AVIF (кодировщик из libavif)
avifenc --min 20 --max 30 hero.jpg hero.avif

# Пакетный ресайз + конвертация через sharp-cli
npx sharp-cli -i "src/img/*.jpg" -o dist/img --format webp --width 1600

В нормальном проекте это делает не человек, а сборка: next/image, nuxt/image, imgproxy, Cloudinary — любой из них умеет отдавать нужный формат и размер по запросу. Ручные команды нужны, чтобы понимать, что именно происходит под капотом, и чтобы быстро починить одну конкретную картинку.

Как это работает

Сложите картину целиком. Браузер получает первые байты HTML и запускает preload scanner — тот находит <img> с src и srcset и ставит запросы в очередь ещё до парсинга CSS и выполнения JS. Приоритет всех картинок изначально низкий; после первого layout браузер поднимает приоритет тем, что попали во вьюпорт. fetchpriority="high" просто сообщает об этом сразу, не дожидаясь layout.

Из этой механики следуют два вывода, которые ломают LCP чаще всего. Первый: картинка, заданная в CSS как background-image, для preload scanner не существует — он читает HTML, а не стили. Герой на фоновой картинке всегда медленнее героя на <img>. Второй: картинка, которую вставляет JS (карусель, ленивый компонент), тоже невидима для сканера и грузится в самом конце.

Отсюда практическое следствие: LCP-элемент должен быть обычным <img> прямо в разметке. Если по каким-то причинам этого не избежать, поставьте хотя бы <link rel="preload" as="image"> в <head>.

Частые ошибки

  • loading="lazy" на hero-картинке — самый дорогой однострочник в вебе.
  • Герой сделан фоном через CSS: preload scanner его не видит, LCP уезжает на секунду.
  • sizes="100vw" у картинки в узкой колонке — браузер тянет вариант вчетверо тяжелее нужного.
  • Конвертировали в WebP, но забыли уменьшить: 4000 пикселей ширины в слоте на 600.
  • Фотография в PNG. Скриншот интерфейса в PNG — нормально, фотография — никогда.
  • Нет width/height — получаете CLS на пустом месте.
  • Иконки как PNG-спрайты вместо SVG или, того хуже, как отдельные запросы по 3 КБ каждый.
  • fetchpriority="high" навешан на пять картинок сразу: если приоритетно всё, приоритетно ничто.

Итоги

  • Картинки — самый большой и самый дешёвый в исправлении вес страницы.
  • AVIF или WebP вместо JPEG/PNG дают минус 30–50% байт без единой строчки кода.
  • srcset + честный sizes отдают каждому устройству свой размер; соврать в sizes хуже, чем не писать его вовсе.
  • Ниже сгиба — loading="lazy". В первом экране — никогда, там fetchpriority="high".
  • width и height в разметке — бесплатная защита от CLS.
  • LCP-элемент держите обычным <img> в HTML, а не фоном в CSS и не вставкой из JS.
Проверьте себя
1. Почему loading="lazy" на LCP-картинке ухудшает метрику?
AЛенивая загрузка сжимает картинку сильнее и портит качество
BPreload scanner не запрашивает такую картинку заранее — решение о загрузке принимается только после расчёта layout, то есть после CSS
CБраузер грузит lazy-картинки в один поток по очереди
Dloading="lazy" запрещает использовать srcset
2. Картинка лежит в колонке шириной 320 CSS-пикселей, но в разметке указано sizes="100vw". Что произойдёт на ноутбуке с ретиной и окном 1440px?
AНичего особенного: браузер всё равно измерит реальный слот и возьмёт мелкий вариант
BБраузер посчитает нужную ширину как 1440 × 2 и скачает самый большой файл из srcset
CБраузер проигнорирует srcset и возьмёт то, что в src
DКартинка растянется на всю ширину окна