Real User Monitoring в 2026: web-vitals.js, CrUX и анализ Core Web Vitals по перцентилям

Собираем Core Web Vitals с реального трафика: web-vitals.js v4, sendBeacon в visibilitychange, CrUX API, BigQuery и сегментация p75 по устройствам.

Гайд RUM: web-vitals.js, CrUX (2026)

Обновлено: 2 июля 2026

Real User Monitoring (RUM) собирает Core Web Vitals с браузеров ваших живых пользователей, а не с лабораторных синтетических запусков Lighthouse. Честно говоря, 90% команд, которые ко мне приходят с вопросом «почему p75 LCP красный в CrUX, а Lighthouse показывает 92», просто не смотрят на полевые данные. В этом руководстве мы настроим web-vitals.js v4 с attribution build, отправим метрики на свой бэкенд через sendBeacon, соединим их с CrUX API и BigQuery, и научимся читать перцентили по классам устройств.

  • Google оценивает Core Web Vitals по 75-му перцентилю за 28-дневное окно CrUX. Синтетическая медиана в Lighthouse эту метрику не отражает.
  • web-vitals.js v4 добавил onINP вместо устаревшего onFID, плюс attribution build с детальной диагностикой (target element, event target, load state).
  • Отправляйте метрики через navigator.sendBeacon в событии visibilitychange. Обычный fetch() при beforeunload теряет до 30% измерений на мобильных.
  • CrUX API даёт публичные p75 для любого origin, CrUX History API даёт 25 недель истории, BigQuery даёт сырые распределения по 24 странам и 3 классам устройств.
  • Всегда сегментируйте RUM по navigator.connection.effectiveType, устройству и типу навигации. Общее p75 скрывает регрессии на медленных устройствах.
  • Google Analytics 4 принимает web-vitals как события, но точность урезана до 100 бакетов. Для серьёзной аналитики нужен свой сток.

Почему синтетический перф это удобная ложь

Я устал объяснять на код-ревью, что «93 в Lighthouse» ничего не значит для реальных пользователей. Lighthouse запускает эмуляцию Moto G4 с искусственным дросселем 1.6 Мбит/с. А ваш пользователь сидит на Xiaomi Redmi 9 в московском метро, с реальной пиковой задержкой 800 мс и Chrome v138, у которого 20 открытых вкладок. Эти два мира не пересекаются.

Google это прекрасно понимает. Именно поэтому официальный порог Core Web Vitals формулируется как «75-й перцентиль по всем визитам за 28 дней в CrUX». Синтетический прогон даёт вам одно значение, а Google смотрит на распределение из миллионов реальных сессий. Если p75 LCP хуже 2.5 с, страница считается провалившейся, даже если Lighthouse рисует зелёный кружок.

В 2026 разрыв стал ещё заметнее. INP, ставший стабильным CWV в марте 2024, критически зависит от долгих задач основного потока во время реальных взаимодействий. Ни одна синтетическая среда не воспроизводит клик пользователя, который в этот момент проматывает три iframe с рекламой. Без RUM вы просто не увидите этих регрессий.

Что такое Real User Monitoring и чем он отличается от synthetic

RUM собирает перформанс-метрики через JavaScript, работающий у пользователя в браузере, и отправляет их на ваш бэкенд. Данные привязаны к реальной географии, реальной сети, реальному устройству и реальному состоянию страницы. Synthetic monitoring запускает браузер в контролируемой среде (например, WebPageTest, Lighthouse CI) с фиксированными параметрами. Оба нужны, но отвечают на разные вопросы.

ХарактеристикаReal User MonitoringSynthetic Monitoring
Источник данныхРеальные браузеры пользователейАвтоматизированный браузер в дата-центре
Что измеряетРеальное распределение по перцентилямОдно значение при заданных условиях
Основные метрикиLCP, INP, CLS, TTFB, FCPLighthouse-скор, LCP, TBT, Speed Index
Кто использует данныеGoogle Search (CrUX и ранжирование)Отладка регрессий в CI
СтоимостьХостинг сбора и аналитикаИнфраструктура запусков
Лаг данныхРеальное времяРеальное время, но одиночный сэмпл
Покрытие устройствВесь ваш реальный трафикТолько то, что вы эмулируете

Практическое правило, которое я даю всем командам: synthetic ловит регрессии до релиза, RUM после. Если Lighthouse CI в пул-реквесте показал +400 мс TBT, блокируйте мёрдж. Если p75 INP в проде за прошлую неделю пробил 200 мс, открывайте инцидент. Одно без другого превращается в слепое пятно.

Настройка web-vitals.js v4 с attribution

Библиотека web-vitals от Google Chrome Team это де-факто стандарт для сбора CWV. В v4 (релиз 2024) полностью убран FID, добавлен onINP как первоклассный экспорт, и появился attribution build, который возвращает диагностическую информацию: какой DOM-элемент оказался LCP, какой обработчик события задержал INP, где произошёл layout shift.

