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

Для измерения скорости сервисов в Яндексе используется метрика Velocity Index — это агрегация метрик Web Vitals (FCP, LCP, TBT, INP, CLS). Итоговое значение получается в диапазоне от 0 до 100 баллов. Хорошим результатом считается индекс больше 85.

Мы поставили себе амбициозную цель: увеличить Velocity Index до 85, а заодно подлечить очевидные «узкие места» в скорости и ускорить всё, до чего сможем дотянуться.

В итоге: 

  • Velocity Index увеличился: 79 → 82.

  • 99-й перцентиль метрик стал в разы быстрее.

  • First Contentful Paint (FCP) страницы тикета улучшился более чем в два раза — с 1,5 до 0,6 с.

  • Отдельные сценарии тоже заметно ускорились.

Но до заветных 85 баллов мы так и не добрались. И вот почему.

Семь раз отмерь…

Главное правило при работе с производительностью — научиться её измерять. Мы для этого используем Velocity Index — индекс скорости, построенный на основе нативных метрик браузеров, на которые ориентируется вся индустрия (aka Web Vitals).

Из всех метрик, на которых базируется Velocity Index, нам особенно интересны три:

  • FCP (First Contentful Paint) — время показа первого элемента на странице с контентом (текста или картинки).

  • LCP (Largest Contentful Paint) — насколько быстро появляется самый крупный элемент на странице.

  • TBT (Total Blocking Time) — суммарное время, когда основной поток был заблокирован парсингом или выполнением JS.

На начало квартала Velocity Index страницы тикета был примерно 79. FCP изначально был довольно высоким — из‑за архитектуры SPA. Весь трекер — одно большое react‑приложение. TBT выглядел нормально, но так как он напрямую не затрагивает пользовательский опыт, мы не фокусировались на нём. А вот LCP часто оставлял желать лучшего из‑за большого количества контента на страницах. Настало время исправить эту ситуацию и сделать загрузку страницы мгновенной.

План: ускоряем вертикально и горизонтально

Наш план был прост по форме, но амбициозен по сути. Мы хотели работать сразу в двух направлениях: вертикальном и горизонтальном.

Вертикальные улучшения. Это работа с конкретными страницами — тикетом, созданием тикета, дашбордами и другими сценариями. Здесь мы:

  • убрали задвоение одних и тех же ручек;

  • оптимизировали порядок загрузки статики;

  • внесли ряд других локальных правок.

Горизонтальные улучшения. Это изменения в общем коде всего сервиса и в общих библиотеках. Ключевые задачи здесь:

  • внедрили HTML‑стриминг в общую библиотеку @gravity‑ui/app‑layout;

  • нашли и устранили причину фоновой нагрузки на процесс при неактивной вкладке (виновницей оказалась общая библиотека);

  • провели ещё несколько системных оптимизаций.

Отдельное направление — ускорение бэкенда API, ведь это один из самых эффективных способов повысить скорость загрузки страницы. Для этого мы: 

  • сделали новую ручку для страницы создания тикета, которая работает в 20 раз быстрее старой;

  • ускорили получение комментариев в тикетах;

  • ускорили загрузку аттачей.

Как мы ускоряли Трекер

Страница тикета

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

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

Причина тормозов оказалась в том, что контент на бэкенде хранится в виде разметки — вики‑синтаксиса или YFM (Yandex Flavored Markdown). Перед отображением страница должна пройти два шага: сконвертировать вики‑разметку в YFM и отрендерить YFM в HTML.

Проблема в том, что парсер разметки работает только на сервере (Node.js) и он довольно медленный. Это не только увеличивает время загрузки тикета и комментариев, но и создаёт потенциальную точку отказа в системе.

Чтобы ускорить процесс, мы начали хранить вместе с разметкой уже отрендеренный HTML‑контент. При запросе проверяется валидность кеша и, если всё актуально, стадия рендеринга полностью пропускается, а в браузер отправляется готовая разметка.

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

Для «тяжёлых» тикетов время загрузки сократилось в разы. Кроме того, теперь можно без проблем открывать очень большие тикеты, которые раньше просто падали по тайм‑ауту.

Комментарии и связи

Следующим «узким местом» оказалась загрузка связей и комментариев. Начали с фронтендных проблем — именно они сильнее всего влияли на восприятие скорости.

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

