lenec ru

← все посты

LCP в реальном проекте: топ-5 причин и как чинить

13K

За последние два года я разбирала LCP в десятке проектов: e-commerce каталоги, медиа-главные, лендинги. Если посмотреть на распределение причин, картина получается на удивление однообразная. На пять виновников приходится примерно 90% случаев, когда LCP вылезает за 2.5 секунды. Расскажу, как ловлю каждую и что обычно помогает.

Все цифры ниже — с реальных замеров в WebPageTest и CrUX. Тестовый профиль, который использую чаще всего: iPhone SE 2020, 4G (9 Mbps download, 170ms RTT), холодный кеш. Это близко к 75-му перцентилю мобильного трафика на русскоязычных проектах, и Google в Search Console смотрит именно на p75.

Сразу оговорка: до того, как чинить, нужно знать, какой именно элемент на странице помечен как LCP. Без этого любые гипотезы — пальцем в небо. В Lighthouse это секция «Largest Contentful Paint element», в DevTools — Performance → Timings → LCP с подсветкой. На проде смотрю через RUM: библиотека web-vitals отдаёт в коллбэке entries[entries.length - 1].element, я его сериализую в селектор и шлю в свой эндпоинт.

Причина 1. LCP-картинка не приоритезирована

Самая частая. Большая героическая картинка на главной или верх карточки товара, и браузер качает её одновременно с десятком других ресурсов, причём по очереди и с низким приоритетом.

Признаки в DevTools: в Network у LCP-картинки приоритет Low или Medium, начало загрузки (Start) задержано на 500–1500мс относительно начала навигации, между HTML и картинкой стоит длинный блок CSS и шрифтов.

Что помогает:

  • Атрибут fetchpriority="high" на тег <img>. Поддерживается во всех современных браузерах с 2023 года, в Safari — с 17.2.
  • <link rel="preload" as="image"> в <head> с правильными imagesrcset и imagesizes — если картинка отзывчивая.
  • Убрать loading="lazy" с LCP-картинки. Видела два раза, как фронт-разработчик ставил lazy на всё подряд «для производительности», включая первый экран. LCP пробивал 5 секунд.
<link rel="preload" as="image"
      href="/hero-1280.jpg"
      imagesrcset="/hero-640.jpg 640w, /hero-1280.jpg 1280w, /hero-1920.jpg 1920w"
      imagesizes="100vw">

<img src="/hero-1280.jpg"
     srcset="/hero-640.jpg 640w, /hero-1280.jpg 1280w, /hero-1920.jpg 1920w"
     sizes="100vw"
     fetchpriority="high"
     alt="Hero">

На одной из посадочных страниц медиапроекта добавление fetchpriority="high" и preload подвинуло LCP с 3.4с до 2.1с на p75 мобильных. Никаких других изменений в этом релизе не было.

Причина 2. Картинка не оптимизирована по весу и формату

Часто LCP-картинку в принципе тянут в JPEG на 400 КБ, когда WebP с тем же визуальным качеством весил бы 110 КБ. На 4G это разница в полсекунды, а на 3G — в две.

Минимальный набор требований к LCP-картинке:

  • Формат AVIF или WebP с фолбеком на JPEG через <picture>.
  • Размер в пикселях — не больше реальных размеров на экране (учитывая devicePixelRatio, но не больше 2x — 3x уже не виден глазом).
  • Отдельный вариант для мобильного и десктопа. На моб LCP-картинка обычно меньше десктопной в 2–3 раза.
  • Сжатие 75–80% качества для JPEG, для WebP/AVIF — около 65%.
<picture>
  <source srcset="/hero-640.avif 640w, /hero-1280.avif 1280w"
          sizes="100vw" type="image/avif">
  <source srcset="/hero-640.webp 640w, /hero-1280.webp 1280w"
          sizes="100vw" type="image/webp">
  <img src="/hero-1280.jpg"
       srcset="/hero-640.jpg 640w, /hero-1280.jpg 1280w"
       sizes="100vw" alt="Hero" fetchpriority="high">
</picture>

В e-commerce проекте, где LCP-картинкой был баннер главной, замена JPEG 320 КБ на AVIF 78 КБ дала минус 380мс к LCP на 4G и минус 1.2с на медленном 3G. Картинка визуально была неотличима, проверяли на пяти редакторах вслепую.

Причина 3. Сервер долго отвечает (TTFB)

LCP не может быть меньше TTFB. Если сервер отдаёт первый байт через 800мс, никакая клиентская оптимизация не вытащит LCP в зелёную зону. Видела, как медиа-главная отвечала за 1.4с, потому что каждый запрос дёргал свежий рендер на Node.js без кеша, плюс пять синхронных вызовов к редактору контента.

