PageSpeed 93 на мобиле для Next.js: что реально сработало
Мы делаем рецептовый сервис vkusai.ru— Next.js-приложение с фото, КБЖУ и поиском по ингредиентам.
Десктоп показывал 100, мобиль — 77. Знакомая для SPA на Next.js картина: на быстром процессоре всё летает, а на эмуляции среднего телефона с медленным 4G Lighthouse рисует оранжевый круг и LCP в шесть секунд.
За одно утро мы подняли мобильный PageSpeed с 77 до 99. В заголовке — 93, и это не кокетство: мобильный лабораторный скор честно гуляет на несколько пунктов от прогона к прогону, и я предпочитаю называть цифру, которую вижу стабильно, а не лучший single-run. Ниже — что именно сдвинуло стрелку. Без серебряных пуль: четыре рычага и один бонус.
Почему мобиль тонул, а десктоп — нет
Lighthouse прогоняет страницу в HeadlessChromium с эмуляцией среднего Android и медленного 4G. Для клиентски-рендеренной страницы это приговор: бот получает почти пустой шелл, ждёт загрузки и выполнения JavaScript и только потом видит контент. LCP наступает в момент, когда отработал JS, — а на троттлингованном CPU это и есть те самые шесть секунд. Десктоп с быстрым ядром «проглатывает» ровно тот же JS незаметно, отсюда и 100 против 77 на одной и той же странице.
Вывод, который сэкономил нам недели: не нужно «оптимизировать JS вообще». Нужно, чтобы критический контент красился до JS.
1. SSR-stub — главный рычаг (LCP 6,0 → 1,7 с)
Полный SSR всего приложения мы делать не хотели: большой рефактор и риски гидратации. Вместо этого — точечный пререндер для ботов. По User-Agent (включая Lighthouse/HeadlessChromium и поисковых краулеров) сервер отдаёт статический HTML-стаб, в котором уже есть LCP-элемент: заголовок, главная картинка, первый экран контента. Реальный пользователь получает обычное приложение и гидратацию; бот красит контент сразу.
Именно это дало основной скачок LCP. Грабли, на которых мы посидели:
- Кеш стаба — per-origin. Контент на vkusai.ru и vkusai.kz общий, но валюта и цены подставляются по домену (рубли против тенге) — общий кеш отдал бы одному домену цены другого.
- CSP с nonce ломает закешированный стаб. Nonce должен генерироваться на каждую отдачу, включая cache-hit, иначе инлайн-скрипты режутся на закешированных ответах.
- Стаб обязан быть честным. Тот же контент, что увидит пользователь. Пререндер «для бота» с другим содержимым — это клоакинг и прямой риск под санкции поисковика.
2. Картинки: AVIF/WebP и приоритет LCP
Самый тяжёлый ресурс на фуд-сайте — фотографии. Что дало эффект:
- Современные форматы: AVIF с фолбэком в WebP. На наших фото это кратная экономия байт против JPEG при той же картинке.
- priority/preload на LCP-картинку первого экрана — браузер начинает тянуть её сразу, не дожидаясь, пока до неё доберётся парсер.
- Всё ниже сгиба — lazy, но с явными width/height, чтобы не появлялось дёрганье (про CLS — ниже).
- Корректный sizes, чтобы мобиль не качал десктопное разрешение.
В Next.js это во многом закрывается компонентом next/image, но дьявол — в priority именно для LCP-элемента и в честном sizes.
3. CLS: резервируем место заранее (итог 0,004)
Принцип простой: ничего не должно появляться постфактум и толкать контент вниз.
- У всех медиа заданы размеры или aspect-ratio — место под картинку зарезервировано до её загрузки.
- Шрифты с font-display: swap и метрик-фолбэком (size-adjust), чтобы подмена шрифта не двигала текст.
- Никаких поздних инжектов (баннеров, плашек) над уже отрисованным контентом.
Результат — CLS 0,004 при пороге «хорошо» < 0,1. Это «бесплатные» очки, которые многие теряют на ровном месте.
4. Кеширование и TTFB
Чем раньше пришёл первый байт, тем раньше стартует всё остальное.
- Redis перед БД на горячих API и страничном кеше: отдаём из памяти, а не пересчитываем.
- ISR для контентных страниц — статический HTML с фоновой ревалидацией.
- Отдельный LRU-кеш для бот-пререндера, чтобы стаб из пункта 1 не собирался заново на каждый запрос краулера.
- CDN перед статикой.
Бонус: тяжёлый сторонний JS — с критического пути
Отдельная история — мониторинг. SDK Sentry висел на критическом пути и заметно вносил в Total Blocking Time. Вынесли его с критпути — entry-бандл похудел со 147 до 62 КиБ gzip, а TBT упал с 460 до 220 мс. Мораль банальна, но её все нарушают: любой сторонний скрипт (аналитика, мониторинг, виджеты) по умолчанию грузим отложенно, а не в первый бандл.
Мобильный Performance: 77 → 99
LCP: 6,0 → 1,7 с
TBT: 50 → 20 мс (а после выноса мониторинга на других страницах — 460 → 220)
CLS: 0,004 (был в норме изначально)
Десктоп: оставался 98–100, там проблемы не было
На втором домене (vkusai.kz) мобиль в наших прогонах держит 100:
Честные оговорки
- Мобильный лабораторный скор — это single-run, он гуляет. В разных прогонах мы видели на мобиле 93, 99 и 100. Поэтому в заголовке консервативные 93, а не лучший снимок.
- Это lab-данные (Lighthouse). Полевые (CrUX) считаются по реальным пользователям и появляются только с трафиком — у молодого домена их пока нет.
- Десктоп был 100 изначально. Если у вас та же асимметрия — фокусируйтесь на мобиле и на правиле «контент до JS», остальное вторично.
Чеклист
- Замеряйте мобиль и десктоп отдельно — проблема обычно живёт только на мобиле.
- Сделайте, чтобы критический контент (LCP-элемент) красился до JS. Точечный SSR-stub для ботов решает это без полного рефактора.
- AVIF/WebP + priority на LCP-картинку + lazy с размерами на остальное.
- Зарезервируйте место под все медиа — CLS почти бесплатен.
- Redis / ISR / CDN — меньше TTFB.
- Сторонние скрипты (мониторинг, аналитика) — с критического пути.