Решение оказалось простым: разделить загрузку на отдельные запросы.Теперь каждый тип данных приходит независимо: пользователь сразу увидит описание тикета, а связи подгрузятся позже.

Если комментарии приходят первыми, а связи позже, возникает layout shift — сдвиг контента при подгрузке. Он не только портит метрики скорости, но и раздражает пользователей: текст прыгает, курсор сбивается, интерфейс ощущается «дрожащим».

Чтобы этого избежать, мы решили не отображать комментарии, пока не загрузятся связи. Таким образом, страница рендерится стабильно, без скачков и визуального шума.

После разделения запросов удалось сделать ещё несколько точечных улучшений:

  • мы перестали загружать связи при изменении порядка сортировки комментариев;

  • не подгружаем комментарии, если открыт таб истории, вложений или коммитов.

Эти изменения дали ощутимый прирост отзывчивости и сократили лишнюю нагрузку на фронт и бэк.

Редактирование полей

Редактирование полей — важная часть страницы тикета. Раньше при изменении любого поля мы честно показывали лоадер и ждали, пока данные обновятся на бэкенде. Это выглядело правильно, но сильно мешало быстро отредактировать несколько полей подряд.

На помощь пришёл подход Optimistic UI. В большинстве случаев запрос на изменение поля тикета завершается успешно, поэтому мы можем моментально применять правки в интерфейсе, не дожидаясь ответа сервера. Если же запрос неожиданно вернётся с ошибкой — просто откатываем изменения. 

Такое поведение делает интерфейс заметно отзывчивее и существенно ускоряет редактирование нескольких полей подряд.

Сделали уборку

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

Что мы улучшили:

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

  • Оптимизировали порядок загрузки статики — убрали лестницу async‑чанков, из‑за которой ресурсы подгружались последовательно, а не параллельно. 

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

Страница создания тикета

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

Решение — новая ручка на бэкенде, которая возвращает только необходимый минимум информации для отрисовки страницы. Дальше мы оптимизировали загрузку данных с клиента, а критичные данные вынесли на Node.js.

В результате первая отрисовка стала быстрее на 350 мс, а полная загрузка страницы ускорилась на 750 мс. 

Ускорение API

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

  • научились быстрее отдавать историю тикета (p95 900 мс → 500 мс);

  • ускорили сохранение настроек пользователя (p95 900 мс → 600 мс);

  • ускорили получение списка досок (p95 600 мс → 480 мс);

  • ускорили получение списка задач проекта (p95 1,3 с → 1 с);

  • ускорили получение истории действий пользователя (p95 3,5 с → 1 с).

И…. почти никакого сдвига Velocity Index

Несмотря на все вертикальные и горизонтальные улучшения, существенного прироста производительности не было. Конечно, ускорение наблюдалось, но не настолько, насколько мы ожидали.

Интересно, что только одно горизонтальное изменение дало заметный буст Velocity Index, остальные правки в сумме давали лишь небольшие приросты. Стали разбираться, почему так произошло.

Prefetch

Приложению нужны данные, чтобы отрисовать все элементы интерфейса. Для этого браузер отправляет запрос в Node.js‑часть, а она уже идёт в бэкенд за данными. Всё это повторяется для каждого эндпоинта с необходимыми данными. И всё это отодвигает момент LCP — когда данных будет достаточно, чтобы отрисовать самую большую часть интерфейса. Кроме того, пользователь в этот момент будет видеть или пустоту или лоадеры, так как часть страницы не может отрисоваться.

Решение этой проблемы — prefetch. Идея простая: заранее загрузить необходимые данные. Как только первый запрос пользователя приходит, мы уже по URL можем понять, какую страницу он открывает и какие данные для неё нужны. Можем запросить сразу всё и положить данные в тело документа. Тогда при рендере можно будет взять готовые данные без похода по сети.

Мы уже имели prefetch на нескольких страницах. В процессе ускорения мы добавили его на большее количество страниц и стали загружать больше данных. Так для страницы задачи мы загружаем данные из восьми эндпоинтов. Казалось бы, лёгкая победа…

…, но не всё так просто.

Проблема 1: в каждом месте для получения данных нужно знать, что, возможно, данные уже есть и их нужно просто «взять» вместо похода по сети. Но сделать можно только один раз, при загрузке.

