Перейти к содержимому
·22 мин чтения

Core Web Vitals в 2026: что реально двигает стрелку

Забудь про теорию — вот что я реально сделал, чтобы добиться LCP ниже 2.5 секунд, CLS до нуля и INP ниже 200 мс на настоящем продакшен-сайте на Next.js. Конкретные техники, а не размытые советы.

Поделиться:X / TwitterLinkedIn

Я потратил добрые две недели на то, чтобы сделать этот сайт быстрым. Не «выглядит быстро в Lighthouse-аудите на моём M3 MacBook» быстрым. По-настоящему быстрым. Быстрым на телефоне за $150 Android на шатком 4G-соединении в туннеле метро. Быстрым там, где это важно.

Результат: LCP ниже 1.8 секунды, CLS на отметке 0.00, INP ниже 120 мс. Все три показателя зелёные в данных CrUX, а не просто лабораторные оценки. И в процессе я кое-что понял — большинство советов по производительности в интернете либо устарели, либо расплывчаты, либо и то и другое.

«Оптимизируй изображения» — это не совет. «Используй ленивую загрузку» без контекста — это опасно. «Минимизируй JavaScript» — это очевидно, но ничего не говорит о том, что именно резать.

Вот что я реально сделал, в порядке значимости.

Почему Core Web Vitals всё ещё важны в 2026 году#

Скажу прямо: Google использует Core Web Vitals как сигнал ранжирования. Не единственный сигнал и даже не самый важный. Релевантность контента, обратные ссылки и авторитет домена по-прежнему доминируют. Но на полях — там, где две страницы имеют сопоставимый контент и авторитет — производительность становится решающим фактором. А в интернете миллионы страниц живут именно на этих полях.

Но забудь на секунду про SEO. Настоящая причина заботиться о производительности — это пользователи. Данные не сильно изменились за последние пять лет:

  • 53% мобильных посещений прерываются, если страница грузится дольше 3 секунд (исследование Google/SOASTA, всё ещё актуально)
  • Каждые 100 мс задержки стоят примерно 1% конверсий (оригинальные данные Amazon, подтверждённые многократно)
  • Пользователи, которые испытывают сдвиги лейаута, значительно реже завершают покупку или заполняют форму

Core Web Vitals в 2026 году состоят из трёх метрик:

МетрикаЧто измеряетХорошоНужно улучшитьПлохо
LCPПроизводительность загрузки≤ 2.5 с2.5 с – 4.0 с> 4.0 с
CLSВизуальная стабильность≤ 0.10.1 – 0.25> 0.25
INPОтзывчивость≤ 200 мс200 мс – 500 мс> 500 мс

Эти пороговые значения не менялись с тех пор, как INP заменил FID в марте 2024 года. Но техники для их достижения эволюционировали, особенно в экосистеме React/Next.js.

LCP: та метрика, которая важнее всего#

Largest Contentful Paint измеряет момент, когда самый большой видимый элемент во вьюпорте заканчивает рендеринг. Для большинства страниц это hero-изображение, заголовок или большой блок текста.

Шаг 1: Найди свой реальный LCP-элемент#

Прежде чем что-либо оптимизировать, тебе нужно знать, что является твоим LCP-элементом. Люди предполагают, что это их hero-изображение. Иногда это веб-шрифт, рендерящий <h1>. Иногда — фоновое изображение, применённое через CSS. Иногда — постер-кадр <video>.

Открой Chrome DevTools, перейди в панель Performance, запиши загрузку страницы и найди маркер «LCP». Он точно покажет, какой элемент вызвал LCP.

Также можно использовать библиотеку web-vitals для программной записи:

tsx
import { onLCP } from "web-vitals";
 
onLCP((metric) => {
  console.log("LCP element:", metric.entries[0]?.element);
  console.log("LCP value:", metric.value, "ms");
});

На этом сайте LCP-элементом оказалось hero-изображение на главной странице и первый абзац текста на страницах блога. Два разных элемента, две разные стратегии оптимизации.

Шаг 2: Предзагрузи LCP-ресурс#

Если твой LCP-элемент — изображение, то самое эффективное, что ты можешь сделать — предзагрузить его. По умолчанию браузер обнаруживает изображения при парсинге HTML, а значит запрос на изображение не начинается, пока HTML не скачан, распарсен и не достигнут тег <img>. Предзагрузка переносит это обнаружение в самое начало.

В Next.js ты можешь добавить preload-ссылку в свой layout или page:

tsx
import Head from "next/head";
 
export default function HeroSection() {
  return (
    <>
      <Head>
        <link
          rel="preload"
          as="image"
          href="/images/hero-optimized.webp"
          type="image/webp"
          fetchPriority="high"
        />
      </Head>
      <section className="relative h-[600px]">
        <img
          src="/images/hero-optimized.webp"
          alt="Hero banner"
          width={1200}
          height={600}
          fetchPriority="high"
          decoding="sync"
        />
      </section>
    </>
  );
}

Обрати внимание на fetchPriority="high". Это более новый Fetch Priority API, и он меняет правила игры. Без него браузер использует собственные эвристики для приоритизации ресурсов — и эти эвристики часто ошибаются, особенно когда у тебя несколько изображений выше линии сгиба.

