Обробка зображень для швидких сайтів: аспектне відношення, стилі lazy loading, розмиті заповнювачі

Було корисно?

На вашому ноутбуці сайт здається «нормальним». А потім ви відкриваєте його на середньому телефоні в готельному Wi‑Fi і спостерігаєте, як сторінка тремтить ніби нервує, а головне зображення приходить модно запізно і ламає ваш LCP.

Це не якийсь містичний фронт‑енд феномен. Це обробка зображень: відсутні аспектні відношення, lazy loading зроблений із тонкістю кувалди та заповнювачі, що виглядають дешево або блокують рендеринг. Виправлення нудні, механічні й абсолютно того варті.

Три правила: резервуйте простір, завантажуйте свідомо, декодуйте плавно

Якщо ви нічого не запам’ятаєте, запам’ятайте ці правила. Вони безпосередньо відповідають за продакшн‑помилки.

1) Резервуйте простір (aspect ratio — не опція)

Браузер не може правильно розкласти сторінку, якщо ваші зображення — сюрприз. Без атрибутів width і height або aspect‑ratio він гадає. Потім дізнається правду після мережевого запиту, і макет зміщується. Це CLS. Користувачам це не подобається. Google це вимірює. У вашу службу підтримки це теж надходить.

2) Завантажуйте свідомо (lazy load потрібних речей, а не важливих)

Lazy loading — інструмент, а не моральна чеснота. Lazy loading головного зображення, що бачать вище згину, — як зачинити вхідні двері зсередини: технічно безпечно, функціонально катастрофічно.

3) Декодуйте плавно (заповнювачі та поведінка декодування важливі)

Навіть після отримання байтів, зображення потрібно декодувати. Величезні JPEG можуть завантажити основний потік. Заповнювачі допомагають користувачам витримати чекання, а правильне декодування запобігає пригальмовуванням під час прокрутки.

Тут також застосовується надійний принцип з операцій: «Надія — не стратегія.» — парафраз відомої думки, приписуваної Гіну Крензу. Якщо поведінка зображень залежить від «ймовірно, воно завантажиться швидко», то в умовах мережі клієнта воно цього не зробить.

Короткі факти та історичний контекст (який справді має значення)

  • Раніше браузери не мали способу резервувати місце для зображення, якщо ви не вказували атрибути width і height. Люди перестали це робити заради «чистого HTML», і з’явився CLS.
  • JPEG старший за веб: стандартизований на початку 1990‑х. Він досі всюди, бо добре стискає фото і підтримується скрізь.
  • PNG з’явився в середині 1990‑х як безпатентна заміна GIF. Чудовий для безвтратної графіки та прозорості, але поганий для великих фотографічних банерів.
  • WebP з’явився у 2010‑му щоб зменшити байти зображень, і йому це вдалося. Але він також приніс десятиліття умовної доставки і фольклору на кшталт «чому в Safari пусто?».
  • AVIF з’явився пізніше (побудований на AV1) і може обходити WebP для багатьох фото при схожій якості. Кодування повільніше; ваш пайплайн має це витримувати.
  • Нативний lazy loading (loading="lazy") з’явився масово в сучасних браузерах приблизно 2019–2020 рр., замінивши дрібний ринок обробників прокрутки й сумних рішень.
  • IntersectionObserver (приблизно 2017) зробив JS‑lazy loading менш огидним, уникаючи спаму подій прокрутки і допомагаючи браузерам оптимізуватися.
  • Core Web Vitals (2020) зробили LCP/CLS/INP мовою бюджетів. Зображення — головний герой у двох із цих трьох метрик.

Аспектне відношення: припиніть відправляти зсуви макета

Зсув макета, спричинений зображеннями, — це самостійно спричинена проблема. Браузер не намагається вам нашкодити. Він прагне розкласти сторінку з неповною інформацією.

Пастка «просто встановити CSS width:100%»

Якщо ваш HTML — <img src="..."> без розмірів, а ваш CSS каже img { max-width: 100%; height: auto; },
ви повідомили браузеру: «Це буде адаптивним, але я не скажу, якої висоти воно буде, поки ти не завантажиш його».

Результат: браузер рендерить текст, потім зображення приходить і штовхає все вниз. Це CLS. Саме тому деякі сайти здаються «стрибаючими».

Що робити натомість (виконайте одне з цього, але не нічого)

  1. Встановлюйте атрибути width і height для кожного <img>. Сучасні браузери використовують їх для обчислення аспектного відношення і резервують місце, навіть коли зображення адаптивне через CSS.
  2. Використовуйте CSS aspect-ratio для контейнерів, що не є img (наприклад, фонів або коли застосовуєте picture з складним арт‑дирекшном).
  3. Використовуйте обгортку із внутрішнім співвідношенням (хак з padding-top) лише якщо змушені підтримувати старі браузери або зламану CMS. Це працює, але створює технічний борг.

Конкретний шаблон: нудно правильний спосіб

Помістіть піксельні розміри в HTML. Дозвольте CSS масштабувати.