Установка через npm или CDN:

npm install web-vitals@^4.2.0
# либо загрузка attribution-варианта с CDN
<script type="module">
  import { onCLS, onFCP, onINP, onLCP, onTTFB }
    from 'https://unpkg.com/[email protected]/dist/web-vitals.attribution.js?module';
</script>

Attribution build весит примерно 3.5 КБ gzip против 1.7 КБ у обычного. Это цена детальной диагностики. Я всегда рекомендую его для продакшена. Без attribution вы видите «INP = 380 мс», а с ним понимаете, что виноват обработчик onClick у кнопки корзины, и событие произошло через 2.1 с после клика (это второй фрейм после «предполагаемого» первого).

Инициализация сборщика

// rum-init.js
import {
  onCLS, onFCP, onINP, onLCP, onTTFB
} from 'web-vitals/attribution';

const ENDPOINT = '/api/rum';
const sessionId = crypto.randomUUID();
const pageId = crypto.randomUUID();

function sendMetric(metric) {
  const body = {
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    delta: metric.delta,
    id: metric.id,
    navigationType: metric.navigationType,
    attribution: metric.attribution,
    sessionId,
    pageId,
    url: location.pathname,
    connection: navigator.connection?.effectiveType || 'unknown',
    dpr: window.devicePixelRatio,
    viewport: `${innerWidth}x${innerHeight}`,
    ts: Date.now()
  };
  if (navigator.sendBeacon) {
    navigator.sendBeacon(ENDPOINT, JSON.stringify(body));
  } else {
    fetch(ENDPOINT, { body: JSON.stringify(body), method: 'POST', keepalive: true });
  }
}

onLCP(sendMetric);
onINP(sendMetric);
onCLS(sendMetric);
onFCP(sendMetric);
onTTFB(sendMetric);

Обратите внимание на четыре вещи. Первое: sessionId и pageId нужны, чтобы группировать метрики одного визита. Второе: navigator.connection.effectiveType даёт грубый класс сети (4g/3g/2g/slow-2g), хранить его обязательно, иначе не сможете сегментировать. Третье: viewport вместо userAgent. UA-строки в Chrome 138+ сильно редактированы (User-Agent Client Hints), полагаться на них бесполезно. Четвёртое: metric.attribution содержит богатый объект (например, для INP это eventTarget, eventType, loadState, interactionTime), и я сохраняю его целиком в JSONB-колонку, а разбираю уже в SQL.

Отправка метрик через sendBeacon и visibilitychange

Классическая ошибка: слать метрики через обычный fetch() в событии beforeunload. Это ломается на мобильных Safari (событие часто не срабатывает при переключении вкладки) и в Chrome при back/forward cache. Правильный паттерн, Navigator.sendBeacon в обработчиках visibilitychange и pagehide.

// буферизуем метрики и отправляем при уходе со страницы
const buffer = [];
function bufferMetric(metric) {
  buffer.push(metric);
}
function flushBuffer() {
  if (!buffer.length) return;
  const payload = JSON.stringify({ session: sessionId, metrics: buffer });
  navigator.sendBeacon('/api/rum-batch', payload);
  buffer.length = 0;
}
addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flushBuffer();
});
addEventListener('pagehide', flushBuffer);

// подписываем колбэки на буфер вместо прямой отправки
onLCP(bufferMetric);
onINP(bufferMetric);
onCLS(bufferMetric);

Есть важный нюанс. В 2026 sendBeacon в Chrome 138+ уважает лимит в 64 КБ на один вызов. Если вы шлёте attribution со всеми полями и накопили 40+ метрик, вы можете упереться. Разбивайте на батчи по 20 метрик, либо режьте attribution до топ-полей (element, target, loadState).

Серверная приёмка

Я предпочитаю минимальный endpoint на Node.js/Fastify, который просто пишет в очередь (Kafka/Redis Streams) и возвращает 204 без задержки:

fastify.post('/api/rum-batch', async (req, reply) => {
  const { session, metrics } = req.body;
  const enriched = metrics.map(m => ({
    ...m,
    session,
    country: req.headers['cf-ipcountry'] || 'XX',
    receivedAt: Date.now()
  }));
  await redis.xadd('rum-stream', '*', 'batch', JSON.stringify(enriched));
  reply.code(204).send();
});

Никогда не пишите синхронно в основную БД. При пиковых нагрузках 200 QPS RUM-трафика и медленной вставке вы получите каскадный отказ. Пишите в стрим, а разгребайте асинхронно в ClickHouse или BigQuery.

CrUX API и CrUX History API