На этом сайте добавление fetchPriority="high" к LCP-изображению снизило LCP примерно на ~400 мс. Это самый большой выигрыш, который я когда-либо получал от однострочного изменения.

Шаг 3: Устрани рендер-блокирующие ресурсы#

CSS блокирует рендеринг. Весь. Если у тебя таблица стилей на 200 КБ, загруженная через <link rel="stylesheet">, браузер не нарисует ничего, пока она полностью не скачается и не распарсится.

Решение состоит из трёх частей:

  1. Инлайн критический CSS — Извлеки CSS, необходимый для контента выше линии сгиба, и встрой его в тег <style> в <head>. Next.js делает это автоматически при использовании CSS Modules или Tailwind с правильной очисткой.

  2. Отложи некритический CSS — Если у тебя есть таблицы стилей для контента ниже линии сгиба (библиотека анимаций футера, компонент графика), загрузи их асинхронно:

html
<link
  rel="preload"
  href="/styles/charts.css"
  as="style"
  onload="this.onload=null;this.rel='stylesheet'"
/>
<noscript>
  <link rel="stylesheet" href="/styles/charts.css" />
</noscript>
  1. Удали неиспользуемый CSS — Tailwind CSS v4 делает это автоматически своим JIT-движком. Но если ты импортируешь сторонние CSS-библиотеки, проверь их. Я нашёл одну библиотеку компонентов, которая импортировала 180 КБ CSS ради одного компонента тултипа. Заменил его на кастомный компонент на 20 строк и сэкономил 170 КБ.

Шаг 4: Время ответа сервера (TTFB)#

LCP не может быть быстрым, если TTFB медленный. Если твой сервер отвечает 800 мс, то LCP будет минимум 800 мс + всё остальное.

На этом сайте (Node.js + PM2 + Nginx на VPS) я замерил TTFB около 180 мс на холодном хите. Вот что я сделал, чтобы удержать его там:

  • ISR (Incremental Static Regeneration) для постов блога — страницы предрендерятся во время сборки и периодически ревалидируются. Первый визит отдаёт статический файл напрямую из реверс-прокси-кэша Nginx.
  • Edge-кэширование заголовкиCache-Control: public, s-maxage=3600, stale-while-revalidate=86400 на статических страницах.
  • Gzip/Brotli-сжатие в Nginx — уменьшает размер передачи на 60-80%.
bash
# nginx.conf snippet
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1000;
gzip_comp_level 6;
 
# Brotli (if ngx_brotli module is installed)
brotli on;
brotli_types text/plain text/css application/json application/javascript text/xml;
brotli_comp_level 6;

Мои результаты по LCP до/после:

  • До оптимизации: 3.8 с (75-й перцентиль, CrUX)
  • После preload + fetchPriority + сжатие: 1.8 с
  • Итого улучшение: 53% снижение

CLS: Смерть от тысячи сдвигов#

Cumulative Layout Shift измеряет, насколько видимый контент перемещается во время загрузки страницы. CLS равный 0 означает, что ничего не сдвинулось. CLS выше 0.1 означает, что что-то визуально раздражает твоих пользователей.

CLS — та метрика, которую большинство разработчиков недооценивают. Ты не замечаешь её на своей быстрой машине для разработки с полным кэшем. Твои пользователи замечают это на своих телефонах, на медленных соединениях, где шрифты грузятся поздно и изображения всплывают по одному.

Обычные виновники#

1. Изображения без явных размеров

Это самая частая причина CLS. Когда изображение загружается, оно сдвигает контент ниже себя вниз. Исправление до неловкости просто: всегда указывай width и height на тегах <img>.

tsx
// BAD — causes layout shift
<img src="/photo.jpg" alt="Team photo" />
 
// GOOD — browser reserves space before image loads
<img src="/photo.jpg" alt="Team photo" width={800} height={450} />

Если ты используешь Next.js <Image>, он обрабатывает это автоматически, пока ты предоставляешь размеры или используешь fill с контейнером заданного размера.

Но вот подвох: если ты используешь режим fill, родительский контейнер должен иметь явные размеры, иначе изображение вызовет CLS:

tsx
// BAD — parent has no dimensions
<div className="relative">
  <Image src="/photo.jpg" alt="Team" fill />
</div>
 
// GOOD — parent has explicit aspect ratio
<div className="relative aspect-video w-full">
  <Image src="/photo.jpg" alt="Team" fill sizes="100vw" />
</div>

2. Веб-шрифты, вызывающие FOUT/FOIT

Когда загружается кастомный шрифт, текст, отрендеренный фоллбэк-шрифтом, перерисовывается кастомным шрифтом. Если у двух шрифтов разные метрики (а так почти всегда), всё сдвигается.

Современное решение — font-display: swap в сочетании с фоллбэк-шрифтами с подогнанным размером:

tsx
// Using next/font — the best approach for Next.js
import { Inter } from "next/font/google";
 
const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  // next/font automatically generates size-adjusted fallback fonts
  // This eliminates CLS from font swapping
});
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" className={inter.className}>
      <body>{children}</body>
    </html>
  );
}

