Прогрессивное веб-приложение живёт по двойным правилам. Для пользователя это почти нативная программа с иконкой на рабочем столе и офлайн-режимом, а для браузера обычная страница, которую судят строгими метриками Core Web Vitals. В марте 2024 года судейская коллегия обновилась: метрику FID отправили в архив, а её место заняла INP, и командам PWA пришлось пересматривать привычные подходы к производительности. Ниже разобрано, что изменилось, из чего складываются секунды ожидания и какие приёмы отрабатывают себя в поле, а не только в лаборатории.

Что Core Web Vitals требуют от прогрессивного приложения

Три метрики задают планку качества. Largest Contentful Paint оценивает загрузку, INP отвечает за отзывчивость, Cumulative Layout Shift следит за стабильностью вёрстки. Целевые значения выглядят скромно: 2,5 секунды для LCP, 200 миллисекунд для INP и коэффициент 0,1 для CLS. Хорошим статус признаётся лишь тогда, когда его получает 75-й процентиль загрузок, причём мобильные и десктопные визиты считаются раздельно. Грубо говоря, три четверти визитов обязаны быть быстрыми, иначе сайт помечается как требующий улучшений, а поисковые системы учитывают скорость при ранжировании.

Для PWA ставки выше, чем для обычного сайта. Пользователь, установивший приложение, ждёт от него поведения программы: мгновенного запуска и живого отклика на каждое нажатие. Браузер проверяет формальные условия установки: в манифесте должны быть имя или короткое имя, иконки размером 192 и 512 пикселей, адрес запуска start_url и режим отображения вроде standalone или fullscreen. Долгое время требовался ещё и service worker с обработчиком fetch, но это условие позже смягчили, а сама технология осталась главным мотором скорости. Скорость не входит в критерии установки, зато решает, останется ли приложение на рабочем столе через неделю.

{
  "name": "Складской дневник",
  "short_name": "Склад",
  "start_url": "/app",
  "display": "standalone",
  "icons": [
    { "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" }
  ]
}

Переход с FID на INP меняет требования к отзывчивости

Старая метрика была честной ровно наполовину. FID мерила задержку до начала обработки первого же взаимодействия: пользователь тапнул по кнопке, секундомер останавливался в момент, когда главный поток смог взяться за обработчик. Сама обработка и отрисовка результата в метрику не попадали, как и все последующие нажатия. Страница могла похвастаться FID в 15 миллисекунд и одновременно подвисать на полсекунды на каждом следующем клике, потому что длинные задачи съедали главный поток уже после старта. Порогом считались 100 миллисекунд, и удержать его помогал главный приём тех лет: грузить меньше JavaScript при старте.

INP считает иначе. С 12 марта 2024 года именно она входит в тройку Core Web Vitals, а FID осталась только в отчётах прошлого. Новая метрика наблюдает все взаимодействия страницы и оценивает полную задержку от нажатия до появления следующего кадра с видимым результатом. Хорошим значением считается 200 миллисекунд и меньше, диапазон до 500 помечается как требующий улучшений, всё свыше признаётся плохим. Учитываются клики, тапы и нажатия клавиш, прокрутка и наведение курсора в подсчёт не попадают. Финальное значение берётся по худшим случаям: обычно сообщается самое медленное взаимодействие, а на активных страницах один худший случай отбрасывается на каждые 50 взаимодействий, поэтому о значении говорят как о 98-м процентиле. Расчёт опирается на Event Timing API, замеры идут прямо из браузера.

Задержка каждого взаимодействия распадается на три последовательные фазы:

  1. входная задержка, интервал до старта обработчиков, когда главный поток занят другими задачами;
  2. обработка, время выполнения самих обработчиков и связанных с ними обновлений состояния;
  3. задержка отрисовки, интервал между завершением обработчиков и показом следующего кадра.

Медленной бывает любая фаза, и лечение у каждой своё: входная задержка лечится разгрузкой главного потока, обработка упрощением логики, отрисовка аккуратной работой с деревом элементов. Виновника видно в трейсе производительности, где каждая фаза подсвечивается отдельно.