cr0x@server:~$ cat /tmp/example.html
<img
  src="/images/product-800.jpg"
  width="800"
  height="600"
  alt="Product photo"
  style="max-width:100%;height:auto;"
>

Така пара width/height резервує коробку 4:3 ще до завершення мережевого запиту. Сторінка стає стабільною. Ваш CLS перестає бути таким захопливим.

Коли аспектні відношення змінюються (проблема рулетки CMS)

У продакшні зображення — не акуратний набір 16:9 прямокутників. Маркетинг може завантажити портрет у ландшафтний слот. «Правильне» виправлення — політика: вимагати аспектного відношення при завантаженні або генерувати безпечні кропи на сервері.

Інженерне рішення — проектувати компоненти, що толерують варіативність:

  • Використовуйте object-fit: cover, коли обрізка прийнятна.
  • Використовуйте object-fit: contain, коли важлива повна видимість (але приймайте letterboxing).
  • Визначте, що прийнятно, і не дозволяйте цьому траплятися випадково.

Стилі lazy loading: нативний, JS і поганий варіант

Lazy loading — це оптимізація трафіку і тактика стабільності рендерингу. Це не перформанс‑трофей, який треба вішати на кожне зображення. Неправильно застосований lazy loading збільшить LCP і зробить ваш сайт повільнішим там, де це важливо.

Нативний lazy loading: ваш дефолт

Для зображень під згином використовуйте:

cr0x@server:~$ cat /tmp/lazy.html
<img src="/images/gallery-1200.jpg" width="1200" height="800" loading="lazy" decoding="async" alt="Gallery">

loading="lazy" дозволяє браузеру вирішувати, коли робити запит, на основі відстані від вікна перегляду та інших евристик. Зазвичай це краще, ніж ваша ручна математика прокрутки.

Коли не треба lazy load

Не робіть lazy loading для:

  • Головного зображення, яке ймовірно є кандидатом на LCP.
  • Критичних іконок інтерфейсу, що з’являються відразу (якщо вони не інлайновані).
  • Зображень, видимих при першому відмальовуванні, особливо на поширених розмірах вікон перегляду.

Якщо ви віддаляєте LCP‑зображення lazy loading‑ом, ви змушуєте браузер чекати, доки евристики лейаута/скролу вирішать, що воно потрібне. По суті, ви просите повільніший старт ввічливо.

Жарт №1: Lazy‑loading головного зображення — як покласти вогнегасник у запечатаний шафа з написом «Розбий скло у разі пожежі». Пожежа дотримається вашого процесу.

JS lazy loading: лише коли потрібна просунута поведінка

JS може знадобитися, коли:

  • Ви міняєте src залежно від підказок клієнта або налаштувань користувача.
  • Потрібно координуватися з віртуалізованими списками.
  • Ви реалізуєте кастомне прогресивне завантаження з контролем пріоритету.

Використовуйте IntersectionObserver. Не прив’язуйте обробник прокрутки, що читає лейаут на кожному тіку. Цей шлях веде до пропущених кадрів і самокритики.

«Lazy loading через CSS background-image» (не робіть так)

Фонові зображення не є частиною моделі завантаження зображень у браузері. Ви втрачаєте srcset, нативний lazy loading та часто втратите легкий preload і підказки для декодування.

Якщо зображення передає контент, використовуйте <img> або <picture>. Фонові зображення — для декору. Так, це питання варте битви.

Розмиті заповнювачі (blur-up): відчуття швидкості без брехні

Blur‑up — це мистецтво показати крихітне, розмите прев’ю одразу, поки вантажиться реальне зображення. Це не магія. Це лише управління нетерпінням користувача дешевим першим відмальовуванням.

Що таке blur‑up (і чого це не є)

  • Є: маленький заповнювач (зазвичай 10–30px шириною, сильно стиснений), розтягнутий і розмитий, щоб заповнити резервований блок.
  • Немає: привід відправляти 4 МБ зображень, бо «користувачі не помітять». Вони помітять. Їхній тарифний план теж помітить.

Варіанти реалізації

  1. LQIP (low-quality image placeholder): крихітний JPEG/WebP у data URI або маленький файл, що кешується CDN.
  2. Blurhash: зберігайте короткий рядок, що представляє розмите наближення; рендерте його на клієнті або сервері як canvas чи SVG.
  3. SVG домінуючого кольору: легкий «середній колір» як заповнювач. Менш приємно, але дуже дешево.

Операційна реальність: заповнювачі можуть стати підривним ножем

Inline base64‑заповнювачі збільшують розмір HTML. Це може затримати TTFB‑до‑першого‑рендеру, особливо на серверно‑рендерених сторінках, де HTML — критичний вантаж. Якщо ви inlne‑ите 2КБ заповнювач для 40 зображень, ви тихо додали 80КБ до стиснення й маркування.

Віддавайте перевагу:

  • Інлайнувати заповнювачі лише для зображень вище згину або невеликого курованого набору.
  • Для галерей зберігати крихітні заповнювачі як окремі кешовані ресурси або використовувати Blurhash рядки (дуже малі), якщо їх можна дешево відрендерити.