next/font — это по-настоящему одна из лучших вещей в Next.js. Он скачивает шрифты во время сборки, хостит их самостоятельно (никакого запроса к Google Fonts в рантайме) и генерирует фоллбэк-шрифты с подогнанным размером, так что подмена фоллбэка на кастомный шрифт вызывает нулевой сдвиг лейаута. Я замерил CLS от шрифтов на уровне 0.00 после перехода на next/font. До этого, со стандартным <link> Google Fonts, было 0.04-0.08.

3. Динамическая вставка контента

Рекламные баннеры, cookie-баннеры, полосы уведомлений — всё, что вставляется в DOM после начального рендеринга, вызывает CLS, если сдвигает контент вниз.

Решение: зарезервируй пространство для динамического контента до его загрузки.

tsx
// Cookie banner — reserve space at the bottom
function CookieBanner() {
  const [accepted, setAccepted] = useState(false);
 
  if (accepted) return null;
 
  return (
    // Fixed positioning doesn't cause CLS because it
    // doesn't affect document flow
    <div className="fixed bottom-0 left-0 right-0 z-50 bg-gray-900 p-4">
      <p>We use cookies. You know the drill.</p>
      <button onClick={() => setAccepted(true)}>Accept</button>
    </div>
  );
}

Использование position: fixed или position: absolute для динамических элементов — это подход без CLS, потому что эти элементы не влияют на нормальный поток документа.

4. CSS-трюк с aspect-ratio

Для адаптивных контейнеров, где ты знаешь соотношение сторон, но не точные размеры, используй CSS-свойство aspect-ratio:

tsx
// Video embed without CLS
function VideoEmbed({ src }: { src: string }) {
  return (
    <div className="w-full aspect-video bg-gray-900 rounded-lg overflow-hidden">
      <iframe
        src={src}
        className="w-full h-full"
        title="Embedded video"
        allow="accelerometer; autoplay; clipboard-write; encrypted-media"
        allowFullScreen
      />
    </div>
  );
}

Утилита aspect-video (это aspect-ratio: 16/9) резервирует ровно столько места, сколько нужно. Никакого сдвига при загрузке iframe.

5. Скелетон-экраны

Для контента, который загружается асинхронно (данные API, динамические компоненты), показывай скелетон, совпадающий по ожидаемым размерам:

tsx
function PostCardSkeleton() {
  return (
    <div className="animate-pulse rounded-lg border p-4">
      <div className="h-48 w-full rounded bg-gray-200" />
      <div className="mt-4 space-y-2">
        <div className="h-6 w-3/4 rounded bg-gray-200" />
        <div className="h-4 w-full rounded bg-gray-200" />
        <div className="h-4 w-5/6 rounded bg-gray-200" />
      </div>
    </div>
  );
}
 