Largest Contentful Paint состоит из четырёх измеримых частей

LCP фиксирует момент, когда самый крупный видимый элемент контента отрисован: большую картинку, текстовый блок или видео. Отсчёт идёт от начала навигации, а не от первого байта. Браузер перебирает кандидатов: сначала кандидатом становится крупный заголовок, затем подгружается фотография героя и забирает звание самой большой детали страницы. Смену кандидата видно через PerformanceObserver с типом largest-contentful-paint, итогом становится последний кандидат. В учёт не идут невидимые элементы с нулевой прозрачностью, декоративные подложки на весь экран и размытые заглушки с низким информационным весом.

Инженерная традиция делит LCP на четыре части, оптимизировать вслепую бесполезно. Время до первого байта занимает около 40 процентов итога, задержка начала загрузки ресурса менее 10, сама загрузка примерно 40, а задержка отрисовки элемента снова менее 10 процентов. Цифры ориентировочные, но польза для диагностики велика. Пусть трейс показал картину: 600 миллисекунд до первого байта, ещё 150 до старта загрузки героя, 900 на саму загрузку и 350 на отрисовку, итого ровно 2 секунды. Сеть и сервер забрали 75 процентов времени, значит бороться нужно с доставкой, а не со скриптами.

Коварство в том, что части связаны между собой. Сжатие картинки в AVIF сокращает третью часть, но выигранное время не всегда превращается в улучшение метрики, оно просто перетекает в задержку отрисовки. Ускорение ответа сервера при медленно стартующем ресурсе тоже растворяется без следа. Побеждает только комплексный разбор, где каждой фазе достаётся своя доля внимания.

Service worker ускоряет повторные загрузки и обременяет первую

Вся механика PWA держится на service worker, и у неё своя цена. При первом визите worker скачивает список ресурсов в Cache Storage, и этот трафик ложится на ту же самую загрузку, которую метрика и измеряет. На слабой сети издержки установки кэша способны съесть весь выигрыш. Зато второй и последующие запуски получают контент с локального диска быстрее, чем по сети, и для LCP повторного визита это чистая прибыль.

Workbox превращает работу с кэшем из ремесла в конвейер. Precache-манифест описывает каждый ресурс с ревизией: файлам с хешем в имени отдельная ревизия не нужна, а HTML, чей адрес нельзя менять при каждом релизе, получает хеш содержимого в специальном поле. Когда выходит новая версия worker, она сверяет манифест со старым, докачивает новые файлы и обновляет изменившиеся в событии install, а пережитки старого кэша вычищает cleanupOutdatedCaches.

import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

precacheAndRoute(self.__WB_MANIFEST);

registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'content-images',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({ maxEntries: 60, maxAgeSeconds: 60 * 60 * 24 * 30 }),
    ],
  })
);

registerRoute(
  ({ request }) => request.mode === 'navigate',
  new NetworkFirst({
    cacheName: 'pages',
    plugins: [new ExpirationPlugin({ maxEntries: 10 })],
  })
);

Для навигаций стратегия CacheFirst опасна: пользователь рискует неделями жить со старой версией интерфейса, поэтому HTML обновляется через сеть с откатом в кэш. Отдельная головная боль это пауза на запуск самого worker: браузер должен оживить его скрипт, прежде чем fetch перехватит запрос, и на холодном старте эта пауза конкурирует с сетевой загрузкой. Механизм navigation preload запускает сетевой запрос параллельно со стартом worker, а результат подхватывается обработчиком через event.preloadResponse.

self.addEventListener('activate', (event) => {
  event.waitUntil(self.registration.navigationPreload.enable());
});

Практические приёмы ускорения LCP от предзагрузки до шрифтов

Первое правило экономии времени: главный ресурс должен быть виден в исходном HTML, чтобы его нашёл preload scanner ещё до исполнения скриптов. Картинка, спрятанная в CSS-фон, обнаруживается поздно, поэтому её выносят в явную предзагрузку. Второе правило: главному герою не место в ленивой загрузке, атрибут loading="lazy" оставляют нижним этажам ленты, а верхним назначают высокий приоритет через fetchpriority.

