Мобільний 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, а показував їх скрипт. Ланцюжок виходив такий:
- завантажити JS-бандл з GSAP;
- дочекатися
DOMContentLoaded; - відпрацювати таймлайн, де заголовок стартував із затримкою 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 балів.