function PostList() {
  const { data: posts, isLoading } = usePosts();
 
  if (isLoading) {
    return (
      <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
        {Array.from({ length: 6 }).map((_, i) => (
          <PostCardSkeleton key={i} />
        ))}
      </div>
    );
  }
 
  return (
    <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
      {posts?.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

Ключ в том, что PostCardSkeleton и PostCard должны иметь одинаковые размеры. Если скелетон высотой 200 пикселей, а реальная карточка — 280 пикселей, ты всё равно получишь сдвиг.

Мои результаты по CLS:

  • До: 0.12 (одна подмена шрифта давала 0.06)
  • После: 0.00 — буквально ноль, на тысячах загрузок страниц в данных CrUX

INP: новичок, который кусается#

Interaction to Next Paint заменил First Input Delay в марте 2024 года, и это фундаментально более сложная метрика для оптимизации. FID измерял только задержку до обработки первого взаимодействия. INP измеряет каждое взаимодействие на протяжении всего жизненного цикла страницы и сообщает худшее (на 75-м перцентиле).

Это означает, что страница может иметь отличный FID, но ужасный INP, если, скажем, клик по выпадающему меню через 30 секунд после загрузки вызывает 500 мс перекомпоновки.

Что вызывает высокий INP#

  1. Длинные задачи на главном потоке — Любое выполнение JavaScript длительностью более 50 мс блокирует главный поток. Пользовательские взаимодействия, которые происходят во время длинной задачи, вынуждены ждать.
  2. Дорогие перерендеры в React — Обновление состояния, которое вызывает перерендер 200 компонентов, занимает время. Пользователь кликает, React сверяет, и отрисовка не происходит 300 мс.
  3. Тразинг лейаута — Чтение свойств лейаута (типа offsetHeight), а затем запись (типа изменения style.height) в цикле заставляет браузер синхронно пересчитывать лейаут.
  4. Большой DOM — Больше DOM-узлов значит медленнее пересчёт стилей и лейаут. DOM с 5,000 узлами заметно медленнее, чем с 500.

Разбивка длинных задач с помощью scheduler.yield()#

Самая эффективная техника для INP — разбивка длинных задач, чтобы браузер мог обработать пользовательские взаимодействия между частями:

tsx
async function processLargeDataset(items: DataItem[]) {
  const results: ProcessedItem[] = [];
 
  for (let i = 0; i < items.length; i++) {
    results.push(expensiveTransform(items[i]));
 
    // Every 10 items, yield to the browser
    // This lets pending user interactions get processed
    if (i % 10 === 0 && "scheduler" in globalThis) {
      await scheduler.yield();
    }
  }
 
  return results;
}

scheduler.yield() доступен в Chrome 129+ (сентябрь 2024) и является рекомендованным способом уступить главному потоку. Для браузеров, которые его не поддерживают, можно откатиться на обёртку setTimeout(0):

tsx
function yieldToMain(): Promise<void> {
  if ("scheduler" in globalThis && "yield" in scheduler) {
    return scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}

useTransition для несрочных обновлений#

React 18+ даёт нам useTransition, который говорит React, что определённые обновления состояния не срочные и могут быть прерваны более важной работой (например, ответом на пользовательский ввод):

tsx
import { useState, useTransition } from "react";
 
function SearchableList({ items }: { items: Item[] }) {
  const [query, setQuery] = useState("");
  const [filteredItems, setFilteredItems] = useState(items);
  const [isPending, startTransition] = useTransition();
 
  function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value;
 
    // This update is urgent — the input must reflect the keystroke immediately
    setQuery(value);
 
    // This update is NOT urgent — filtering 10,000 items can wait
    startTransition(() => {
      const filtered = items.filter((item) =>
        item.name.toLowerCase().includes(value.toLowerCase())
      );
      setFilteredItems(filtered);
    });
  }
 
  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={handleSearch}
        placeholder="Search..."
        className="w-full rounded border px-4 py-2"
      />
      {isPending && (
        <p className="mt-2 text-sm text-gray-500">Filtering...</p>
      )}
      <ul className="mt-4 space-y-2">
        {filteredItems.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

Без startTransition ввод в поле поиска ощущался бы тормозным, потому что React пытался бы отфильтровать 10,000 элементов синхронно перед обновлением DOM. С startTransition инпут обновляется мгновенно, а фильтрация происходит в фоне.

Я замерил INP на странице инструмента со сложным обработчиком ввода. До useTransition: 380 мс INP. После: 90 мс INP. Это 76% улучшения от смены React API.

Дебаунсинг обработчиков ввода#

Для обработчиков, которые запускают дорогие операции (API-вызовы, тяжёлые вычисления), дебаунси их:

tsx
import { useCallback, useRef } from "react";
 
function useDebounce<T extends (...args: unknown[]) => void>(
  fn: T,
  delay: number
): T {
  const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
 
  return useCallback(
    ((...args: unknown[]) => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
      timeoutRef.current = setTimeout(() => fn(...args), delay);
    }) as T,
    [fn, delay]
  );
}
 
// Usage
function LiveSearch() {
  const [results, setResults] = useState<SearchResult[]>([]);
 
  const search = useDebounce(async (query: string) => {
    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
    const data = await response.json();
    setResults(data.results);
  }, 300);
 
  return (
    <input
      type="text"
      onChange={(e) => search(e.target.value)}
      placeholder="Search..."
    />
  );
}

300 мс — моё стандартное значение дебаунса. Достаточно короткое, чтобы пользователи не замечали задержку, достаточно длинное, чтобы не срабатывать на каждое нажатие клавиши.

Web Workers для тяжёлых вычислений#

Если у тебя по-настоящему тяжёлые вычисления (парсинг большого JSON, обработка изображений, сложные расчёты), перенеси их с главного потока полностью:

tsx
// worker.ts
self.addEventListener("message", (event) => {
  const { data, operation } = event.data;
 
  switch (operation) {
    case "sort": {
      // This could take 500ms for large datasets
      const sorted = data.sort((a: number, b: number) => a - b);
      self.postMessage({ result: sorted });
      break;
    }
    case "filter": {
      const filtered = data.filter((item: DataItem) =>
        complexFilterLogic(item)
      );
      self.postMessage({ result: filtered });
      break;
    }
  }
});
tsx
// useWorker.ts
import { useEffect, useRef, useCallback } from "react";
 
function useWorker() {
  const workerRef = useRef<Worker>();
 
  useEffect(() => {
    workerRef.current = new Worker(
      new URL("../workers/worker.ts", import.meta.url)
    );
    return () => workerRef.current?.terminate();
  }, []);
 
  const process = useCallback(
    (operation: string, data: unknown): Promise<unknown> => {
      return new Promise((resolve) => {
        if (!workerRef.current) return;
 
        workerRef.current.onmessage = (event) => {
          resolve(event.data.result);
        };
 
        workerRef.current.postMessage({ operation, data });
      });
    },
    []
  );
 
  return { process };
}

Web Workers работают в отдельном потоке, поэтому даже двухсекундное вычисление вообще не повлияет на INP. Главный поток остаётся свободным для обработки пользовательских взаимодействий.

Мои результаты по INP:

  • До: 340 мс (худшее взаимодействие — инструмент тестирования регулярных выражений со сложной обработкой ввода)
  • После useTransition + дебаунсинг: 110 мс
  • Улучшение: 68% снижение

Специфические преимущества Next.js#

Если ты на Next.js (13+ с App Router), у тебя есть доступ к мощным перформанс-примитивам, которые большинство разработчиков не используют полностью.

next/image — но правильно настроенный#

next/image хорош, но конфигурация по умолчанию оставляет производительность на столе:

tsx
// next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  images: {
    formats: ["image/avif", "image/webp"],
    deviceSizes: [640, 750, 828, 1080, 1200],
    imageSizes: [16, 32, 48, 64, 96, 128, 256],
    minimumCacheTTL: 60 * 60 * 24 * 365, // 1 year
  },
};
 
export default nextConfig;

Ключевые настройки:

  • formats: ["image/avif", "image/webp"] — AVIF на 20-50% меньше WebP. Порядок важен: Next.js пробует AVIF первым, откатывается на WebP, затем на оригинальный формат.
  • minimumCacheTTL — По умолчанию 60 секунд. Для блога изображения не меняются. Кэшируй их на год.
  • deviceSizes и imageSizes — Дефолты включают 3840 пикселей. Если ты не раздаёшь 4K-изображения, урежь этот список. Каждый размер генерирует отдельное кэшированное изображение, а неиспользуемые размеры тратят дисковое пространство и время сборки.

И всегда используй проп sizes, чтобы сказать браузеру, какого размера изображение будет рендериться:

tsx
// Full-width hero image
<Image
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={600}
  sizes="100vw"
  priority // LCP image — don't lazy load it!
/>
 
// Card image in a responsive grid
<Image
  src="/card.jpg"
  alt="Card"
  width={400}
  height={300}
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>

Без sizes браузер может скачать изображение 1200 пикселей для слота 300 пикселей. Это потраченные байты и потраченное время.

Проп priority на LCP-изображении критически важен. Он отключает ленивую загрузку и автоматически добавляет fetchPriority="high". Если твой LCP-элемент — next/image, просто добавь priority, и ты на полпути.

next/font — шрифты с нулевым сдвигом лейаута#

Я затронул это в разделе CLS, но это заслуживает внимания. next/font — единственное решение загрузки шрифтов, которое я видел и которое стабильно достигает нулевого CLS:

tsx
import { Inter, JetBrains_Mono } from "next/font/google";
 
const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-inter",
});
 