Blur‑up + aspect ratio: нероздільні

Blur‑up без резервування простору — просто розмите відображення зсуву макета. Правильна послідовність:

  1. Резервуйте простір за допомогою width/height або aspect-ratio.
  2. Намалюйте заповнювач (розмите прев’ю) одразу.
  3. Замініть на фінальне зображення, коли воно задекодоване.

Адаптивні зображення: srcset, sizes і податок на трафік

Адаптивні зображення — це місце, де перформанс тихо виграють або програють. Не завдяки геройствам, а завдяки правильному srcset і sizes.

Браузер не може вгадати ваш лейаут

Якщо ви даєте srcset, але пропускаєте або неправильно вказуєте sizes, браузер помилиться і завантажить невідповідний кандидат. Часто це надто велике зображення. Це марнотратство трафіку і повільніший LCP.

Конкретний приклад: дескриптори ширини

cr0x@server:~$ cat /tmp/responsive.html
<img
  src="/images/hero-800.jpg"
  srcset="/images/hero-400.jpg 400w,
          /images/hero-800.jpg 800w,
          /images/hero-1200.jpg 1200w,
          /images/hero-1600.jpg 1600w"
  sizes="(max-width: 600px) 100vw, 600px"
  width="1600"
  height="900"
  alt="Hero image"
  fetchpriority="high"
>

Це означає: на малих екранах зображення займе всю ширину вікна перегляду. На більших — воно відобразиться шириною 600px. Браузер вибирає файл, близький до цього відображуваного розміру, помноженого на DPR.

Арт‑дирекшн: picture — ваш друг

Коли на мобайлі потрібен інший кроп, ніж на десктопі, використовуйте <picture>, а не надійтеся, що object-fit прочитає ваші думки.

Перемовини форматів

Використовуйте джерела в picture для AVIF/WebP і відкат до JPEG/PNG. Не робіть UA‑sniffing. Це крихке і ускладнює реагування на інциденти.

Критичний шлях: LCP‑зображення, preload і пріоритет

На більшості сторінок є одне зображення, що важить більше за інші: кандидат на LCP. Ставтеся до нього як до першокласної залежності.

Правила для LCP‑зображення

  • Не робіть для нього lazy loading.
  • Переконайтесь, що воно доступне рано в HTML (уникайте приховування за клієнтським рендерингом, якщо можете).
  • Використовуйте fetchpriority="high" на <img>, коли це доречно.
  • Розгляньте rel="preload", якщо браузер знаходитиме його запізно (наприклад, коли це фон у CSS — ще одна причина не використовувати фон).

Preload: гострий інструмент, використовуйте обережно

Передзавантаження занадто великої кількості зображень відбирає пропускну здатність у CSS/JS і може зробити все повільнішим. Preload одного‑двох — можливо; більше — вже ризик.

Жарт №2: Preload восьми зображень — це як викликати вісім таксі, бо ви поспішаєте. Ви запізнитеся, але компанія таксі буде дуже задоволена.

Декодування і рендеринг

Використовуйте decoding="async" для більшості некритичних зображень. Це підказує браузеру декодувати поза основним потоком, коли можливо.
Для LCP‑зображення браузер може ігнорувати підказку. Це нормально; ви висловлюєте намір, а не видаєте повістку.

Пайплайн зображень і реалії зберігання/CDN

Фронтенд‑розмітка — лише половина справи. Інша половина — ваш пайплайн зображень: структура зберігання, ключі кешування, трансформації та операційна гігієна.

Генеруйте деривати, не Resize на льоту безкоштовно

Динамічне ресайзування на краю може бути чудовим — поки не отримаєте сплеск промахів у кеші і ваш трансформер зображень не стане найгарячішим сервісом у кластері.
Попередньо генеруйте звичні розміри для типових лейаутів. Використовуйте on‑the‑fly як контрольований fallback, а не єдиний план.

Ключі кешу й «безкінечні варіанти»

Найшвидше зображення — те, що вже в кеші. Але API для обрізки зображень запрошують необмежені параметри:
width, height, format, quality, crop mode, background color, DPR, sharpen… вітаю, ви створили генератор промахів кешу.

Практичний підхід:

  • Вайтлістіть розміри (наприклад, 320, 480, 640, 800, 1200, 1600).
  • Обмежуйте quality і забирайте метадані.
  • Нормалізуйте параметри і впорядкуйте їх детерміністично, щоб уникнути фрагментації кешу.

Продуктивність сховища й origin все ще важливі

Якщо ваш CDN промахується і origin повільний, платити буде користувач. Слідкуйте за затримками I/O на origin зображень, особливо якщо ви зберігаєте оригінали на мережеовому сховищі.
Реальність SRE: «це просто статичні файли» перетворюється в «чому ми наситили GET‑запити до об’єктного сховища?» у випадковий вівторок.

Стиснення і формати: виберіть дефолти

Дефолтна стратегія форматів, що працює для більшості сайтів:

  • Фотографії: AVIF (перший), WebP (резервний), JPEG як fallback.
  • Лого/іконки з прозорістю: SVG коли можливо; інакше PNG/WebP без втрат.
  • Не використовуйте PNG для великих фото, якщо вам не подобається оплачувати трафік.