<link rel="preload" as="image"
      href="/hero/1280.avif"
      imagesrcset="/hero/640.avif 640w, /hero/1280.avif 1280w"
      imagesizes="100vw"
      fetchpriority="high">

Формат решает не меньше приоритета. AVIF и WebP при том же кадре весят меньше JPEG, а отзывчивые атрибуты srcset и sizes не позволяют телефону качать десктопную ширину. Для текстового героя отдельный враг это шрифты: пока файл не загружен, браузер либо прячет текст, либо рисует его запасным начертанием. Предзагрузка файла шрифта в паре с дескриптором font-display и значением swap удерживает метрику, а субсеттинг отрезает от файла лишние алфавиты.

<link rel="preload" as="font" type="font/woff2"
      href="/fonts/inter-cyrillic.woff2" crossorigin>

Ещё один пласт это критический CSS. Стили первого экрана инлайнятся прямо в документ, основной файл подключается асинхронно, и так снимается часть блокировки первой отрисовки. Клиентский рендеринг несёт свою плату: пустой контейнер под будущий контент не считается контентом, и LCP ждёт настоящих букв и картинок. Оболочку приложения стоит отдавать готовой разметкой с сервера, а живность навешивать поверх. Скелетоны экранов тут не спасают: они лишь маскируют ожидание, метрика же честно ждёт настоящего элемента.

Длинные задачи главного потока тормозят метрику INP

Отзывчивость решается не в момент клика, а за секунды до него. Пока главный поток занят скриптом, каждое нажатие стоит в очереди, и входная задержка растёт. Задача длиннее 50 миллисекунд в браузерной терминологии считается длинной, и таких задач на перегруженной странице десятки. Классические виновники: бандл на несколько мегабайтов, гидрация всего дерева, сторонние скрипты со своими обработчиками. На мобильном устройстве парсинг мегабайта JavaScript ощутимо дороже, чем на ноутбуке, и разрыв между лабораторией и полем рождается именно здесь.

const editor = await import('./features/editor.js');
editor.mount(target);

Арсенал известен давно: резать бандл на чанки и грузить по маршруту, откладывать некритичный код до спокойных минут через requestIdleCallback, уводить тяжёлые вычисления в Web Worker, дробить длинные задачи с уступкой управления потоку. Отдельная строка протокола это зрячие обновления: обработчик не должен перерисовывать полдерева ради одной цифры, чем меньше работы на отрисовку, тем короче третья фаза. И гигиеническая норма напоследок: не стоит читать стили и менять их вперемешку, браузер принудительно пересчитывает вёрстку на каждом шаге.

Инструменты замеров и регламент контроля метрик в реальном проекте

Лаборатория и поле смотрят на страницу по-разному. Трейс DevTools и Lighthouse раскладывают секунды на части и намекают на причины, но моделируют один профиль устройства. Полевые сводки собираются из визитов реальных пользователей и определяют статус Core Web Vitals. PageSpeed Insights совмещает обе картины, вебмастерская консоль показывает оценку по группам страниц. Библиотека web-vitals забирает значения прямо из браузерных API, а сборка attribution добавляет к каждому событию подсказку с элементом и фазой задержки.

import { onLCP, onINP, onCLS } from 'web-vitals';

onLCP((metric) => report('lcp', metric.value));
onINP((metric) => report('inp', metric.value));
onCLS((metric) => report('cls', metric.value));

Регламент прост: полевые значения собираются непрерывно и уходят в аналитику с контекстом страницы, порог 75-го процентиля проверяется на каждой итерации релиза, а любая регрессия разбирается по фазам. Оптимизация Core Web Vitals в PWA не разовый спринт, а встроенная гигиена поставки: service worker обновляет кэш без мусора, бандлы нарезаются на чанки, герой страницы предзагружается с высоким приоритетом, и установленное приложение отвечает за доли секунды, как положено программе.