Страница, которая скрывала собственный контент
При быстром соединении сайт открывался на несколько секунд серым пустым экраном, а затем изображения медленно подгружались одно за другим. Исправления оказались небольшими. Самой важной частью работы было доказать, почему существовала каждая задержка — потому что, как выяснилось, почти каждая была чьим-то предыдущим исправлением.
Жалоба была простой: при первом посещении отображалась серая страница. Заголовок и логотип появлялись, а всё остальное на две, три, иногда четыре секунды превращалось в сплошное серое поле — даже в офисной сети, которая по результатам speed-теста работала отлично. Затем появлялся главный баннер, а следом изображения — настолько медленно, что можно было буквально наблюдать за их загрузкой.
Перед тем как что-либо менять, я установил для себя правило: никаких исправлений без измерения, которое указывает на конкретную причину. Это единственная защита от классической ошибки при оптимизации производительности: изменить пять вещей, увидеть, что сайт стал быстрее, и при этом не знать, какое именно изменение помогло и что оно могло сломать.
Сначала измерения
Тестовый стенд представлял собой автоматизированный запуск браузера: холодный кэш, первое посещение, ограниченные сеть и CPU, соответствующие реальности недорогого телефона. Наблюдатели фиксировали момент появления серого состояния, момент его исчезновения и момент отрисовки крупнейшего элемента контента. Каждый потенциальный фикс проверялся одним и тем же скриптом — по три запуска до и после изменения.
Первый запуск чётко разделил проблему на две части. Серый экран занимал первую часть временной шкалы. Изображения — вторую. Причины были разными, исправления тоже, и ни одна из причин не находилась там, где я ожидал.
Шторка
Сервер действительно рендерил всю страницу — главный баннер, продукты, текст, всё. Но первым делом клиент скрывал уже готовый контент. Флаг загрузки отправлялся в состоянии, отрендеренном на сервере, со значением true, поэтому приложение просыпалось с ощущением, что находится в процессе навигации, и рисовало серую шторку поверх контента, который уже был на экране. Шторка поднималась только после завершения гидрации. На телефоне с ограниченными ресурсами это означало несколько секунд скрытия уже готовой работы.
Очевидный фикс — одно слово: заменить true на false. Но у этого слова была история. Кто-то из команды намеренно установил его, и никто уже не помнил почему.
Git помнил. Оказалось, что кто-то уже пробовал false, а через несколько дней откатил изменение с сообщением о «случае с перезагрузкой». Навигационный guard, который поднимал шторку, срабатывал при каждом изменении маршрута, включая первоначальную загрузку и перезагрузку страницы. Поэтому при выключенном флаге любая перезагрузка всё равно показывала бы серый экран — просто через другую дверь. Откат был не ошибкой: он исправлял баг, который моё однострочное изменение вернуло бы обратно.
Поэтому настоящий фикс — это слово плюс guard: шторка поднимается только при переходе с одной уже открытой страницы на другую. При первой загрузке и перезагрузке её нет; при навигации внутри приложения loader продолжает работать. Повторное измерение показало ноль переключений шторки при загрузке и перезагрузке, при этом loader остался там, где ему и положено быть.
Главный баннер, который рендерился пустым
Второе открытие: сервер вообще не рендерил главный баннер. Компонент запускал запрос данных, но не дожидался его завершения, поэтому сервер отправлял пустую секцию, а настоящий баннер появлялся только после того, как клиент повторно выполнял запрос. Ожидание fetch во время серверного рендера — и обучение клиента не делать повторный запрос к данным, которые он уже получил в payload, — перенесли главный баннер в самый первый paint. Такой же механизм пропуска повторного запроса был добавлен в загрузчик переводов: он незаметно повторно скачивал словари, которые сервер уже встроил в страницу.
Изображения кодировались при каждом запросе
Вторая половина временной шкалы оказалась ещё страннее: каждый запрос изображения каждый раз оплачивал полную стоимость конвертации — для каждого посетителя. Пайплайн изображений на лету конвертирует оригиналы в AVIF — это хорошо. Но результат нигде не кэшировался. CDN был настроен просто пропускать запросы изображений дальше, а origin заново кодировал один и тот же файл для каждого человека, который его запрашивал. Две с половиной секунды CPU на одно изображение — снова и снова, без конца.
Фреймворк предлагал однострочный кэш маршрутов, но он портил каждое изображение, которого касался. Он сериализует тела ответов в JSON, что незаметно повреждает бинарные данные: AVIF размером 150 КБ возвращался как JSON размером около полумегабайта. Поэтому кэш превратился в небольшой самописный middleware: хэшировать URL, сохранять байты и заголовки на диске, а при следующем запросе отдавать их повторно. Байтово идентичный результат был проверен по checksum, а повторные запросы сократились с 2,4 секунды до менее чем миллисекунды.
Учим кэш говорить «нет»
Кэш, ключом которого служит URL, фактически приглашает интернет заполнить ваш диск. Кто угодно может запросить одно и то же изображение в пяти тысячах произвольных размеров, и каждый запрос будет стоить вам и кодирования, и новой записи в кэше.
Поэтому middleware сначала валидирует запрос и только потом начинает работу: разрешены только те шаблоны модификаторов, которые реально использует сайт; размеры ограничены retina-разрешением; источники ограничены настоящими папками с изображениями; есть жёсткий лимит на количество записей в кэше. После достижения лимита запросы продолжают обслуживаться, но новые данные больше не сохраняются. Всё, что не соответствует этой схеме, получает 404 ещё до запуска кода обработки изображений. Легитимный сайт проходит; скрипт, который пытается прощупывать систему, ничего не получает — и делает это дёшево для сервера.
Кэш, который был отключён не просто так
У backend была своя шторка. Каждый запрос переводов интерфейса выполнял полный запрос словаря — две секунды на холодном кэше — потому что кэш вокруг него был закомментирован. И снова он был отключён намеренно. И снова история объяснила почему. Production работает на нескольких серверах, и у каждого свой файловый кэш. Старое время жизни в пять часов означало, что изменение редактора появлялось на одном сервере сразу, а на другом — только днём. Переводы начинали выглядеть сломанными случайным образом. Отключить кэш было вполне рациональной реакцией.
Исправление заключалось не в том, чтобы спорить с этой причиной, а в том, чтобы уменьшить её масштаб: десять минут вместо пяти часов. Изменение редактора появляется везде в течение нескольких минут — ниже порога, при котором большинство пользователей успевает это заметить, — а серверы всё ещё отвечают из кэша примерно на 99% запросов. Измерение от холодного до прогретого состояния: две секунды превратились в несколько сотых секунды. Кэш общий для всех посетителей, а цена редкого cache miss — всего десятая доля миллисекунды.
Что показали цифры
Один и тот же скрипт, те же ограничения, по три запуска с каждой стороны. Без throttling время до отрисовки крупнейшего контента сократилось с 1,7 секунды до менее чем половины секунды, причём главный баннер присутствовал уже в первом кадре. На профиле ограниченного телефона — том самом, который соответствовал исходной жалобе, — серое состояние сократилось с 14,5 секунды до двух, а время до отрисовки крупнейшего контента — с 20 секунд до семи. Повторные загрузки изображений сократились с секунд до примерно одной миллисекунды, при этом содержимое осталось байтово идентичным.
Почему я доволен результатом
Каждая медленная часть этой временной шкалы оказалась чьим-то предыдущим исправлением: шторка защищала от мигания при перезагрузке, а отключённый кэш защищал от исчезающих переводов. Удалить что-либо из этого, не разобравшись в истории, означало бы вернуть ровно тот баг, от которого это изменение когда-то защищало — скорее всего, через несколько недель, уже в production, когда связь с моим изменением было бы практически невозможно найти.
Поэтому часть этой работы, которую я защищал бы сильнее всего, — не строка кода. Это привычка: измерять до тех пор, пока конкретная причина не станет виновником, затем читать историю до тех пор, пока эта причина не объяснит сама себя, и только после этого менять код — а затем повторять то же измерение, чтобы улучшение стало фактом, а не впечатлением. Финальный diff — всего несколько небольших изменений. Страница просто перестала скрывать то, что уже успела сделать.