const jetbrainsMono = JetBrains_Mono({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-mono",
});
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html
      lang="en"
      className={`${inter.variable} ${jetbrainsMono.variable}`}
    >
      <body className="font-sans">{children}</body>
    </html>
  );
}

Два шрифта, нулевой CLS, ноль внешних запросов в рантайме. Шрифты скачиваются во время сборки и раздаются с твоего домена.

Стриминг с Suspense#

Именно тут Next.js становится по-настоящему интересным для производительности. С App Router ты можешь стримить части страницы в браузер по мере их готовности:

tsx
import { Suspense } from "react";
import { PostList } from "@/components/blog/PostList";
import { Sidebar } from "@/components/blog/Sidebar";
import { PostListSkeleton } from "@/components/blog/PostListSkeleton";
import { SidebarSkeleton } from "@/components/blog/SidebarSkeleton";
 
export default function BlogPage() {
  return (
    <div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
      <div className="lg:col-span-2">
        {/* This loads fast — stream it immediately */}
        <h1 className="text-4xl font-bold">Blog</h1>
 
        {/* This requires a database query — stream it when ready */}
        <Suspense fallback={<PostListSkeleton />}>
          <PostList />
        </Suspense>
      </div>
 
      <aside>
        {/* Sidebar can load independently */}
        <Suspense fallback={<SidebarSkeleton />}>
          <Sidebar />
        </Suspense>
      </aside>
    </div>
  );
}

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

Это особенно мощно для LCP. Если твой LCP-элемент — заголовок (а не список постов), он рендерится мгновенно независимо от того, сколько длится запрос к базе данных.

Конфигурация сегмента маршрута#

Next.js позволяет настраивать кэширование и ревалидацию на уровне сегмента маршрута:

tsx
// app/blog/page.tsx
// Revalidate this page every hour
export const revalidate = 3600;
 
// app/tools/[slug]/page.tsx
// These tool pages are fully static — generate at build time
export const dynamic = "force-static";
 
// app/api/search/route.ts
// API route — never cache
export const dynamic = "force-dynamic";

На этом сайте посты блога используют revalidate = 3600 (1 час). Страницы инструментов используют force-static, потому что их контент не меняется между деплоями. API поиска использует force-dynamic, потому что каждый запрос уникален.

Результат: большинство страниц отдаётся из статического кэша, TTFB ниже 50 мс для кэшированных страниц, и сервер едва напрягается.

Инструменты измерения: доверяй данным, а не глазам#

Твоё восприятие производительности ненадёжно. Твоя машина для разработки имеет 32 ГБ ОЗУ, NVMe SSD и гигабитное соединение. У твоих пользователей — нет.

Стек измерений, который я использую#

1. Панель Performance в Chrome DevTools