CrUX (Chrome User Experience Report) это агрегированный публичный датасет реальных Core Web Vitals с более чем миллиарда Chrome-пользователей. Он же источник данных, который Google Search использует для сигнала Page Experience. У CrUX три поверхности: CrUX API (текущие p75), CrUX History API (25 недель истории) и публичный датасет в BigQuery (месячные срезы за годы).

# Пример запроса к CrUX API v1
curl -X POST \
  "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "origin": "https://example.com",
    "formFactor": "PHONE",
    "metrics": ["largest_contentful_paint",
                "interaction_to_next_paint",
                "cumulative_layout_shift"]
  }'

Ответ содержит гистограмму (три бакета: good/needs-improvement/poor) и percentiles.p75 для каждой метрики. Ключевой момент. CrUX даёт данные по origin или по конкретному URL, но URL-уровень покрывает только сайты с достаточным трафиком (порядка 300+ визитов за 28 дней в Chrome). Если ваш URL не найден, спускайтесь к origin.

История нужна, чтобы отследить регрессии релизов. CrUX History API даёт до 25 недель по неделям. Я строю недельный дашборд: p75 LCP/INP/CLS × PHONE/DESKTOP × прошлые 25 недель. Скачок p75 INP на +80 мс между релизами это красный флаг, который я не увижу ни в GA4, ни в Lighthouse.

BigQuery: сырой CrUX и анализ конкурентов

Публичный датасет chrome-ux-report в BigQuery это самая мощная поверхность CrUX. Он даёт сырые гистограммы (100+ бакетов) для сотен тысяч origin по 24 странам и трём классам устройств. Стоимость: первый терабайт запросов в месяц бесплатно.

-- Топ-5 конкурентов по p75 LCP на мобильных в РФ, июнь 2026
SELECT
  origin,
  ROUND(SAFE_DIVIDE(
    SUM(CASE WHEN start < 2500 THEN density END),
    SUM(density)
  ), 3) AS good_lcp_share,
  ROUND(
    APPROX_QUANTILES(start, 100)[OFFSET(75)],
    0
  ) AS p75_lcp_ms
FROM `chrome-ux-report.country_ru.202606`,
UNNEST(largest_contentful_paint.histogram.bin)
WHERE form_factor.name = 'phone'
  AND origin IN (
    'https://ozon.ru',
    'https://wildberries.ru',
    'https://yandex.ru'
  )
GROUP BY origin
ORDER BY p75_lcp_ms;

Замечу: столбец density нормализован на 1.0 внутри origin, сумма долей всех бакетов даст единицу. Аппроксимация p75 через APPROX_QUANTILES работает, только если вы правильно взвешиваете. Для корректного взвешивания часто нужен CROSS JOIN с UNNEST плюс агрегация, в CrUX-кулинарной книге есть готовые SQL-рецепты.

Сегментация RUM: p75, классы устройств, тип сети

Единое общее p75 по всему трафику почти всегда бесполезно. Я всегда режу минимум по трём осям: класс устройства (PHONE/DESKTOP/TABLET), effectiveType (4g/3g/2g), тип навигации (navigate/back_forward/reload/prerender). Регрессия на устройствах с 3g и старом Android часто перекрывается 90% десктопного трафика в общей метрике, и вы её просто не увидите.

-- ClickHouse-запрос по собранным RUM-логам
SELECT
  toStartOfDay(ts) AS day,
  device_class,
  connection,
  quantileTDigest(0.75)(value) AS p75_inp,
  count() AS visits
FROM rum_events
WHERE name = 'INP'
  AND ts > now() - INTERVAL 28 DAY
GROUP BY day, device_class, connection
HAVING visits > 200
ORDER BY day DESC, p75_inp DESC;

Порог в 200 визитов важен: перцентили на маленьких выборках шумят катастрофически, вы получите ложные тревоги каждый час. Google в CrUX использует минимум 300 визитов на URL за 28 дней, берите за ориентир.

Attribution из web-vitals позволяет углубиться дальше. Если у вас плохой INP, посмотрите на eventTarget. Обычно 60% случаев концентрируются на 3-4 элементах. Оптимизация обработчиков этих элементов даёт мгновенный сдвиг p75. Подробнее про инструментирование INP я разбирала в статье оптимизация INP в 2026, а диагностику долгих кадров в разборе Long Animation Frames API для отладки INP.

Интеграция с GA4 и альтернативы

Если у вас нет ресурсов на свой RUM-сток, минимально жизнеспособный вариант это слать web-vitals в GA4 как события:

function sendToGA4(metric) {
  gtag('event', metric.name, {
    value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
    metric_id: metric.id,
    metric_value: metric.value,
    metric_delta: metric.delta,
    metric_rating: metric.rating,
    debug_target: metric.attribution?.element || ''
  });
}
onLCP(sendToGA4);
onINP(sendToGA4);
onCLS(sendToGA4);