У нас уже было решение для получения данных — gravity‑ui/sdk. Кеширование было реализовано обёрткой, так что мы могли со стороны Node.js просто докидывать необходимые данные, а фронтенд использовал бы их без написания дополнительной логики в каждой фиче.

Проблема 2: данные могут грузиться долго. Сущности бывают разного размера. Например, контент тикета с несколькими мегабайтами описания и сотней человек в наблюдателях может грузиться довольно долго. Поэтому при загрузке страницы обязательно нужны аккуратно подобранные тайм‑ауты, по истечении которых мы отдаём всё, что успело загрузиться, и не блокируем загрузку страницы.

Проблема 3: любая загрузка занимает время. Вместо того чтобы отдать код страницы браузеру и разрешить загружать и парсить JS, мы ждём загрузку данных, чтобы весь контент был готов сразу. Другими словами, мы жертвуем FCP в пользу LCP.

Но есть способ обойтись без жертв.

HTML‑стриминг

Любой сайт в первую очередь запрашивает HTML‑страницу. Формирование этой страницы на сервере может занимать значительное время. При этом одну часть страницы можно сгенерировать быстро, а другая может потребовать длительной обработки.

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

Такой подход снижает время до первой отрисовки и делает интерфейс более отзывчивым для пользователя.

Внедрение стриминга помогло улучшить FCP более чем в два раза (с 1,5 до 0,6 с).

Внедрение стриминга разбили на три этапа.

Этап первый: MVP на странице тикета. Нужно сделать прототип для одной страницы под флагом. Здесь нужна полностью рабочая реализация, но с возможностью включения и выключения для конкретного пользователя. В результате нужно убедиться, что прототип работает.

Второй этап: production‑ready‑реализация. Нужно вынести все фиксы и доработки в библиотеки gravity‑ui, prefetch‑данные перенести в конец документа, чтобы, пока они загружаются, максимальная часть документа могла быть отрисована.

Третий этап: показать пользователю что‑то более полезное, чем белый экран. Например, каркас страницы и спиннер. Время отрисовки этого контента и станет метрикой FCP.

Итоговая схема загрузки страницы

Это сократило количество «путешествий» между браузером, Node.js и бэкендом и позволило нам максимально плотно сжать запросы, загрузку и парсинг JS вместе. Пока пользователь в ожидании загрузки полного HTML‑документа с данными, мы уже скачали весь необходимый JS и даже выполнили часть из него:

Но не всё так радужно. На третьем этапе, когда мы увидели просто космическое улучшение FCP, мы также заметили, что Velocity Index не вырос. А произошло это потому, что одновременно с улучшением FCP мы ухудшили TBT.

Как «хакнуть» TBT

Ну не может такого быть, что объективные улучшения работы сервиса не отражались бы на Velocity Index, — а у нас он хоть немного, но ухудшился.

Немного углубившись в метрику TBT (Total Blocking Time), мы подтвердили важный факт: TBT у нас и так был большим, но из‑за высокого FCP браузер просто не засчитывал часть блокировок.

Метрика общего времени блокировки (TBT) измеряет общее количество времени после первой отрисовки содержимого (FCP), когда основной поток был заблокирован достаточно долго, чтобы помешать реагированию на ввод.

Причём TBT имеет больший вес в расчёте Velocity Index. Это означает, что улучшая FCP, но не уменьшая TBT, мы на самом деле можем ухудшить Velocity Index. Иными словами, если сейчас сделать FCP высоким, не меняя TBT, наш Velocity Index может даже вырасти.

Новый год и мифический LCP

К новогодним праздникам мы были очень близки к цели — Velocity Index достиг 84.

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

Мы ещё раз внимательно изучили, как считается LCP (Largest Contentful Paint). При загрузке страницы формируется список кандидатов на LCP, и из них выбирается самый большой элемент с картинкой или текстом, но есть одно исключение:

Элемент с фоновым изображением, загруженным с помощью функции url() (в отличие от градиента CSS ).

Именно таким образом была реализована новогодняя тема в навигации. 

Если посмотреть в DevTools, какой элемент считается LCP, мы увидим, что это была новогодняя навигация. Явно не этот элемент мы хотели измерять, но именно он влиял на метрику.

Соответственно, убрав новогоднюю тему навигации, мы тут же откатились обратно.