Самый детальный инструмент из доступных. Запиши загрузку страницы, посмотри на флеймчарт, найди длинные задачи, обнаружь рендер-блокирующие ресурсы. Именно тут я провожу большую часть своего времени на дебаг.

На что обращать внимание:

  • Красные уголки на задачах = длинные задачи (>50 мс)
  • События Layout/Paint, вызванные JavaScript
  • Большие блоки «Evaluate Script» (слишком много JavaScript)
  • Водопад сети, показывающий поздно обнаруженные ресурсы

2. Lighthouse

Хорош для быстрой проверки, но не оптимизируй ради баллов Lighthouse. Lighthouse работает в симулированной тротлинг-среде, которая не идеально совпадает с реальными условиями. Я видел страницы с оценкой 98 в Lighthouse и 4 секунды LCP в поле.

Используй Lighthouse для направления, а не как табло.

3. PageSpeed Insights

Самый важный инструмент для продакшен-сайтов, потому что он показывает реальные данные CrUX — фактические замеры от реальных пользователей Chrome за последние 28 дней. Лабораторные данные говорят, что может произойти. Данные CrUX говорят, что происходит.

4. Библиотека web-vitals

Добавь это на свой продакшен-сайт для сбора метрик реальных пользователей:

tsx
// components/analytics/WebVitals.tsx
"use client";
 
import { useEffect } from "react";
import { onCLS, onINP, onLCP, onFCP, onTTFB } from "web-vitals";
import type { Metric } from "web-vitals";
 
function sendToAnalytics(metric: Metric) {
  // Send to your analytics endpoint
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating, // "good" | "needs-improvement" | "poor"
    delta: metric.delta,
    id: metric.id,
    navigationType: metric.navigationType,
  });
 
  // Use sendBeacon so it doesn't block page unload
  if (navigator.sendBeacon) {
    navigator.sendBeacon("/api/vitals", body);
  } else {
    fetch("/api/vitals", {
      body,
      method: "POST",
      keepalive: true,
    });
  }
}
 
export function WebVitals() {
  useEffect(() => {
    onCLS(sendToAnalytics);
    onINP(sendToAnalytics);
    onLCP(sendToAnalytics);
    onFCP(sendToAnalytics);
    onTTFB(sendToAnalytics);
  }, []);
 
  return null;
}

Это даёт тебе собственные данные, подобные CrUX, но с большей детализацией. Можно сегментировать по странице, типу устройства, скорости соединения, географическому региону — что угодно.

5. Chrome User Experience Report (CrUX)

Датасет CrUX в BigQuery бесплатен и содержит скользящие 28-дневные данные для миллионов origin'ов. Если у твоего сайта достаточно трафика, можно запросить свои собственные данные:

sql
SELECT
  origin,
  p75_lcp,
  p75_cls,
  p75_inp,
  form_factor
FROM
  `chrome-ux-report.materialized.metrics_summary`
WHERE
  origin = 'https://yoursite.com'
  AND yyyymm = 202603

Список на уничтожение водопада#

Сторонние скрипты — убийца производительности номер один на большинстве сайтов. Вот что я нашёл и что с этим сделал.

Google Tag Manager (GTM)#

Сам GTM — это ~80 КБ. Но GTM загружает другие скрипты — аналитику, маркетинговые пиксели, инструменты A/B-тестирования. Я видел конфигурации GTM, которые загружают 15 дополнительных скриптов общим объёмом 2 МБ.

Мой подход: Не используй GTM в продакшене. Загружай скрипты аналитики напрямую, откладывай всё и используй loading="lazy" для скриптов, которые могут подождать:

tsx
// Instead of GTM loading everything
// Load only what you need, when you need it
 
export function AnalyticsScript() {
  return (
    <script
      defer
      src="https://analytics.example.com/script.js"
      data-website-id="your-id"
    />
  );
}

Если без GTM совсем никак, загружай его после того, как страница станет интерактивной:

tsx
"use client";
 
import { useEffect } from "react";
 
export function DeferredGTM({ containerId }: { containerId: string }) {
  useEffect(() => {
    // Wait until after page load to inject GTM
    const timer = setTimeout(() => {
      const script = document.createElement("script");
      script.src = `https://www.googletagmanager.com/gtm.js?id=${containerId}`;
      script.async = true;
      document.head.appendChild(script);
    }, 3000); // 3 second delay
 
    return () => clearTimeout(timer);
  }, [containerId]);
 
  return null;
}

Да, ты потеряешь данные от пользователей, которые уходят в первые 3 секунды. По моему опыту, это стоящий компромисс. Эти пользователи всё равно не конвертировались.

Чат-виджеты#

Виджеты живого чата (Intercom, Drift, Crisp) — одни из худших нарушителей. Один Intercom загружает 400+ КБ JavaScript. На странице, где 2% пользователей реально кликают по кнопке чата, это 400 КБ JavaScript для 98% пользователей.

Моё решение: Загружай виджет по взаимодействию.

tsx
"use client";
 
import { useState } from "react";
 
