Кастомные метрики производительности поверх Web Vitals
Привет! Я Максим, работаю в команде Ленты ВКонтакте и пишу про производительность фронтенда. В этой статье — про кастомные метрики производительности: зачем они нужны поверх Web Vitals, как выбирать точки измерения и как использовать данные, чтобы ловить регрессии и доказывать эффект оптимизаций.
Web Vitals хорошо отвечают на общие вопросы о производительности страницы. Но продуктовые сценарии устроены сложнее. Пользователю важно не только когда появился крупнейший элемент на экране, но и когда открылся диалог, стала доступна отправка сообщения, запустилось видео или отрисовалась новая порция постов в ленте. Для таких задач и нужны кастомные метрики.
Почему Web Vitals не всегда достаточно
Возьмём веб-мессенджер. Лабораторный LCP страницы (например, в Lighthouse) может быть около 1,7 секунды — хорошее значение для холодного старта: Google считает «хорошим» LCP до 2,5 секунды на 75-м перцентиле. Но сам мессенджер в этот момент ещё не обязательно готов к работе.
Дело в том, что LCP фиксирует появление крупнейшего изображения или текстового блока в области просмотра. На конкретной странице таким элементом может оказаться, например, рекламный баннер в боковой колонке. Метрика отработала корректно, но на вопрос «когда пользователь сможет начать переписку?» она не отвечает.