Практичні завдання: 12+ команд, результати та рішення

Це приклади завдань, які ви запускаєте під час роботи над перформансом і реагування на інциденти. Кожне включає команду, приклад виводу, що він означає і яке рішення прийняти далі.

Завдання 1: Визначте найбільші зображення на диску (швидка триаж)

cr0x@server:~$ cd /var/www/site/public/images && find . -type f -printf "%s %p\n" | sort -nr | head
8421932 ./hero/original-homepage.jpg
5211033 ./blog/2024/launch.png
3328810 ./products/widget-x/angle-1.jpg
2988801 ./gallery/event-photos/001.jpg
2559012 ./team/headshots/ceo.jpg

Що це означає: У вас є багатомегабайтні активи, що відправляються «як є».

Рішення: Це ваші перші цілі для конвертації (змінити розмір + сучасний формат + налаштування якості). Також перевірте, чи якісь із них вище згину.

Завдання 2: Перевірте розміри та формат зображення (чи подаєте постер як мініатюру?)

cr0x@server:~$ identify -verbose /var/www/site/public/images/hero/original-homepage.jpg | head -n 20
Image:
  Filename: /var/www/site/public/images/hero/original-homepage.jpg
  Format: JPEG (Joint Photographic Experts Group JFIF format)
  Geometry: 6000x4000+0+0
  Colorspace: sRGB
  Depth: 8-bit
  Filesize: 8.03MiB
  Interlace: None
  Orientation: Undefined

Що це означає: 6000×4000 — це оригінал камери. Ніхто не потребує цього для героя, що відображається на 1200–1600 CSS‑пікселів ширини.

Рішення: Генеруйте деривати; обмежуйте максимальну ширину; видаляйте EXIF; розгляньте AVIF/WebP.

Завдання 3: Конвертуйте JPEG у WebP і порівняйте розмір (пробна перевірка)

cr0x@server:~$ cwebp -q 80 /var/www/site/public/images/hero/original-homepage.jpg -o /tmp/hero.webp
Saving file '/tmp/hero.webp'
File:      /var/www/site/public/images/hero/original-homepage.jpg
Dimension: 6000 x 4000
Output:    1243876 bytes Y-U-V-All-PSNR 41.35 44.07 44.13   42.16 dB

Що це означає: Велике скорочення байтів при прийнятній якості для багатьох фото.

Рішення: Використовуйте WebP або AVIF у продакшні, але не відправляйте оригінальні розміри — спочатку змініть розмір.

Завдання 4: Змінити розмір до реального деривату і закодувати (що користувачі фактично завантажать)

cr0x@server:~$ convert /var/www/site/public/images/hero/original-homepage.jpg -resize 1600x -strip -quality 82 /tmp/hero-1600.jpg
cr0x@server:~$ ls -lh /tmp/hero-1600.jpg
-rw-r--r-- 1 cr0x cr0x 312K Dec 29 10:11 /tmp/hero-1600.jpg

Що це означає: Ви скоротили 8MB оригінал до 312KB деривату з розумною шириною відображення.

Рішення: Збудуйте набір дериватів (наприклад, 400/800/1200/1600) і підключіть їх через srcset.

Завдання 5: Підтвердіть HTTP‑кешування на краю (чи заставляєте CDN працювати?)

cr0x@server:~$ curl -I https://cdn.example.com/images/hero-1600.jpg
HTTP/2 200
content-type: image/jpeg
content-length: 319488
cache-control: public, max-age=31536000, immutable
etag: "a1b2c3d4"
accept-ranges: bytes
age: 86400
via: 1.1 varnish

Що це означає: Довгий час кешування з immutable відмінний для версіонованих імен файлів. Поле Age вказує на доставку з кешу.

Рішення: Тримайте таку політику для файлів з хешованими іменами. Якщо ви не використовуєте fingerprint, не ставте рік кешування без стратегії очищення.

Завдання 6: Перевірте, чи CDN часто промахується (прихований origin‑біль)

cr0x@server:~$ awk '{print $9}' /var/log/nginx/access.log | sort | uniq -c | sort -nr | head
  9241 200
   812 304
   119 206
    37 404

Що це означає: Переважно 200. Це недостатньо; вам треба метрики промахів/потраплянь кешу з логів CDN або заголовків.

Рішення: Якщо ви не бачите рівні потраплянь у кеш, додайте заголовок відповіді, наприклад X-Cache на краю або увімкніть логування CDN. Наблювання кращe за припущення.

Завдання 7: Знайдіть зображення без width/height у сервер‑рендереному HTML (аудит CLS)

cr0x@server:~$ curl -s https://www.example.com/ | grep -oE '<img[^>]*>' | head
<img src="/images/hero-1600.jpg" class="hero">
<img src="/images/logo.svg" alt="Company">
<img src="/images/promo.jpg" loading="lazy">

Що це означає: Ці теги <img> не мають внутрішніх розмірів у розмітці.