export function ChatButton() {
  const [loaded, setLoaded] = useState(false);
 
  function loadChat() {
    if (loaded) return;
 
    // Load the chat widget script only when the user clicks
    const script = document.createElement("script");
    script.src = "https://chat-widget.example.com/widget.js";
    script.onload = () => {
      // Initialize the widget after script loads
      window.ChatWidget?.open();
    };
    document.head.appendChild(script);
    setLoaded(true);
  }
 
  return (
    <button
      onClick={loadChat}
      className="fixed bottom-4 right-4 rounded-full bg-blue-600 p-4 text-white shadow-lg"
      aria-label="Open chat"
    >
      {loaded ? "Loading..." : "Chat with us"}
    </button>
  );
}

Неиспользуемый JavaScript#

Запусти Coverage в Chrome DevTools (Ctrl+Shift+P > «Show Coverage»). Он покажет, сколько именно каждого скрипта реально используется на текущей странице.

На типичном Next.js-сайте я обычно нахожу:

  • Библиотеки компонентов, загруженные целиком — Ты импортируешь Button из UI-библиотеки, но вся библиотека попадает в бандл. Решение: используй tree-shakeable библиотеки или импортируй по подпутям (import Button from "lib/Button" вместо import { Button } from "lib").
  • Полифилы для современных браузеров — Проверь, не шипишь ли ты полифилы для Promise, fetch или Array.prototype.includes. В 2026 году они тебе не нужны.
  • Мёртвые feature-флаги — Ветки кода за feature-флагами, которые были «включены» полгода назад. Удали флаг и мёртвую ветку.

Я использую анализатор бандлов Next.js для поиска раздутых чанков:

tsx
// next.config.ts
import withBundleAnalyzer from "@next/bundle-analyzer";
 
const nextConfig = {
  // your config
};
 
export default process.env.ANALYZE === "true"
  ? withBundleAnalyzer({ enabled: true })(nextConfig)
  : nextConfig;
bash
ANALYZE=true npm run build

Это открывает визуальную тримап твоих бандлов. Я нашёл библиотеку форматирования дат на 120 КБ, которую заменил на нативный Intl.DateTimeFormat. Нашёл парсер markdown на 90 КБ, импортированный на странице, которая markdown не использовала. Маленькие победы, которые складываются.

Рендер-блокирующий CSS#

Я упоминал это в разделе LCP, но стоит повторить, потому что это так распространено. Каждый <link rel="stylesheet"> в <head> блокирует рендеринг. Если у тебя пять таблиц стилей, браузер ждёт все пять, прежде чем нарисовать что-либо.

Next.js с Tailwind справляется с этим хорошо — CSS инлайнится и минимален. Но если ты импортируешь сторонний CSS, проверь его:

tsx
// BAD — loads entire library CSS on every page
import "some-library/dist/styles.css";
 
// BETTER — dynamic import so it only loads on pages that need it
const SomeComponent = dynamic(
  () => import("some-library").then((mod) => {
    // CSS is imported inside the dynamic component
    import("some-library/dist/styles.css");
    return mod.SomeComponent;
  }),
  { ssr: false }
);

Реальная история оптимизации#

Расскажу пошагово про реальную оптимизацию страницы инструментов этого сайта. Это страница с 15+ интерактивными инструментами, каждый со своим компонентом, и некоторые из них (вроде тестера регулярных выражений и форматтера JSON) тяжёлые на JavaScript.

Отправная точка#

Начальные измерения (данные CrUX, мобильные, 75-й перцентиль):

  • LCP: 3.8 с — Плохо
  • CLS: 0.12 — Нужно улучшить
  • INP: 340 мс — Плохо

Оценка Lighthouse: 62.

Расследование#

Анализ LCP: LCP-элемент — это заголовок страницы (<h1>), который должен рендериться мгновенно. Но его задерживали:

  1. CSS-файл на 200 КБ из библиотеки компонентов (рендер-блокирующий)
  2. Кастомный шрифт, загруженный через CDN Google Fonts (FOIT на 800 мс на медленных соединениях)
  3. TTFB 420 мс, потому что страница рендерилась на сервере при каждом запросе без кэширования

Анализ CLS: Три источника:

  1. Подмена шрифта с фоллбэка Google Fonts на кастомный шрифт: 0.06
  2. Карточки инструментов загружаются без резервирования высоты: 0.04
  3. Cookie-баннер, вставленный вверху страницы, сдвигающий всё вниз: 0.02

Анализ INP: Тестер регулярных выражений — худший нарушитель. Каждое нажатие клавиши в поле ввода regex вызывало:

  1. Полный перерендер всего компонента инструмента
  2. Вычисление regex для тестовой строки
  3. Подсветку синтаксиса паттерна regex

Общее время на нажатие клавиши: 280-400 мс.

Исправления#

Неделя 1: LCP и CLS

  1. Заменил CDN Google Fonts на next/font. Шрифт теперь самохостится, загружается во время сборки, с фоллбэком подогнанного размера. CLS от шрифтов: 0.06 → 0.00

  2. Удалил CSS библиотеки компонентов. Переписал 3 компонента, которые из неё использовал, на Tailwind. Всего удалено CSS: 180 КБ. Рендер-блокирующий CSS: устранён

  3. Добавил revalidate = 3600 на страницу инструментов и детальные страницы инструментов. Первый хит рендерится на сервере, последующие отдаются из кэша. TTFB: 420 мс → 45 мс (кэшировано)

  4. Добавил явные размеры всем компонентам карточек инструментов и использовал aspect-ratio для адаптивных лейаутов. CLS от карточек: 0.04 → 0.00

  5. Переместил cookie-баннер на position: fixed внизу экрана. CLS от баннера: 0.02 → 0.00