Для этого сценария полезнее отдельные метрики, например:
time_to_dialog_ready_cold— от начала загрузки страницы;time_to_dialog_ready_warm— от перехода в чат внутри приложения.
Обе заканчиваются моментом, когда поле ввода готово принимать текст.
Даже когда крупнейшим элементом становится сообщение или стикер и LCP ближе к реальному восприятию, его появление не значит, что пользователь уже может писать. Кроме того, LCP определён только для прямой загрузки страницы: переходы внутри одностраничного приложения он не видит. Та же история с полевыми данными CrUX: они показывают 75-й перцентиль Web Vitals реальных пользователей, но это всё равно Largest Contentful Paint, а не готовность основного сценария.
Поэтому Web Vitals и кастомные метрики решают разные задачи. Первые дают стандартный срез, который можно сравнивать между страницами и сайтами. Вторые измеряют конкретные действия внутри продукта.
Что считать кастомной метрикой
Под кастомной метрикой я понимаю измерение, собранное через User Timing API и привязанное к определённому пользовательскому сценарию. Например:
клик по видео → открылось модальное окно → появился первый кадр
переход в диалог → загрузился интерфейс → поле ввода готово к работе
Хорошая метрика отвечает на конкретный вопрос. Не «насколько быстро работает страница», а «сколько пользователь ждёт завершения сценария X».
Один сценарий можно разложить на этапы. Если рендер поста состоит из получения данных, разбора содержимого, рендера компонентов и появления изображения, каждый этап можно измерить отдельно. Если итоговая цифра ухудшилась, сразу видно, какая часть дала основное изменение.
На такие метрики удобно ставить целевые значения, например: p75 времени от клика по видео до первого кадра — меньше 1 секунды. Тогда метрика становится не только инструментом диагностики, но и критерием при раскатке изменений.
Как выбрать хорошую метрику
Самое сложное обычно не реализация, а формулировка. У хорошей метрики понятны начало, конец и смысл результата. Если команда видит time_to_dialog_ready_cold, должно быть очевидно, какой сценарий измеряется и что считается готовностью.
В первой версии метрик для ленты мы слишком увлеклись детализацией: хотели отдельно измерять появление изображений и разные стадии отрисовки контента. В реальных данных такие показатели оказались слишком шумными. Посты отличаются количеством картинок, структурой и типами содержимого, и чтобы что-то сравнивать, приходилось добавлять признаки и группировки. Следить за метриками стало сложнее, а не проще.
Отсюда принцип, к которому мы пришли: продуктовые и диагностические метрики проектируются отдельно.
- Продуктовая метрика стабильная и понятная без сложной сегментации. Её выносят на общий дашборд и используют в технических A/B-тестах.
- Диагностические метрики могут быть сколь угодно детальными. Они нужны, чтобы разложить сценарий на этапы и найти причину изменения.
Не смешивать разные условия в одном показателе
Другая частая проблема — неоднородные данные. Одна метрика может одновременно включать холодный и тёплый запуск, сценарии с кэшем и без, несколько разных точек входа. Если эти режимы сильно отличаются, одна агрегированная цифра мешает анализу: при регрессии сначала придётся выяснять, в какой группе она случилась.
Поэтому ещё до внедрения стоит решить, какие условия можно объединять, а какие передавать отдельными признаками. И заранее подумать, куда данные поедут: нужно хранилище, где их можно резать по признакам и смотреть перцентили, а не только среднее.
Кейс: рендер постов в ленте
Один из первых больших сценариев, где мы применили кастомные метрики, — рендер элементов ленты. Нас интересовало время от начала рендера поста до его готовности, и отдельно — этапы: разбор содержимого, появление изображения, различия между типами постов.
Мы разметили этапы рендера и смогли сравнивать время отдельных стадий для разных типов контента. Например, увидели эффект от перехода на новый механизм разбора текста поста: на p75 и p90 этот этап ускорился в 15–20 раз.
А вот верхнеуровневая метрика готовности поста практически не сдвинулась: её собственные дневные колебания оказались того же порядка, что и весь выигрыш. Мы ускорили операцию, но пользователь этого почти не заметил — этап не был узким местом сценария.
Это хороший пример того, зачем нужны оба уровня. Диагностическая метрика доказала, что оптимизация сработала. Продуктовая честно показала, что для пользователя это не главное, и подсказала искать узкое место в другом месте.
Почему это не видно в Web Vitals: изменение времени разбора текста практически невозможно разглядеть через INP, потому что эта операция не определяла итоговую задержку взаимодействия. С LCP похоже: случаи, когда нужный пост становится крупнейшим элементом первого экрана, — лишь часть сценариев. Кастомная метрика нужна именно потому, что мы оптимизируем конкретный этап, а не загрузку страницы целиком.
Кейс: открытие видео из ленты
Другой пример — открытие модального окна с видео из ленты. Сценарий начинается с клика по карточке и заканчивается моментом, когда окно открыто и видео начинает отображаться. Мы разметили этот путь и увидели, из каких этапов складывается задержка. Дальше оптимизировали по шагам:
- Разделили бандл модальных окон. Раньше один общий чанк содержал код всех модалок, и открытие любой тянуло за собой остальные.
- Перестали запрашивать уже известные данные. Объект видео передаётся из ленты в модалку сразу, недостающие поля догружаются в фоне.
- Предзагрузка по наведению. Чанк нужной модалки начинает грузиться при наведении на карточку, и к моменту клика модуль уже на месте.
- Убрали лишние ререндеры и передали в модалку время старта видео.
До изменений после клика пользователь мог несколько секунд видеть промежуточное состояние. После раскатки на графике сразу появилась «лесенка»: p75 времени от клика до первого кадра сократился примерно в 2,5–3 раза, медиана — примерно в 4 раза.
Web Vitals такой сценарий не измеряют: они могут косвенно коррелировать с общим состоянием интерфейса, но не знают, сколько прошло от конкретного клика до первого кадра.
Как это реализовать на открытых API
Разберём упрощённый пример: измерим открытие фотографии из ленты — от клика до появления картинки в модальном окне. Нужны три события:
feed:image_modal:click
feed:image_modal:visible
feed:image_modal:image_visible
Все PerformanceMark попадают в общий поток User Timing, поэтому сразу договоритесь о формате имён, например {область}:{сценарий}:{событие}. Это избавит от коллизий с метками других команд и сторонних библиотек.
Минимальный трекер сценария
// scenario.js — сценарий как набор меток User Timing
export function createScenario(name) {
const marks = [];
let finished = false;
return {
// startTime — если момент события известен точнее, чем «сейчас»
// (например, event.timeStamp клика или renderTime из Element Timing)
mark(step, startTime) {
if (finished) return;
const options = startTime === undefined ? undefined : { startTime };
marks.push(performance.mark(`${name}:${step}`, options));
},
finish(attrs = {}) {
if (finished || marks.length === 0) return;
finished = true;
const start = marks[0].startTime;
const steps = {};
for (const m of marks) {
steps[m.name] = Math.round(m.startTime - start);
performance.clearMarks(m.name);
}
send({ scenario: name, steps, ...attrs });
},
};
}
function send(payload) {
const body = JSON.stringify({
...payload,
// Признаки окружения: помогают резать данные при анализе.
// connection и deviceMemory есть только в Chromium — в остальных браузерах будет undefined.
connection: navigator.connection?.effectiveType,
cores: navigator.hardwareConcurrency,
memory: navigator.deviceMemory,
page: location.pathname,
});
// sendBeacon переживает закрытие вкладки; fetch с keepalive — запасной вариант
if (!navigator.sendBeacon?.("/rum", body)) {
fetch("/rum", { method: "POST", body, keepalive: true });
}
}
Каждое значение — время от первой метки сценария до текущей, финальная метка даёт длительность всего сценария. Обратите внимание на параметр startTime: метку стоит ставить на момент самого события, а не на момент, когда до него дошёл ваш код. Для клика это event.timeStamp, для картинки — время отрисовки из Element Timing.
Подключаем к интерфейсу
import { createScenario } from "./scenario.js";
let scenario = null;
feed.addEventListener("click", (event) => {
const photo = event.target.closest("[data-photo-id]");
if (!photo) return;
scenario = createScenario("feed:image_modal");
scenario.mark("click", event.timeStamp);
openImageModal(photo.dataset.photoId);
});
// Вызываем из кода модалки, когда она смонтирована и видна
export function onImageModalVisible() {
scenario?.mark("visible");
}
Как поймать момент появления картинки
С кликом и открытием окна всё просто: эти события контролирует приложение. С изображением сложнее: нужен не момент создания <img>, а фактическая отрисовка на экране. Для этого есть Element Timing API. Добавляем изображению атрибут elementtiming:
<img src="photo.jpg" alt="" elementtiming="modal_image" data-post-id="1_1">
И подписываемся на записи типа element:
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.identifier !== "modal_image") continue;
// renderTime — момент отрисовки. Для картинок с другого домена без заголовка
// Timing-Allow-Origin он может быть недоступен — тогда берём loadTime.
const paintedAt = entry.renderTime || entry.loadTime;
scenario?.mark("image_visible", paintedAt);
scenario?.finish({ postId: entry.element?.dataset.postId });
scenario = null;
}
});
observer.observe({ type: "element", buffered: true });
// Не забудьте observer.disconnect(), когда подписка больше не нужна
data-post-id позволяет связать отрисовку с конкретным элементом ленты. Важное ограничение: Element Timing поддерживается только в Chromium-браузерах, поэтому в выборке не будет пользователей Safari и Firefox. Для сценариев, где эта аудитория важна, нужен запасной сигнал, например событие load у картинки плюс requestAnimationFrame.
Вся цепочка:
клик → открытие модалки → фактическая отрисовка изображения → finish() → отправка статистики
Две ловушки при чтении данных
Незавершённые сценарии. В примере выше данные уходят только при finish(). Если пользователь закрыл модалку раньше, чем отрисовалась картинка, записи не будет вовсе. Для диагностических метрик это не страшно: они сравнивают этапы внутри завершённых сценариев. А для верхнеуровневых это смещение бьёт по хвосту: медленные сценарии чаще бросают, и p90 по завершённым выглядит лучше реального опыта. Решение — отправлять и брошенные сценарии с признаком abandoned: true и смотреть их долю.
Частота отправки. Если ограничивать отправку (например, одна запись на пользователя за какой-то период, чтобы активные пользователи не заливали статистику повторами), вы фактически собираете «первый сценарий пользователя за окно». Это нормально, но учитывайте при чтении перцентилей: первые открытия чаще холодные.
Как использовать данные после внедрения
Сам факт появления метрики пользы не даёт. Заранее решите, где и зачем команда будет на неё смотреть.
Для верхнеуровневых сценариев соберите отдельный дашборд и следите за несколькими перцентилями (p50, p75, p90), а не за средним. При изменении показателя нужно уметь разделить данные по важным условиям: устройство, тип соединения, точка входа, состояние кэша, вариант эксперимента.
Для технических A/B-тестов: если изменение должно ускорить открытие видео, измеряйте именно время открытия видео, а не пытайтесь подтвердить эффект через LCP всей страницы. Для критичных сценариев задайте целевой уровень и используйте его при решении о раскатке.
Чего не делать
Не начинайте с десятков метрик. Сначала выберите несколько ключевых сценариев и для каждого — одну понятную верхнеуровневую метрику. Детализацию добавляйте, когда она понадобится для диагностики.
Не заменяйте Web Vitals. LCP, INP и CLS дают стандартный срез и позволяют сравнивать страницы во времени и с конкурентами — как в перф-индексе рунета. Кастомные метрики этот слой дополняют.
Следите, что метрикой пользуются. Наш опыт с первой версией метрик ленты показал: если показатель трудно объяснить или для анализа каждый раз нужна ручная группировка, он быстро перестаёт быть рабочим инструментом.
Что в итоге
Кастомные метрики нужны там, где стандартные показатели не описывают конкретный пользовательский сценарий. Web Vitals покажут, когда появился крупный элемент и насколько быстро интерфейс отвечает на ввод. Но они не знают, когда мессенджер готов к отправке сообщения, сколько заняла отрисовка следующего поста или сколько прошло от клика по видео до первого кадра.
Процесс можно выстроить так:
- Сформулировать понятный пользовательский сценарий.
- Выбрать его начало и конец.
- Добавить одну основную метрику.
- Только потом — детальные показатели для диагностики.
Тогда данные помогают видеть регрессии, проверять оптимизации и принимать решения о раскатке, а не просто добавляют графиков.