Рішення: Виправте шаблони/компоненти, щоб вони виводили width і height (або обгортку з aspect-ratio) для кожного контент‑зображення.

Завдання 8: Визначте, звідки береться LCP‑зображення (HTML чи CSS‑фон)

cr0x@server:~$ curl -s https://www.example.com/ | grep -i "background-image" | head
.hero { background-image: url("/images/hero-1600.jpg"); }

Що це означає: Ваш герой — CSS‑фонове зображення. Браузери знаходять його після завантаження та парсингу CSS. Це може затримати запит і нашкодити LCP.

Рішення: Віддавайте перевагу <img> для героя. Якщо потрібно залишити CSS, розгляньте preload для героя і переконайтесь, що CSS критичний і швидкий.

Завдання 9: Виміряйте таймінги запитів зображень у браузері (полякова перевірка)

cr0x@server:~$ chromium --headless --disable-gpu --dump-dom https://www.example.com/ >/dev/null
[1229/101322.114:WARNING:headless_shell.cc(618)] Running in headless mode.

Що це означає: Headless‑прогін підтверджує, що сторінка рендериться, але за замовчуванням не дає таймінгів.

Рішення: Використовуйте реальний інструмент трасування перформансу в CI або локально; для триажу покладайтеся на заголовки серверу/CDN і логи. Не прикидатися, що дамп DOM у headless — це тест перформансу.

Завдання 10: Перевірте, чи Brotli/Gzip не марнують ресурси на зображеннях (зазвичай марнують)

cr0x@server:~$ curl -I -H 'Accept-Encoding: br,gzip' https://cdn.example.com/images/hero-1600.jpg
HTTP/2 200
content-type: image/jpeg
content-encoding: 

Що це означає: Немає content-encoding для JPEG, що правильно. Стиснення вже стиснених зображень марнує CPU і може збільшувати розмір.