Проблема GA4: он сэмплирует, урезает распределение и не даёт истинные перцентили. Параметры events в GA4 бакетируются, и получить настоящий p75 без BigQuery-экспорта нельзя. Для серьёзной аналитики подключайте BigQuery-экспорт GA4 (бесплатно с 2023 для стандартных проектов) и считайте p75 вручную.

Альтернативы: SpeedCurve LUX, Datadog RUM, New Relic Browser, Sentry Performance, Vercel Speed Insights, Cloudflare Web Analytics. Из бесплатных мне нравится Cloudflare, он даёт p75 CWV из коробки без установки SDK (собирает через встроенный script от Cloudflare CDN). Vercel Speed Insights удобен, если вы уже на Vercel, но привязан к их платформе. Если вы уже используете CDN-стратегии, посмотрите мой разбор приоритизации загрузки ресурсов в 2026. RUM без хорошего TTFB не спасёт.

Частые ошибки при внедрении RUM

За последние два года я разбирала десятки внедрений и вижу одни и те же грабли:

  1. Отправка каждой метрики отдельным fetch. Каждый POST это TCP-хендшейк или как минимум HTTP-запрос. На мобильных с 3G это добавляет 200-500 мс задержки. Батчите и шлите один раз в visibilitychange.
  2. Игнорирование bfcache. Back/forward cache в Chrome возвращает страницу мгновенно, но onLCP вернёт CLS/LCP с очень малым значением, и вы «улучшите» метрики искусственно. Фильтруйте по metric.navigationType === 'back_forward' отдельно.
  3. Смешение бота и человека. Headless-Chrome от Google Search, PageSpeed Insights и синтетические тесты тоже выполняют ваш скрипт. Проверяйте navigator.webdriver и фильтруйте по User-Agent Client Hints.
  4. Хранение только p75. Записывайте сырые значения, а p75 считайте на лету. Иначе вы не сможете пересчитать перцентиль по сегменту, о котором подумаете завтра.
  5. Отсутствие privacy-фильтра. Не собирайте document.referrer и полные URL с query-параметрами, там могут быть PII. Обезличивайте query-параметры или храните только pathname.
  6. Сравнение с медианой Lighthouse. Медиана 5 прогонов Lighthouse не равна p75 CrUX. Не удивляйтесь расхождению в 40-50%.

И последнее. RUM без обратной связи в разработку это мёртвый груз. Соедините его с CI: если p75 INP по критическому URL пробил 200 мс на 1000+ визитов за сутки, откатывайте релиз автоматически. Мониторинг без действия это дорогой лог.

Часто задаваемые вопросы

В чём разница между RUM и synthetic monitoring?

RUM собирает метрики с браузеров реальных пользователей и даёт распределение по перцентилям в проде. Synthetic monitoring запускает браузер в контролируемой среде с фиксированными условиями и даёт одно значение. Он годится для регрессионных тестов в CI, но не отражает реальный опыт пользователей.

Почему Google использует p75, а не медиану Core Web Vitals?

75-й перцентиль означает, что 75% пользователей увидели показатель не хуже указанного значения. Медиана (p50) скрывала бы плохой опыт четверти аудитории, а на мобильном трафике это часто десятки миллионов сессий. p75 балансирует между «средним» и «худшим» реальным опытом.

Достаточно ли web-vitals.js без attribution build для продакшена?

Для сбора чисел, да. Но вы не сможете диагностировать причины. Attribution build добавляет ~1.8 КБ gzip, зато возвращает конкретные DOM-элементы, обработчики событий и loadState. В моей практике attribution окупается на первом же серьёзном инциденте с INP.

Можно ли использовать CrUX данные конкурентов без их согласия?

Да, CrUX это публичный датасет, агрегированный и обезличенный. Google явно разрешает анализ любых origin. Через BigQuery-датасет chrome-ux-report вы можете сравнить p75 LCP/INP/CLS ваших конкурентов и найти отраслевые бенчмарки.

Как отправлять метрики без потерь при закрытии вкладки?

Используйте navigator.sendBeacon в обработчиках visibilitychange (условие document.visibilityState === 'hidden') и pagehide. Событие beforeunload не срабатывает на мобильных при переключении вкладки. Обычный fetch() с keepalive: true это приемлемый fallback, но с ограничением 64 КБ.

Сколько трафика нужно, чтобы получить достоверные RUM-перцентили?

Google в CrUX использует порог 300+ визитов на URL за 28 дней. Для внутреннего мониторинга я рекомендую минимум 200 сэмплов на сегмент за окно агрегации, иначе p75 будет шумным. Если у вас меньше, укрупняйте сегмент (не URL, а раздел сайта; не день, а неделя).

Nadia El-Sayed
Об авторе Nadia El-Sayed

Core Web Vitals specialist focused on real-user monitoring. Believes synthetic-only perf testing is a comforting lie.