Неделя 2: INP

  1. Обернул вычисление результатов тестера regex в startTransition:
tsx
function RegexTester() {
  const [pattern, setPattern] = useState("");
  const [testString, setTestString] = useState("");
  const [results, setResults] = useState<RegexResult[]>([]);
  const [isPending, startTransition] = useTransition();
 
  function handlePatternChange(value: string) {
    setPattern(value); // Urgent: update the input
 
    startTransition(() => {
      // Non-urgent: compute matches
      try {
        const regex = new RegExp(value, "g");
        const matches = [...testString.matchAll(regex)];
        setResults(
          matches.map((m) => ({
            match: m[0],
            index: m.index ?? 0,
            groups: m.groups,
          }))
        );
      } catch {
        setResults([]);
      }
    });
  }
 
  return (
    <div>
      <input
        value={pattern}
        onChange={(e) => handlePatternChange(e.target.value)}
        className={isPending ? "opacity-70" : ""}
      />
      {/* results rendering */}
    </div>
  );
}

INP на тестере regex: 380 мс → 85 мс

  1. Добавил дебаунсинг обработчику ввода форматтера JSON (задержка 300 мс). INP на форматтере JSON: 260 мс → 60 мс

  2. Перенёс вычисления генератора хэшей в Web Worker. Хэширование SHA-256 больших входных данных теперь происходит полностью вне главного потока. INP на генераторе хэшей: 200 мс → 40 мс

Результаты#

После двух недель оптимизации (данные CrUX, мобильные, 75-й перцентиль):

  • LCP: 3.8 с → 1.8 с (53% улучшение)
  • CLS: 0.12 → 0.00 (100% улучшение)
  • INP: 340 мс → 110 мс (68% улучшение)

Оценка Lighthouse: 62 → 97.

Все три метрики уверенно в зоне «Хорошо». Страница ощущается мгновенной на мобильных. И органический поисковый трафик вырос на 12% в месяц после улучшений (хотя доказать причинно-следственную связь я не могу — были и другие факторы).

Чеклист#

Если ты не вынесешь из этого поста ничего другого, вот чеклист, который я прогоняю на каждом проекте:

LCP#

  • Определи LCP-элемент с помощью DevTools
  • Добавь priority (или fetchPriority="high") к LCP-изображению
  • Предзагрузи LCP-ресурсы в <head>
  • Устрани рендер-блокирующий CSS
  • Самохости шрифты с next/font
  • Включи Brotli/Gzip-сжатие
  • Используй статическую генерацию или ISR где возможно
  • Установи агрессивные заголовки кэширования для статических ассетов

CLS#

  • Все изображения имеют явные width и height
  • Используется next/font с фоллбэками подогнанного размера
  • Динамический контент использует position: fixed/absolute или зарезервированное пространство
  • Скелетон-экраны совпадают по размерам с реальными компонентами
  • Нет вставки контента вверху страницы после загрузки

INP#

  • Нет длинных задач (>50 мс) во время обработчиков взаимодействий
  • Несрочные обновления состояния обёрнуты в startTransition
  • Обработчики ввода дебаунсятся (300 мс)
  • Тяжёлые вычисления вынесены в Web Workers
  • Размер DOM ниже 1,500 узлов где возможно

Общее#

  • Сторонние скрипты загружаются после интерактивности страницы
  • Размер бандла проанализирован и tree-shaken
  • Неиспользуемый CSS удалён
  • Изображения раздаются в формате AVIF/WebP
  • Мониторинг реальных пользователей в продакшене (библиотека web-vitals)

Заключительные мысли#

Оптимизация производительности — это не разовая задача. Это дисциплина. Каждая новая фича, каждая новая зависимость, каждый новый сторонний скрипт — это потенциальная регрессия. Быстрыми остаются те сайты, где кто-то постоянно следит за метриками, а не те, где кто-то провёл разовый оптимизационный спринт.

Настрой мониторинг реальных пользователей. Настрой алерты при регрессии метрик. Сделай производительность частью процесса код-ревью. Когда кто-то добавляет библиотеку на 200 КБ, спроси, есть ли альтернатива на 5 КБ. Когда кто-то добавляет синхронное вычисление в обработчик событий, спроси, можно ли его отложить или перенести в воркер.

Техники в этом посте не теоретические. Это то, что я реально сделал, на этом сайте, с реальными цифрами в подтверждение. Твой результат будет отличаться — каждый сайт разный, каждая аудитория разная, каждая инфраструктура разная. Но принципы универсальны: грузи меньше, грузи умнее, не блокируй главный поток.

Твои пользователи не пришлют тебе благодарственную записку за быстрый сайт. Но они останутся. Они вернутся. И Google это заметит.

Похожие записи