Что смотрю:

  • В DevTools у HTML-документа поле «Waiting (TTFB)». Норма для России — до 400мс с холодного кеша.
  • В RUM — метрику navigation.responseStart - navigation.requestStart, разбитую по странам и регионам.
  • Если есть CDN — кеш-хит-рейт. На главной должно быть стабильно 90%+, иначе кеш настроен криво.

Чиню по убыванию эффекта:

  1. Кеш HTML на CDN или nginx-уровне. Даже на 30 секунд — снимает 80% нагрузки с сервера и режет TTFB до 50–100мс.
  2. Stale-while-revalidate: отдаём кешированную версию, фоном перегенерируем.
  3. Если SSR — выносим тяжёлые блоки в edge-инклюды или отдельные API-вызовы.
  4. Убираем синхронные внешние вызовы из критического пути. Ходить в three-сервиса перед отдачей HTML — почти всегда плохая идея.

Причина 4. LCP-элемент — текст, заблокированный шрифтом

На лендингах и блогах LCP часто оказывается заголовком. И блокируется он не загрузкой, а ожиданием кастомного шрифта. Если у тебя font-display: block (или дефолт auto, который во многих браузерах ведёт себя как block), браузер не отрисует текст до 3 секунд, ожидая шрифт.

Лечится в три шага:

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2-variations');
  font-weight: 100 900;
  font-display: swap;
  font-style: normal;
}
<link rel="preload"
      href="/fonts/inter-var.woff2"
      as="font"
      type="font/woff2"
      crossorigin>

Третий шаг — настроить fallback-шрифт через size-adjust и ascent-override, чтобы при swap текст не прыгал. Это уже про CLS, но идёт в одном пакете.

@font-face {
  font-family: 'Inter Fallback';
  src: local('Arial');
  ascent-override: 90%;
  descent-override: 22.43%;
  line-gap-override: 0%;
  size-adjust: 107.4%;
}

body { font-family: 'Inter', 'Inter Fallback', sans-serif; }

На блоге, где LCP был <h1> над фолдом, переход с font-display: block на swap + preload убрал у LCP 600мс на p75. До замера никто не верил, что виноваты шрифты.

Причина 5. JS блокирует рендер

Это коварная категория. LCP-элемент уже скачан и в DOM, но не отрисовывается, потому что main thread занят парсингом и исполнением 800 КБ JS. Особенно характерно для SPA с CSR: пока бандл не выполнится и React не отрендерит дерево, на экране пусто.

Признаки в Performance: длинная жёлтая полоса Scripting перед моментом LCP, в Long Tasks — задачи по 500–800мс.

Что помогаю делать командам:

  • Разнести бандл по роутам через динамический import(). На главной не должно быть кода админки, личного кабинета, чекаута.
  • Поднять SSR или хотя бы статическую генерацию для шаблонных страниц. Тогда LCP-элемент рендерится сразу из HTML, не ждёт гидрации.
  • Перенести аналитику и виджеты в defer или загрузку после взаимодействия. GA4 на 60 КБ перед основным контентом — нормальная боль.
  • Если фреймворк позволяет (Astro, Qwik, частично Next.js App Router) — использовать партиционную гидрацию. У того же Astro на компонент клиентского JS вообще ноль, если не указано client:*.

На SPA-каталоге товаров (CRA, бандл 720 КБ gzipped) после переноса на Next.js с SSR и code-splitting LCP упал с 4.5с до 1.8с на p75. Сам Next тут не магия — магия в том, что HTML с LCP-картинкой приходит готовым, а JS подгружается параллельно и не блокирует первый кадр.

Что делать прямо сейчас

Если у тебя плохой LCP и непонятно, с чего начать, порядок такой:

  1. Снять метрику. Lighthouse mobile + RUM, минимум неделя данных, смотреть p75.
  2. Понять, какой элемент LCP. Это уже даёт половину диагноза.
  3. В DevTools посмотреть, что было между navigationStart и LCP: TTFB, загрузка ресурсов, парсинг JS.
  4. Чинить по одной причине за релиз и мерять до/после. Иначе непонятно, что именно сработало.

Без замеров до — это не оптимизация, это вера. И ещё: LCP — не самоцель. Если у тебя зелёный LCP, но INP в красной зоне, пользователю всё равно тяжело. Поэтому смотри метрики целиком, а LCP правь в первую очередь только если он реально бьёт по p75.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.