Суть проблемы
Тяжёлый лендинг-витрина магазина премиум-косметики. Клиент жаловался на жалобы покупателей: страницы «висят» по 10 секунд, мобильные пользователи уходят ещё до отрисовки каталога. Метрики конверсии падали третий месяц подряд. Аудит провёл через Lighthouse и DevTools (вкладки Performance и Network) на эмуляции мобильной сети 4G.
- !PageSpeed Insights Mobile — 40/100Красная зона по всем ключевым метрикам Core Web Vitals, рекомендации Lighthouse на 15 экранов.
- !LCP ~5.8 с, FCP высокий, CLS из-за картинок без размеровLargest Contentful Paint — худший показатель в отчёте; карточки «прыгают» при загрузке изображений.
- !Изображения в исходном PNG/JPEG по 2–4 МБ каждоеHero-баннер 3.8 МБ, фотографии товаров отдавались в исходном разрешении 4000×3000 без ресайза и сжатия.
- !Главный JS-бандл 5 МБ, грузится синхронноМонолитный bundle.js в <head> блокирует рендер на 4+ секунды до первого кадра.
- !Полное время загрузки ~10 с на мобильной сетиСуммарно: 4 с на JS, 3 с на изображения, 2 с на шрифты и блокировки, 1 с на серверный ответ без кэша.
Performance 40 · LCP 5.8 с · FCP 3.4 с · CLS 0.28 · TBT 1 480 мс
Performance 94 · LCP 1.9 с · FCP 1.1 с · CLS 0.02 · TBT 120 мс
Как диагностировал
Прошёлся сверху вниз по waterfall в DevTools и отчёту Lighthouse, чтобы выделить корневые причины, а не симптомы. Каждая находка подтверждена замером в Network.
- Lighthouse — главные потериLargest Contentful Element — огромное hero-изображение 4000×3000 без оптимизации, без
srcset/sizes. Отдаётся как одна PNG в полном разрешении, браузер масштабирует на клиенте. - Network — изображенияИзображения без сжатия и без современных форматов, нет lazy-loading ниже первого экрана, нет
width/height→ layout shift (CLS 0.28). Ниже fold грузятся все 18 картинок сразу. - Render-blocking — монолитный бандлОдин
bundle.jsна 5 МБ в<head>безdefer/async. В него втащили всю UI-библиотеку целиком:momentсо всеми локалями,lodashцеликом, тяжёлый карусельный компонент. TBT 1 480 мс. - Сервер — нет сжатия и кэшированияСервер отдаёт статику без
Cache-Control, без gzip/brotli, по HTTP/1.1 (без HTTP/2). Нет CDN — вся статика на одном origin с API. Повторные заходы грузят всё заново. - Шрифты — синхронная загрузка и FOITШрифты грузятся синхронно,
font-display: block→ FOIT на 2 с, текст не виден. Неиспользуемый CSS на 180 КБ в одном файле, критический путь замусорен. - Сводка корневых причинЧетыре независимых источника торможения: (1) изображения-гиганты без форматов и ленивой загрузки, (2) монолитный JS без сплиттинга, (3) отсутствие серверного сжатия/кэша/HTTP2/CDN, (4) блокирующие шрифты и CSS. Лечил все четыре.
Что исправил
Разбил работу на четыре направления: изображения, JS-бандл, серверная конфигурация, шрифты/CSS. Ниже — сниппеты «было → стало» по каждому.
1. Изображения: WebP/AVIF, srcset, lazy-load, размеры
Hero-изображение и фотографии товаров конвертировал в WebP + AVIF, добавил ресайз под брейкпоинты, атрибуты width/height против CLS, loading="lazy" и decoding="async" для всего вне первого экрана, fetchpriority="high" для hero.
<img src="/images/products/serum.png"
alt="Сыворотка для лица">
<!-- 3.4 МБ, 4000×3000, без размеров → CLS при загрузке -->
<picture>
<source type="image/avif"
srcset="/img/serum-480.avif 480w,
/img/serum-960.avif 960w,
/img/serum-1440.avif 1440w">
<source type="image/webp"
srcset="/img/serum-480.webp 480w,
/img/serum-960.webp 960w,
/img/serum-1440.webp 1440w">
<img src="/img/serum-960.jpg"
width="480" height="360"
loading="lazy" decoding="async"
sizes="(min-width: 768px) 33vw, 100vw"
alt="Сыворотка для лица">
</picture>
<!-- 96 КБ (480w), размеры зафиксированы → CLS = 0 -->
Для hero-баннера — тот же приём, но с fetchpriority="high" и без lazy (он на первом экране):
<img src="/img/hero-1440.webp"
width="1440" height="600"
fetchpriority="high" decoding="async"
alt="Летняя коллекция">
<!-- приоритетная загрузка → LCP элемент готов быстрее -->
2. JS: code-splitting, tree-shaking, замена тяжёлых зависимостей
Монолитный bundle.js на 5 МБ разбил на чанки через динамический import(), вынес vendor-код отдельно, заменил moment на date-fns (tree-shakeable), lodash на lodash-es, убрал бандл из <head>, добавил defer.
module.exports = {
entry: "./src/index.js", // один вход — один бандл 5 МБ
output: {
filename: "bundle.js", // синхронно в <head>
path: path.resolve(__dirname, "dist"),
},
};
module.exports = {
entry: "./src/index.js",
output: {
filename: "[name].[contenthash:8].js",
path: path.resolve(__dirname, "dist"),
clean: true,
},
optimization: {
splitChunks: {
chunks: "all",
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: "vendor",
},
},
},
},
// date-fns + lodash-es подлежат tree-shaking в production
mode: "production",
};
Роутинг и тяжёлые виджеты — через React.lazy, чтобы первый экран не тянул каталог и корзину целиком:
import { lazy, Suspense } from "react";
const Catalog = lazy(() => import("./pages/Catalog"));
const Cart = lazy(() => import("./pages/Cart"));
<Suspense fallback={<Spinner />}>
<Routes>
<Route path="/catalog" element={<Catalog />} />
<Route path="/cart" element={<Cart />} />
</Routes>
</Suspense>
И подключение в HTML — вынес из <head>, добавил defer:
<head>
<script src="/bundle.js"></script> <!-- блокирует рендер -->
</head>
<body>
<!-- в конце body, не блокирует первый кадр -->
<script type="module" defer src="/vendor.a1b2c3d4.js"></script>
<script type="module" defer src="/app.e5f6a7b8.js"></script>
</body>
3. Сервер: brotli, Cache-Control, HTTP/2, CDN
Включил brotli для текстовых ассетов, Cache-Control: immutable для статики с хэшем в имени, перевёл origin на HTTP/2, статика ушла на CDN (Cloudflare), добавил preload критических ресурсов.
server {
listen 80;
server_name cosmetics-shop.ru;
root /var/www/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# нет сжатия, нет cache-control, HTTP/1.1
}
server {
listen 443 ssl http2; // HTTP/2 — мультиплексирование запросов
server_name cosmetics-shop.ru;
root /var/www/dist;
# Brotli для текстовых ассетов
brotli on;
brotli_types text/css application/javascript application/json image/svg+xml;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
# Иmmutable-кэш для статики с хэшем в имени
location ~* \.(js|css|woff2|webp|avif)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}
CDN: статика раздаётся с cdn.cosmetics-shop.ru, origin настроен как pull-through. preload критических ресурсов в <head>:
<link rel="preload" as="image"
href="/img/hero-1440.webp"
fetchpriority="high">
<link rel="preload" as="font"
href="/fonts/inter-var.woff2"
type="font/woff2" crossorigin>
4. Шрифты: font-display: swap, preload, subset
Убрал FOIT через font-display: swap, за preload-ом оставил только woff2, шрифт сабсетирован до латиницы + кириллицы (был 320 КБ, стал 48 КБ). Критический CSS инлайн-ом в <head>, остальной — асинхронно через media="print" + onload.
@font-face {
font-family: "Inter";
src: url("/fonts/inter-regular.woff2") format("woff2"),
url("/fonts/inter-regular.woff") format("woff"),
url("/fonts/inter-regular.ttf") format("truetype");
/* нет font-display → FOIT на 2 с */
}
@font-face {
font-family: "Inter";
src: url("/fonts/inter-var-subset.woff2") format("woff2");
font-weight: 100 900;
font-display: swap; // текст виден сразу, шрифт подменяется
}
<!-- критический CSS инлайном — не блокирует рендер -->
<style>
:root { --accent: #c8a96a; }
.hero { display: flex; min-height: 60vh; }
</style>
<!-- остальной CSS грузится асинхронно -->
<link rel="stylesheet" href="/css/app.[hash].css"
media="print" onload="this.media='all'">
Результат
После применения всех четырёх направлений прогнал Lighthouse повторно на том же эмуляторе 4G. Все ключевые метрики ушли в зелёную зону.
Сайт полностью в зелёной зоне Core Web Vitals. Мобильные пользователи перестали уходить до отрисовки каталога — по данным клиента, конверсия в покупку выросла на 18% за первый месяц после оптимизации. Дополнительно настроил регулярный прогон Lighthouse в CI: при падении Performance ниже 85 пайплайн падает, чтобы метрики не деградировали silently.