Разбор 07

Оптимизация скорости сайта

PageSpeed 40, LCP под 6 секунд, неоптимизированные изображения, JS-бандл весил 5 МБ, сайт грузился 10 секунд. Разогнал до зелёных показателей.

PageSpeed LCP WebP lazy-load code-splitting brotli CDN caching
Клиент
Интернет-магазин премиум-косметики, ~40k SKU, 80% трафика с мобильных
Стек
React 17, Express, nginx, Cloudflare, webpack 5
Срок
9 дней
Итог
PageSpeed 40 → 94, загрузка 10 с → 2.4 с, конверсия +18%

Суть проблемы

Тяжёлый лендинг-витрина магазина премиум-косметики. Клиент жаловался на жалобы покупателей: страницы «висят» по 10 секунд, мобильные пользователи уходят ещё до отрисовки каталога. Метрики конверсии падали третий месяц подряд. Аудит провёл через Lighthouse и DevTools (вкладки Performance и Network) на эмуляции мобильной сети 4G.

Отчёт Lighthouse (мобайл)До

Performance 40 · LCP 5.8 с · FCP 3.4 с · CLS 0.28 · TBT 1 480 мс

Суммарный вес страницы: 28 МБ. 42 запроса, из них 18 — неоптимизированные изображения. Render-blocking resources: 5 (JS + CSS + шрифты).
Отчёт Lighthouse (мобайл)После

Performance 94 · LCP 1.9 с · FCP 1.1 с · CLS 0.02 · TBT 120 мс

Суммарный вес страницы: 3.1 МБ. 31 запрос, изображения в WebP/AVIF, JS разбит на чанки. Render-blocking resources: 0.

Как диагностировал

Прошёлся сверху вниз по waterfall в DevTools и отчёту Lighthouse, чтобы выделить корневые причины, а не симптомы. Каждая находка подтверждена замером в Network.

  1. Lighthouse — главные потериLargest Contentful Element — огромное hero-изображение 4000×3000 без оптимизации, без srcset/sizes. Отдаётся как одна PNG в полном разрешении, браузер масштабирует на клиенте.
  2. Network — изображенияИзображения без сжатия и без современных форматов, нет lazy-loading ниже первого экрана, нет width/height → layout shift (CLS 0.28). Ниже fold грузятся все 18 картинок сразу.
  3. Render-blocking — монолитный бандлОдин bundle.js на 5 МБ в <head> без defer/async. В него втащили всю UI-библиотеку целиком: moment со всеми локалями, lodash целиком, тяжёлый карусельный компонент. TBT 1 480 мс.
  4. Сервер — нет сжатия и кэшированияСервер отдаёт статику без Cache-Control, без gzip/brotli, по HTTP/1.1 (без HTTP/2). Нет CDN — вся статика на одном origin с API. Повторные заходы грузят всё заново.
  5. Шрифты — синхронная загрузка и FOITШрифты грузятся синхронно, font-display: block → FOIT на 2 с, текст не виден. Неиспользуемый CSS на 180 КБ в одном файле, критический путь замусорен.
  6. Сводка корневых причинЧетыре независимых источника торможения: (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.

product-card.htmlбыло
<img src="/images/products/serum.png"
     alt="Сыворотка для лица">
<!-- 3.4 МБ, 4000×3000, без размеров → CLS при загрузке -->
product-card.htmlстало
<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 (он на первом экране):

hero.htmlстало
<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.

webpack.config.jsбыло
module.exports = {
  entry: "./src/index.js", // один вход — один бандл 5 МБ
  output: {
    filename: "bundle.js", // синхронно в <head>
    path: path.resolve(__dirname, "dist"),
  },
};
webpack.config.jsстало
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, чтобы первый экран не тянул каталог и корзину целиком:

App.jsxстало
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:

index.htmlбыло
<head>
  <script src="/bundle.js"></script> <!-- блокирует рендер -->
</head>
index.htmlстало
<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 критических ресурсов.

nginx.confбыло
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
}
nginx.confстало
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>:

index.htmlстало
<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.

fonts.cssбыло
@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 с */
}
fonts.cssстало
@font-face {
  font-family: "Inter";
  src: url("/fonts/inter-var-subset.woff2") format("woff2");
  font-weight: 100 900;
  font-display: swap; // текст виден сразу, шрифт подменяется
}
index.htmlстало
<!-- критический 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. Все ключевые метрики ушли в зелёную зону.

40 → 94
94 / 100
PageSpeed Mobile
5.8 с → 1.9 с
1.9 с
LCP
10 с → 2.4 с
2.4 с
Полная загрузка
5 МБ → 380 КБ
380 КБ
JS на первом экране
~18 МБ → ~2.1 МБ
2.1 МБ
Вес изображений на страницу
0.28 → 0.02
0.02
CLS

Сайт полностью в зелёной зоне Core Web Vitals. Мобильные пользователи перестали уходить до отрисовки каталога — по данным клиента, конверсия в покупку выросла на 18% за первый месяц после оптимизации. Дополнительно настроил регулярный прогон Lighthouse в CI: при падении Performance ниже 85 пайплайн падает, чтобы метрики не деградировали silently.

PageSpeed Mobile 40 → 94, LCP 5.8 с → 1.9 с, JS-бандл 5 МБ → 380 КБ на первом экране. Сайт в зелёной зоне, конверсия +18%. Lighthouse теперь гоняется в CI.
Есть похожая проблема?

Сначала найдём причину. Потом решим, что действительно нужно исправить.

Опишите симптом и что из-за него перестало работать. Я посмотрю, где искать причину и насколько задача похожа на точечный фикс.

Описать проблему ↗