Мобільний PageSpeed цього сайту показував 77 балів: LCP 4,3 секунди, Speed Index 5,1 секунди. Для портфоліо розробника – так собі візитівка. Нижче – що саме гальмувало, як ми це знайшли і що допомогло. Без «встановіть плагін кешування»: лише причини, цифри і конкретні зміни.

Звідки починати: не вгадувати, а міряти

Перше правило – не лагодити навмання. PageSpeed Insights зручний, але має денні ліміти й «шумить» між запусками. Тому ми запускали той самий рушій локально (Lighthouse у мобільному режимі) і брали медіану трьох прогонів. Найцінніше у звіті – не загальний бал, а розбивка LCP на фази.

Вона одразу показала головне: сервер відповідав за 0,2 с, а 2,4 с припадало на «затримку відмальовки елемента». Тобто сторінка вже давно завантажилась, а найбільший елемент – заголовок у hero – ще не було видно.

Причина №1: заголовок чекав на JavaScript

Hero-блок мав гарну анімацію появи на GSAP. Але в CSS заголовок, підзаголовок і кнопки стояли з opacity: 0, а показував їх скрипт. Ланцюжок виходив такий:

  1. завантажити JS-бандл з GSAP;
  2. дочекатися DOMContentLoaded;
  3. відпрацювати таймлайн, де заголовок стартував із затримкою 0,6 с і анімувався ще секунду.

На швидкому ноутбуці цього не помітно. На мобільному 4G, який емулює Lighthouse, це плюс дві з половиною секунди до LCP.

Що зробили: перенесли появу з GSAP на звичайну CSS-анімацію. Вона стартує разом із першою відмальовкою і нічого не чекає. Заголовок тепер з’являється першим і без затримки, решта – каскадом. GSAP лишився там, де він справді потрібен: 3D-нахил логотипа за курсором.

.hero__name { animation: hero-in 1s cubic-bezier(.215,.61,.355,1) both; }

@keyframes hero-in {
  from { opacity: var(--hero-from, 0); transform: translateY(var(--hero-y, 0)); }
  to   { opacity: 1; transform: none; }
}

@media (prefers-reduced-motion: reduce) {
  .hero__name { animation: none; }
}

Пастка: opacity: 0 і LCP

Після першої правки бал виріс, а LCP – майже ні. Звіт показав, що «найбільшим елементом» тепер став… пункт меню в шапці. Причина: Chrome не враховує як кандидатів на LCP елементи з нульовою прозорістю. Наша CSS-анімація починалась з opacity: 0 – і заголовок просто випав зі змагання.

Рішення в один рядок: анімація заголовка стартує з opacity: 0.01. Оком різниці не видно, а браузер рахує елемент з першого кадру. Саме для цього в прикладі вище є змінна --hero-from.

Якщо LCP «не рухається» після оптимізацій – перевірте, чи не стартує ваш головний елемент з повної прозорості. Це одна з найпоширеніших причин, і її легко пропустити.

Причина №2: CSS і JS їхали без стиснення

Друга знахідка – серверна. У стандартному nginx.conf на Ubuntu директива gzip on увімкнена, але gzip_types закоментований. У результаті стискається лише HTML, а CSS і JavaScript віддаються «сирими». Два CSS-файли, що блокують відмальовку, на мобільному коштували приблизно по 0,9 с.

gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/css application/javascript application/json
           image/svg+xml application/manifest+json text/xml;

Головний CSS зменшився з 23 до 5,7 КБ, бандл GSAP – з 68 до 27 КБ. Заодно увімкнули HTTP/2: замість черги з дванадцяти запитів через кілька з’єднань – одне мультиплексоване. Лише ці дві серверні зміни прискорили першу відмальовку (FCP) з 2,0 до 1,2 с.

Причина №3: аналітика на старті

Тег Google Analytics важить близько 179 КБ, з яких на старті не використовується майже половина. Ми почали вантажити його після події load, коли браузер вільний (requestIdleCallback). Важливий нюанс: dataLayer створюється одразу, тож події, що сталися до завантаження тегу, не губляться – тег обробить чергу, щойно прийде.

window.dataLayer = window.dataLayer || [];
function gtag(){ dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', 'G-XXXXXXX');

addEventListener('load', () => {
  (window.requestIdleCallback || setTimeout)(() => {
    const s = document.createElement('script');
    s.async = true;
    s.src = 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX';
    document.head.appendChild(s);
  });
});

Чесний мінус: відвідувачі, що пішли в першу секунду, не потраплять у статистику. Для портфоліо це прийнятна ціна; для інтернет-магазину з платною рекламою рішення варто зважити окремо.

Дрібниця, яку видно в CLS

Логотип у hero не мав атрибутів width і height, тож під час завантаження сторінка трохи «підстрибувала» (CLS 0,011). Два атрибути – і зсув зник повністю.

Результат

Метрика Було Після сервера Фінал
Бал Performance 77–80 82 98
First Contentful Paint 2,0 с 1,2 с 1,1 с
Largest Contentful Paint 4,3 с 3,7 с 1,3 с
Speed Index 5,2 с 5,1 с 3,9 с
Total Blocking Time до 190 мс 191 мс 29 мс
Cumulative Layout Shift 0,011 0 0

Speed Index лишився в «жовтій» зоні – це плата за каскадну анімацію в hero, і ми свідомо її залишили.

Що забрати собі

  • Дивіться на фази LCP, а не лише на бал. «Затримка відмальовки» майже завжди означає, що елемент чекає на JS або шрифт.
  • Не ховайте головний контент до завантаження скриптів. Анімація появи – на CSS, JS – для інтерактиву.
  • Перевірте сервер: чи стискаються CSS і JS, чи працює HTTP/2. Це п’ять хвилин роботи і секунда економії.
  • Аналітику – після завантаження сторінки, але з чергою dataLayer одразу.
  • Міряйте медіаною кількох прогонів – окремий запуск може відрізнятися на 10 балів.