Рішення: Переконайтесь, що ваш вебсервер/CDN не намагається gzip для image/*.

Завдання 11: Перевірте латентність диска на origin (бо «статичні» ще б’ють по сховищу при промаху)

cr0x@server:~$ iostat -x 1 3
Linux 6.8.0 (img-origin-01) 	12/29/2025 	_x86_64_	(8 CPU)

avg-cpu:  %user   %nice %system %iowait  %steal   %idle
           3.12    0.00    1.58    7.90    0.00   87.40

Device            r/s     rkB/s   rrqm/s  %rrqm r_await rareq-sz     w/s     wkB/s   w_await  aqu-sz  %util
nvme0n1         92.0   18432.0     0.0    0.0   12.40   200.3      8.0    1024.0    4.10    1.30  78.0

Що це означає: r_await близько 12ms і 7–8% iowait вказують, що затримка сховища є фактором під час піків читання.

Рішення: Якщо рівень промахів CDN високий — виправте кешування насамперед. Якщо промахи нормальні, але латентність висока — перемістіть «гарячі» зображення на швидше сховище, додайте локальний кеш або масштабуйтесь на origin.

Завдання 12: Підтвердіть ефективність кешу на рівні ОС (файли «гарячі» чи постійно витісняються?)

cr0x@server:~$ grep -E 'MemFree|Cached|Buffers' /proc/meminfo
MemFree:         812340 kB
Buffers:         122144 kB
Cached:        18342392 kB

Що це означає: Здоровий page cache (Cached) може швидко обслуговувати повторні запити зображень на origin.

Рішення: Якщо кеш малий і ви третеся — зменшіть промахи origin, додайте оперативної пам’яті або поставте HTTP‑кеш (наприклад, nginx proxy_cache) перед сховищем.

Завдання 13: Перевірте, чи імена файлів дружні до кешування (хешування/версіювання)

cr0x@server:~$ ls -1 /var/www/site/public/images | head
hero-1600.jpg
hero-1200.jpg
logo.svg
promo.jpg

Що це означає: Імена файлів виглядають стабільними й без fingerprint.

Рішення: Якщо ви хочете річне кешування — використовуйте fingerprint у іменах (наприклад, hero-1600.a1b2c3.jpg) або версійний шлях. Інакше тримайте коротші терміни кешування і план очищення.

Завдання 14: Перевірте, чи зображення відправляються з правильним MIME‑типом (безшумні поломки)

cr0x@server:~$ curl -I https://cdn.example.com/images/hero-1600.webp
HTTP/2 200
content-type: application/octet-stream
content-length: 512044
cache-control: public, max-age=31536000, immutable

Що це означає: Неправильний content-type. Деякі браузери/CDN це толерують, інші — ні, а політики безпеки можуть блокувати.

Рішення: Виправте MIME‑мапінг на origin/CDN для WebP/AVIF. Це класика «все працює в стейджингу».

Швидкий план діагностики

Коли швидкий сайт раптом починає відчуватися повільним, часу на філософські дебати про практики немає. Потрібен тісний цикл: ідентифікувати вузьке місце, виправити найвпливовіше, перевірити.

По‑перше: це LCP, CLS чи «загальна повільність»?

  • Користувачі скаржаться на «прибитий» макет: підозрюйте відсутні аспектні відношення і пізнє завантаження шрифтів/зображень, що викликають зсуви.
  • Користувачі скаржаться на «порожній герой / повільний перший вигляд»: підозрюйте виявлення/пріоритизацію LCP‑зображення та надмірний розмір payload.
  • Користувачі скаржаться «прокрутка пригальмовує»: підозрюйте одночасне декодування великої кількості зображень на основному потоці, важкі JS lazy‑завантажувачі або надвеликий DOM + зображення.

По‑друге: підтвердьте поведінку критичного зображення

  1. Чи герой — це <img> чи CSS‑фон?
  2. Чи він випадково lazy loaded?
  3. Чи резервовано для нього правильні розміри?
  4. Чи занадто він великий (байти або пікселі) для свого відображення?

По‑третє: перевірте доставку і кешування

  1. Чи відповіді CDN — потрапляння в кеш (Age зростає, X-Cache: HIT якщо є)?
  2. Чи origin повільний (I/O wait, висока латентність читання)?
  3. Чи правильні й узгоджені заголовки cache‑control?

По‑четверте: перевірте правильність адаптивних зображень

  • Чи присутній srcset з кількома ширинами?
  • Чи sizes відповідає лейауту?
  • Чи відправляєте ви 1600w у лейаут, що відображається 360px?

По‑п’яте: заповнювачі та декодування

  • Чи є у вас blur‑up заповнювачі для критичних зображень?
  • Чи заповнювачі роздувають HTML і затримують перший рендер?
  • Чи декодуються багато великих зображень під час прокрутки?

Поширені помилки: симптом → корінь → виправлення

1) Симптом: сплески CLS на сторінках контенту

Корінь: Зображення без атрибутів width/height або wrappers з aspect‑ratio. Реклами/ембіди теж роблять так, але зазвичай підозрювані — зображення.

Виправлення: Виводьте внутрішні розміри з CMS/під час зборки. Якщо розміри невідомі, зберігайте їх при завантаженні і вимагайте в шаблонах.

2) Симптом: LCP погіршився після «додавання lazy loading скрізь»

Корінь: Зображення вище згину lazy loaded, тому браузер відкладає їх завантаження.

Виправлення: Приберіть loading="lazy" з кандидата LCP і інших вище згину зображень. Розгляньте fetchpriority="high" і preload, якщо виявлення затримане.

3) Симптом: мобільні пристрої завантажують величезні зображення, незважаючи на srcset

Корінь: Пропущено або неправильно вказано sizes. Браузер вважає, що зображення займе всю ширину вікна або використовує дефолт, що не відповідає лейауту.

Виправлення: Встановіть коректне sizes відповідно до реальних CSS‑брейкпоінтів і ширин контейнера. Перевіряйте після змін у лейауті.

4) Симптом: зображення з’являються пізно, хоча байти малі

Корінь: Зображення вказано в CSS (background-image), його браузер виявляє після завантаження/парсингу CSS; або URL з’являється після клієнтського JS.

Виправлення: Використовуйте <img> в HTML для контентних/геройних зображень. Якщо CSS неминучий — preload і зробіть критичний CSS inlined або швидко завантажуваним.

5) Симптом: пригальмовування прокрутки, коли багато зображень входять у вікно

Корінь: Декодування великої кількості зображень одночасно; JS lazy‑завантажувач викликає зміни лейауту; занадто великі зображення для їх місця.

Виправлення: Використовуйте нативний lazy loading, обмежуйте розміри зображень, зменшуйте байти, додавайте decoding="async" і уникайте кастомних обробників прокрутки.

6) Симптом: низький hit rate кешу; origin CPU перевантажується на трансформаціях

Корінь: Необмежені параметри трансформації створюють нескінченну кількість варіантів і руйнують кешування.

Виправлення: Вайтлістьте розміри дериватів, нормалізуйте параметри, обмежте налаштування якості та попередньо генеруйте поширені варіанти. Моніторьте кардинальність варіантів.

7) Симптом: у деяких браузерах AVIF/WebP показуються як поламані зображення

Корінь: Неправильний Content-Type, помилковий порядок фолбеків у picture або конфіг CDN.

Виправлення: Подавайте правильні MIME‑типи і використовуйте <picture> з <source> AVIF/WebP перед JPEG/PNG fallback.

8) Симптом: HTML‑вантаж збільшився після додавання blur‑up заповнювачів

Корінь: Inlined base64‑заповнювачі для багатьох зображень на одній сторінці.

Виправлення: Inline лише критичні заповнювачі; інакше використовуйте крихітні кешовані файли заповнювачів або компактні кодування типу Blurhash.

Три корпоративні міні‑історії з практики

Міні‑історія 1: Інцидент через хибне припущення

Рітейл‑сайт впровадив «простий» редизайн: більше зображень товарів, чистіша типографіка, менше відволікань. В одній площині вони зробили все правильно — перемістили зображення за CDN і ввімкнули агресивне кешування.

Хибне припущення було тихим: «Якщо зображення в кеші, вони не можуть спричиняти інциденти». Ніхто більше не вважав доставку зображень продакшн‑залежністю. Команда зосередила моніторинг на API та оформленні замовлень, а не на статичних активах.

Потім у пайплайні зображень з’явилась тонка зміна. Новий трансформер почав виводити WebP для деяких варіантів, але відправляти їх із загальним MIME‑типом. Більшість браузерів пробачили. Певна частина — ні. Першими прийшли тикети в підтримку: «На iPhone немає зображень продукту». Канал інцидентів теж загорівся пізніше.

Виправлення не було героїчним. Воно було принизливо базовим: правильні MIME‑типи на origin і edge, плюс тест, що запитував репрезентативний набір зображень і перевіряв заголовки відповіді відповідно до розширення файлу.

Тривале покращення було культурним: зображення повернулися на дашборд надійності. Латентність, hit rate кешу і коди помилок відстежували як будь‑який інший продакшн‑сервіс, бо саме такими вони і є.

Міні‑історія 2: Оптимізація, що відвернулася назад

Платформа публікацій вирішила погнати кращий показник Lighthouse. Хтось помітив, що багато зображень під згином завантажуються сразу, тож додали loading="lazy" до кожного компоненту зображення. Одна зміна в рядку. Чистий диф. Те, що хвалять.

Регрес не проявився в десктоп‑тестах. Він проявився в польових даних: LCP на мобайлі погіршився. Верхнє зображення статті тепер lazy loaded, бо компонент використовувався повсюди, включно з героїчним слотом. На швидких мережах все було нормально. На повільних воно стало вузьким місцем.

Команда спочатку посилила заходи. Додали більший root margin у JS‑lazy loader, думаючи, що це «почне раніше». Це створило іншу проблему: JS виконувався під час прокрутки, робив зайву роботу, і сторінка почала втрачати кадри, коли кілька зображень заходили у вікно.

Остаточне виправлення було хірургічне: героїчні зображення явно стали loading="eager" (або взагалі без loading), плюс fetchpriority="high". Все інше використовувало нативний lazy loading. Також вони примусили атрибути width/height, що зменшило CLS і робило сторінку менш «неспокійною».

Урок не в тому, що lazy loading — погано. Урок у тому, що глобальні оптимізації, застосовані бездумно, стають глобальними регресіями. У продакшні абстракції проходять аудит.

Міні‑історія 3: Нудна, але правильна практика, що врятувала день

Корпоративна SaaS‑панель мала суворе правило пайплайну: кожне завантажене зображення перевіряли на розміри, потім генерували деривати на фіксовані ширини. Ці розміри зберігали поряд із активом і впроваджували в HTML як width і height. Правило було давно. Усім воно здавалося «legacy‑гігієною».

Запустили нову фічу: блоки з контентом, які створюють користувачі, з вбудованими зображеннями і макетом у стилі masonry. Це була ідеальна буря для зсувів макета. Продуктова команда хвилювалась про візуальну нестабільність, інженери готувалися до нічних тикетів.

Тикетів не було. CLS залишився у межах норми. Лейаут поводився стабільно, бо кожне зображення мало резервований блок з моменту, коли HTML потрапив у браузер. Навіть якщо зображення були повільні, сторінка була стабільною. Користувачі могли прокручувати без перестановок UI.

Коли пізніше команда додала blur‑up заповнювачі, це було просто. Резервований простір вже існував, тож заповнювачі покращили відчуття швидкості замість маскування хаосу.

Це правило пайплайну не було гламурним. Воно не отримало святкового повідомлення в Slack. Воно тихо запобігло цілій категорії проблем, що є найвищою похвалою для продакшну.

Контрольні списки / покроковий план

Покроковий план: відправте стабільні, швидкі зображення за два тижні

  1. Зробіть інвентар зображень. Визначте головних порушників за байтами та за критичністю сторінок (головна сторінка, топ‑лендінги).
  2. Спочатку виправте аспектні відношення. Додайте width/height або обгортки aspect-ratio у компоненти/шаблони. Це найшвидший виграш для CLS.
  3. Визначте набір дериватів. Виберіть невелику множину канонічних ширин і дотримуйтесь її. Не дозволяйте довільним ширинам із query params без обмеження.
  4. Увімкніть сучасні формати з фолбеком. AVIF/WebP першим, JPEG/PNG як fallback через picture.
  5. Підключіть srcset + sizes. Нехай sizes відповідає реальним ширинам лейауту. Перевіряйте після змін у CSS.
  6. Реалізуйте lazy loading свідомо. За замовчуванням loading="lazy" для зображень під згином. Ніколи не lazy load для кандидата LCP.
  7. Пріоритизуйте LCP‑зображення. Забезпечте раннє виявлення; розгляньте fetchpriority="high" і (економно) preload.
  8. Додайте blur‑up заповнювачі для ключових зображень. Не інлайньте заповнювачі для десятків зображень; обирайте критичні слоти.
  9. Закріпіть кешування. Використовуйте fingerprint‑імена; ставте довгі терміни кешування з immutable; перевіряйте hit rate CDN.
  10. Додайте тести. Перевіряйте заголовки відповіді (MIME type, cache‑control) і те, що HTML містить внутрішні розміри для <img>.
  11. Спостерігайте в продакшні. Відстежуйте CLS/LCP у RUM; також контролюйте hit rate CDN і латентність origin.
  12. Зробіть це політикою. Вимагайте правил при завантаженні зображень: максимальні розміри, допустимі формати і обов’язковий збір метаданих.

Чек‑лист перед деплоєм (pre‑merge)

  • Кожне контентне <img> має width і height (або свідому обгортку з aspect-ratio).
  • Зображення вище згину не lazy loaded.
  • srcset містить кілька ширин, а sizes віддзеркалює реальний CSS‑лейаут.
  • Герой/LCP‑зображення знаходиться в HTML (не в CSS), якщо немає задокументованого винятку.
  • AVIF/WebP доставляються через picture з коректним фолбеком.
  • Заголовки кешу правильні для fingerprint‑активів.
  • MIME‑типи правильні для WebP/AVIF/SVG.
  • Заповнювачі не роздувають HTML‑вантаж.

Питання та відповіді

1) Чи потрібні мені width і height, якщо розмір контролює CSS?

Так. HTML‑атрибути надають внутрішнє аспектне відношення, щоб браузер міг резервувати місце до завантаження зображення. CSS все ще може масштабувати його адаптивно.

2) Чи достатній сам по собі CSS aspect-ratio?

Може бути достатнім для контейнерів або якщо ви не можете легко вставити розміри. Але якщо у вас є справжні розміри, width/height на <img> простіші й портативніші.

3) Чи слід мені lazy load все, що під згином?

Зазвичай так, з нативним loading="lazy". Але остерігайтеся того, що «під згином» залежить від пристрою. На високих екранах те, що ви вважали під згином, може бути видимим одразу.

4) Чому мій LCP погіршився після додання lazy loading?

Ймовірно, ви lazy loaded кандидат LCP (зазвичай герой). Браузер відтермінував його завантаження. Приберіть lazy loading для цього зображення і розгляньте fetchpriority="high".

5) Чи варто використовувати blur‑up заповнювачі?

Для сторінок з великою кількістю зображень або візуально‑орієнтованих — так, особливо для героя і перших кількох зображень. Але не inlne‑те десятки base64‑заповнювачів і не прикидайтеся, що ви не перемістили байти в HTML.

6) Використовувати Blurhash чи LQIP?

LQIP простий і виглядає добре, але може додати байтів (особливо якщо inlined). Blurhash — дуже компактний рядок, але додає складність рендеру (canvas/SVG) і потребує ретельної реалізації.

7) Чи достатньо WebP, чи додавати AVIF?

WebP широко підтримується і є хорошою базою. AVIF може бути меншим для багатьох фото при схожій якості, але кодування дорожче. Якщо пайплайн витримає — відправляйте AVIF з WebP‑фолбеком.

8) Як зрозуміти, чи sizes правильний?

Якщо мобільні пристрої послідовно завантажують більші кандидати, ніж відповідає відображеному розміру, sizes ймовірно невірний або відсутній. Виправте відповідно до реальних ширин контейнерів на брейкпоінтах.

9) Чи можна покладатися на CDN, щоб виправити перформанс зображень?

CDN допомагає з доставкою, але не виправляє фундаментальні речі. Він не вирішить відсутні aspect‑ratio, неправильне sizes або відправку 6000px зображення в слот 400px. Також промахи кешу все ще б’ють по origin — плануйте це.

10) Який найпростіший «достатньо хороший» набір для невеликої команди?

Виводьте width/height, використовуйте srcset/sizes з невеликим набором дериватів, нативний lazy loading під згином і довге кешування для fingerprint‑активів. Додайте blur‑up лише для героя.

Висновок: наступні кроки, які можна реально відправити

Швидкі сайти не з’являються тому, що хтось посипав loading="lazy" по тегі зображень. Вони з’являються, коли ви усуваєте невизначеність: резервуєте простір, доставляєте потрібні байти і пріоритизуєте важливе.

Наступні кроки:

  1. Виберіть топ‑5 сторінок і визначте LCP‑зображення на кожній. Переконайтесь, що воно не lazy loaded, не сховане в CSS і не надмірно велике.
  2. Примусьте внутрішні розміри для кожного компоненту зображення. Розглядайте відсутність width/height як баг, а не рекомендацію.
  3. Визначте і згенеруйте фіксований набір дериватів. Підключіть їх через srcset і напишіть точні sizes.
  4. Вирішіть стратегію заповнювачів: відсутні, домінуючий колір, LQIP або Blurhash. Потім реалізуйте це послідовно і помірковано там, де потрібно.
  5. Інструментуйте доставку: заголовки кешу, hit rate CDN, латентність origin. Коли щось регресує — ви знайдете, де саме, а не просто помітите проблему.
← Попередня
Mini-ITX + флагманська GPU: як вмістити пекло в маленьку коробку
Наступна →
Docker Compose + systemd: Надійний запуск стеків після перезавантаження (без хитрих обхідних рішень)

Залишити коментар