{ "index": 19, "slug": "editorial-2027-06-field-performance-capstone", "title": "p95 не изменился: как найти настоящую причину медленной страницы", "excerpt": "Уменьшение bundle не гарантирует быстрый ответ. Разбираем полевой симптом, разделяем сервер, сеть и браузер, а затем принимаем решение по повторяемому p95.", "contentHtml": "
После релиза JavaScript-бандл стал меньше на 180 КБ, но p95 загрузки каталога остался около 3,1 секунды. Пользователь по-прежнему видит пустой первый экран. Цена ошибки — потратить спринт на минификацию, а затем обнаружить, что запрос к базе ждёт 1,8 секунды или браузер тратит время на главный поток. Размер файла изменился; причина задержки могла остаться прежней.
Числа в этом вступительном сценарии — иллюстрация, а не выгрузка telemetry конкретного проекта. Практический вопрос от этого не меняется: как доказать, какой участок пути тормозит страницу? Разложим один запуск на ожидание ответа, передачу документа и ресурсов, работу главного потока и отрисовку. Затем повторим измерение с теми же условиями. Результат становится инженерным доказательством, когда рядом с ним сохранены входы, сырые наблюдения, метод расчёта и критерий решения.
p95 — это выбранный квантиль распределения: после сортировки наблюдений он показывает уровень, ниже которого находится примерно 95% выборки по согласованному правилу расчёта. Оставшиеся наблюдения образуют хвост. Это не обещание каждому пользователю и не среднее время. Две команды могут получить разные p95 из одних событий, если по-разному обработают пропуски, сегменты, границы окна или интерполяцию.
Маленькая выборка делает хвост хрупким. На двадцати измерениях nearest-rank p95 фактически выбирает второе значение с конца; один поздний запуск заметно меняет результат. На пяти измерениях это ещё менее устойчиво. Поэтому в отчёте указывайте число наблюдений и храните исходные значения. Для полевой метрики отдельно фиксируйте окно агрегации, сегмент пользователей и правила исключения.
Не смешивайте показатели. TTFB — согласованная командой оценка времени до начала ответа, обычно связанная с точкой responseStart; она не описывает выполнение JavaScript. LCP — момент отрисовки крупнейшего видимого элемента в рамках правил метрики; он зависит от HTML, CSS, шрифта, изображения, viewport и устройства. Размер bundle описывает объём ресурса, но не говорит, какой запрос или задача главного потока задерживает первый экран.
| Поле | Пример | Зачем фиксировать |
|---|---|---|
| Версия | commit abc123 | Связать результат с кодом |
| Сценарий | открыть каталог и дождаться первого экрана | Не менять пользовательское действие между сериями |
| Условия | Chromium, 1280×800, cold cache | Не смешивать разные режимы |
| Выборка | 20 повторов в лаборатории | Понимать устойчивость хвоста |
| Сырые данные | JSON со всеми значениями | Пересчитать итог и увидеть выбросы |
| Метрики | TTFB, LCP, p95, long tasks | Отделить сервер от браузера |
Навигация начинается с запроса документа. До первого байта браузер ждёт сеть, proxy и сервер. Сервер в это время может ждать соединение с базой, блокировку, очередь или внешний сервис. После первого байта браузер получает остальной HTML. Затем parser обнаруживает CSS, обычные script и другие зависимости: они влияют на порядок загрузки и работу главного потока. LCP появляется только после того, как браузер нашёл и отрисовал подходящий крупнейший элемент.
Для расследования полезна рабочая карта server_wait + html_transfer + blocking_resources + main_thread_work + paint. Это не универсальная формула пользовательской метрики: этапы могут перекрываться, а браузерные и серверные часы не образуют простую арифметическую сумму. Карта нужна, чтобы задать следующий вопрос. Если до первого байта стало дольше, проверяйте сервер и сеть. Если эта часть стабильна, а LCP вырос, переходите к ресурсам, layout и JavaScript.
Navigation Timing предоставляет временные точки навигации, включая начало записи и получение первого и последнего байта. Resource Timing даёт записи о ресурсах и их инициаторах. В браузере можно начать с performance.getEntriesByType('navigation')[0] и performance.getEntriesByType('resource'), но не считать наличие записи доказательством влияния на LCP. Сопоставьте запись с waterfall, HTML-тегом, initiator и фактически изменившимся элементом.
Этот пример намеренно ограничен loopback HTTP-путём. Он не моделирует браузер, мобильную сеть, CDN, TLS, реальную базу или пользовательский состав. Сервер добавляет 40 миллисекунд перед ответом, клиент последовательно делает двадцать запросов, сохраняет сырые времена и считает медиану и p95. Это способ проверить арифметику до анализа страницы, а не доказательство production-производительности.
import { performance } from 'node:perf_hooks';\nimport { createServer } from 'node:http';\n\nconst server = createServer((_request, response) => {\n setTimeout(() => response.end('ready'), 40);\n});\n\nawait new Promise((resolve) =>\n server.listen({ host: '127.0.0.1', port: 0 }, resolve)\n);\n\nconst { port } = server.address();\nconst samples = [];\n\ntry {\n for (let attempt = 0; attempt < 20; attempt += 1) {\n const started = performance.now();\n const response = await fetch('http://127.0.0.1:' + port);\n await response.text();\n samples.push(performance.now() - started);\n }\n} finally {\n await new Promise((resolve) => server.close(resolve));\n}\n\nfunction nearestRank(values, rank) {\n const sorted = [...values].sort((a, b) => a - b);\n const index = Math.max(0, Math.ceil(sorted.length * rank) - 1);\n return sorted[index];\n}\n\nconsole.log({\n count: samples.length,\n medianMs: nearestRank(samples, 0.5).toFixed(1),\n p95Ms: nearestRank(samples, 0.95).toFixed(1),\n samplesMs: samples.map((value) => value.toFixed(1)),\n});Сохраните код как measure.mjs и запустите в Node.js 18 или новее, где доступен глобальный fetch. Обычно значения будут немного выше 40 миллисекунд: к искусственной задержке добавятся loopback, HTTP и планирование процесса. Точное число зависит от машины. Функция использует nearest-rank только для прозрачного учебного примера; в production заранее договоритесь о методе percentile и применяйте его одинаково к baseline и новой серии.
Добавьте один искусственный выброс и увидите, что p95 меняется, хотя большинство запросов осталось прежним. Это показывает чувствительность хвоста, но не доказывает регрессию страницы. Для браузерного вывода нужны отдельные запуски с Navigation Timing, Resource Timing и наблюдением LCP. Для серверной причины полезно связать request ID с trace и при необходимости передать этапы через Server-Timing.
| Наблюдение | Что оно поддерживает | Что проверить дальше | Ограничение вывода |
|---|---|---|---|
| TTFB и LCP выросли вместе | документ начал приходить позже | server timing, trace, очередь, база, upstream | TTFB не указывает единственную причину |
| TTFB стабилен, LCP вырос | проблема возникла после первого байта | waterfall, render-blocking ресурсы, long tasks, элемент LCP | нужен браузерный запуск, а не только серверный лог |
| bundle меньше, LCP прежний | bundle не был доминирующим участком | сравнить resource entries и CPU-профиль | заявление ограничено выбранным сценарием |
| среднее лучше, p95 хуже | хвост стал тяжелее или появились выбросы | сырые значения, размер выборки, сегменты и нагрузка | среднее не заменяет хвостовую метрику |
| тайминг ресурса неполный | ограничение API или cross-origin | origin, Timing-Allow-Origin, браузер и тип записи | нельзя додумывать скрытые значения |
Высокий TTFB не доказывает вину базы. Он фиксирует задержку до согласованной точки ответа; причиной могут быть соединение, очередь, proxy, серверный код или upstream. Если сервер отдаёт Server-Timing, браузер может получить названия и длительности заявленных этапов, но это сигнал для сопоставления с trace и логами, а не автоматический root cause.
Ранний script тоже не равен проблеме. Он может быть небольшим и нужным для маршрутизации. Большой script может прийти после отрисовки LCP. Смотрите на фактическую блокировку главного потока, initiator и связь с выбранным элементом. Если trace не подтверждает гипотезу, вернитесь к разбиению пути, а не усиливайте оптимизацию по привычке.
Сначала зафиксируйте baseline: commit, URL, действие пользователя, браузер, класс CPU, viewport, сеть, состояние cache, размер данных и время запуска. Разделите cold cache и warm cache. В первом режиме часть ресурсов и соединений отсутствует локально; во втором путь уже прогрет. Смешанная серия отвечает на неясный вопрос и может скрыть регрессию.
Затем выберите один критерий решения. Например: p95 LCP не выше согласованного порога в mobile-профиле при заданном throttling и cold cache. Рядом храните TTFB, время до responseEnd, размер переданных ресурсов и число long tasks. Сам порог — продуктовая договорённость, а не число, которое автоматически следует из W3C API.
Уберите или уменьшите одного кандидата: конкретный preload, импорт или блокирующий script. Не меняйте одновременно SQL, CDN, компрессию и порядок HTML. Повторите ту же серию, сохраните все наблюдения и сравните распределения. Если LCP не изменился, это полезный результат: bundle не был причиной на выбранном сценарии. Если улучшилось только среднее, решение ещё не подтверждено.
Локальный HTTP-тест проверяет арифметику и порядок работы клиента, но не пользовательскую скорость. Лабораторный browser-run помогает сравнить контролируемые условия, но не покрывает все устройства, сети и состав контента. Полевой p95 зависит от числа наблюдений, агрегации, сегментов и того, как инструмент исключает или учитывает невалидные записи. Поэтому один порог нельзя без объяснения переносить между страницами.
LCP — пользовательский сигнал о крупнейшем видимом элементе, а не универсальный показатель завершения приложения. Элемент может измениться, поздняя загрузка изображения может сдвинуть момент, а одно и то же значение не раскрывает серверную причину. TTFB также не заменяет trace. Для честного вывода указывайте, какая метрика была критерием, на какой выборке и в каких условиях она получена.
Расследование закончено, когда другая команда может открыть отчёт, увидеть baseline и повторить сравнение. В отчёте есть исходные данные, метод расчёта, один доминирующий участок, проверенная гипотеза, повтор после одного изменения и отрицательный сценарий. «Стало быстрее» недостаточно; проверяемая формулировка выглядит так: «при заданных URL, браузере, viewport, сети и cache p95 LCP изменился с A до B, а TTFB и размер ресурсов изменились на C и D».
responseStart и responseEnd. Это API, а не готовый порог качества; измерительный контекст зависит от браузера.Timing-Allow-Origin; документ может обновляться.