Футер/хедер

Ещё одной неприятностью оказалось то, что в LCP иногда попадали элементы навигации — футер и хедер.

Например, после того как мы заменили свой трекерный футер на общий, Velocity Index для страницы дашборда упал с 85 до 82. То есть даже корректные изменения интерфейса могли непредсказуемо влиять на метрику, если она считывала «не те» элементы как LCP.

И это всё не из‑за того, что футер как компонент стал хуже. Просто поменялась вёрстка с этой… 

…на эту.

В итоге стало ясно, что обычное изменение текстов или вёрстки может привести к тому, что Velocity Index взлетит или упадёт, без реальных изменений производительности. Или наоборот — вы видите высокий Velocity Index, но ваши улучшения просто не видны метрике.

Почему это особенно сильно сказалось на странице с дашбордами? Потому что именно на этой странице видно футер. На остальных страницах контента гораздо больше, поэтому Footer не попадает на initial‑экран и автоматически исключается из кандидатов LCP.

Пользовательский контент

Ещё одна маленькая, но неприятная проблема проявилась в процессе оптимизации страницы тикета. Оказалось, что загрузка пользовательского контента может приводить к рандомным падениям производительности

Например, если множество пользователей заходят в тикет, в котором есть неоптимизированная пользовательская картинка, это может ухудшить LCP более чем в 2–3 раза. То есть даже хорошо оптимизированная система может неожиданно тормозить из‑за контента, который создают сами пользователи.

Можно это исправить?

Рассмотрим пример из документации по LCP

В этом примере браузер предоставляет несколько вариантов элементов для LCP: сначала это ссылка «Visual Stories», потом заголовок «Who has …» и только затем картинка. В данном случае всё понятно и логично, но не всегда так бывает.

Браузер поддержи��ает из коробки возможность посмотреть, какие элементы были кандидатами на LCP:

new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    console.log('LCP candidate:', entry.startTime, entry);
  }
}).observe({type: 'largest-contentful-paint', buffered: true});

В какой‑то момент API выбирает элемент, который считается LCP. К сожалению, это далеко не всегда тот элемент, который мы хотим измерить. Метрика LCP сама по себе корректна, но важно понимать, как она измеряется.

Результаты и выводы

Мы смогли значительно ускорить время до первой отрисовки всех страниц. Популярные пользовательские сценарии — загрузка тикета и комментариев, редактирование полей, создание тикета — также стали работать быстрее. Но главное, мы научились работать со скоростью и правильно интерпретировать метрики.

Вот что мы узнали для себя: 

  • Пользовательский контент может приводить к разбросу метрики до 300%, из‑за чего будет сложно видеть небольшие улучшения.

  • Не всегда метрики отображают реальную картину. Причем отклонение может быть в любую сторону. Так у нас фактический Velocity Index он был ниже, и многие оптимизации просто не отображались на приборах, создавая впечатление, что улучшений нет.

  • Как следствие метрики могут быть «хакнуты», как специально так и неосознанно.

  • Тем не менее метрики важны и полезны. Для работы над скоростью полезно иметь как можно больше метрик, желательно собираемых с реальных пользователей.

  • Получить низкий LCP (< 1000 мс) в таком крупном проекте, как Трекер — нетривиальная задача. Нельзя обойтись устранением явных косяков и выкидыванием лишнего — требуется гораздо больше работы на фронтенде и бэкенде.

  • На метрики сильнее всего влияют страницы с наибольшим трафиком. Так в трекере единственная критичная страница с точки зрения метрик — тикет. Она влияет на Velocity Index в два раза больше всех остальных страниц вместе взятых, но мы уделяли ей далеко не всё внимание.

И если вы тоже хотите улучшить свой проект с точки зрения скорости, то помните несколько простых вещей:

  • Даже хорошая и общепризнанная метрика показывает лишь часть картины.

  • Выбирать несколько метрик и слепо идти за ними — плохой вариант. Нужно понимать, как работает каждая метрика. Рост не всегда означает реальное улучшение, а отсутствие изменений метрики — не всегда отсутствие ускорения.

  • Важно улучшать опыт пользователя, а не только цифры. Для этого нужно понимать, что чаще всего делают пользователи в сервисе и как ускорения влияют на эти сценарии.

Stay tuned!