Как мы ускорили мобильный Safari: два бага, один симптом
Жалоба была длиной в четыре слова: грузится вечно, иногда не грузится. Оказалось, что это два никак не связанных бага под одной претензией — один в том, как страницы доставлялись, другой в том, что они делали с телефоном, когда доезжали. Вот вся кампания целиком, включая решение, которое мы отменили через двадцать три дня после того, как его приняли.
На этой странице
Любой отчёт о производительности от живого человека приходит сжатым. Наш гласил, что на iPhone сайт грузится вечно, а иногда не грузится вовсе. В этой фразе нет диагноза, и самый быстрый способ потерять неделю — принять её за один баг: начать удалять анимации, потому что страница ощущается тяжёлой, или начать добавлять заголовки кеширования, потому что страница ощущается медленной. Работа над производительностью в мобильном Safari сходится только тогда, когда симптом разделён надвое: сколько телефон ждёт байтов и что телефон делает с ними, когда получил.
Мы измерили обе половины, нашли в каждой свою первопричину и починили их двумя коммитами в один день. Половина доставки оказалась структурным багом в нашем дереве маршрутов Next.js, из-за которого каждая локализованная страница рендерилась динамически. Половина рантайма была накоплением: около девяноста бесконечных анимаций, WebGL-сцена, заводившаяся на железе, у которого об этом просить не следовало, и анимационное ядро framer-motion, ехавшее в начальном бандле домашнего маршрута.
- 250–950 ms → 3–8 ms
- TTFB главной
- 1.12 MB → 468 KB
- HTML главной
- 7 → 345
- пререндеренных маршрутов
- −91 KB
- начальный JS главной
Результат на двух фронтах, измерено 26.07.2026 по коммитам доставки и рантайма.
Все маршруты локалей были динамическими
Число доставки было безобразным ещё до того, как в картину вошла сеть. TTFB главной держался на 250–950 мс на localhost, при десяти-двадцати некешированных запросах в Supabase на просмотр. На ноутбуке, по петле, с прогретой базой. Что бы там ни переживал телефон, это было то же самое плюс всё, что добавляет сотовый круг.
Причина была не в запросах. Причина была в том, что ни одной странице не позволялось быть статической. next-intl никогда не включали в статический рендеринг, и — вот что действительно важно — наш корневой layout доставал активную локаль из состояния запроса вызовом getLocale(). Чтение времени запроса в layout заражает все маршруты под ним. Next.js совершенно правильно заключил, что ничто под этим layout пререндерить нельзя, и каждый маршрут локали рендерился на каждый запрос, отдавался с no-store и никогда не становился кешируемым на CDN. А веер запросов в CMS выполнялся при каждом просмотре, потому что кешировать его было некуда.
Починкой оказался не заголовок кеша. Починкой оказалось удаление app/layout.tsx.
Более новый API Next next/root-params существует ровно для задач такой формы и здесь неприменим: [locale] не является корневым параметром, пока над ним стоит layout. Единственный способ сделать сегмент локали вершиной дерева — не иметь над ним ничего, а это значит полностью отказаться от единственного общего корневого layout. Так мы и сделали. В репозитории больше нет app/layout.tsx. На его месте четыре корневых layout, по одному на семейство маршрутов, и каждый рендерит общий <RootShell>, которому принадлежат разметка <html> и <body> и всё глобальное: [locale] плюс (admin), (admin-auth) и pitch, причём последние три жёстко прописывают locale="en", потому что админ-консоли таблица переводов ни к чему. global-error.tsx сохраняет собственный <html>, как и обязан.
Вторая половина этого перехода постраничная: setRequestLocale нужно вызывать в каждом серверном компоненте, который рендерит локализованную страницу, и сейчас мы вызываем его в 34 файлах. Это по-настоящему плохой инвариант: он невидим, он молчит при нарушении, и страница, которая о нём забыла, не ломается — она просто тихо снова уходит в динамику и уносит с собой свой TTFB. Мы записали правило в комментарии тех файлов, откуда новую страницу вероятнее всего скопируют, и записываем его здесь тоже: новые страницы обязаны повторять setRequestLocale.
Один маршрут остался динамическим намеренно. Интерактивная дорожная карта читает useSearchParams без границы Suspense, что прибивает её к динамическому рендерингу; поверхность всё равно noindex, а продавливать вопрос значило бы перестраивать страницу, на которую никто не жаловался. Прибить её явно оказалось лучше, чем оставить случайной.
Кешируем базу, а не страницу
Пререндер чинит путь запроса, но наш контент редактируемый: тексты секций, метаданные страниц и содержимое строк приходят из Supabase через небольшой слой CMS. Матрица сборки на шесть локалей, которая ходит в базу по разу на запрос на страницу, обменяла бы медленный запрос на медленную сборку. Поэтому три пути чтения ушли под unstable_cache: getSectionRow под ["cms-section"], getRowsForLocale под ["cms-rows"], getPageMetaRow под ["cms-page-meta"], и все с { revalidate: 300, tags: ["cms-content"] }. Теперь и рендер страницы, и вся шестиязычная сборка ходят в Supabase по разу на уникальный запрос за пять минут, а не по разу на обращение.
Одна деталь в этой фразе несущая и легко теряется при рефакторинге: CMS использует публичный клиент Supabase без cookies. Вызов cookies() внутри unstable_cache в Next 15+ бросает исключение, поэтому клиент, читающий сессию, не кешируется вовсе. Мы это знаем, потому что та же ошибка уже однажды молча убила наши переопределения переводов из базы — несколькими месяцами раньше и за голым catch. Редакторы, к слову, пять минут не ждут: действия записи в админке связывают revalidatePath с revalidateTag("cms-content"), а у переопределений i18n свой тег "i18n-overrides".
Числа доставки сдвинулись разом. Сборка ушла с 7 пререндеренных маршрутов на 345, всего 497 статических страниц. Главная отдаётся с x-nextjs-cache: HIT и TTFB в диапазоне 3–8 мс — цифры CDN, потому что теперь это артефакт CDN. Сам HTML упал с 1,12 МБ до 468 КБ, хотя и не только благодаря пререндеру, что подводит нас к решению, которое мы отменили.
Тот же рычаг, двадцать три дня спустя
3 июля мы включили в Next inlineCss. Рассуждение было здравым, а доказательства настоящими: наша таблица стилей весила 38,6 КБ, была последним блокирующим рендер запросом на странице и стоила примерно 530 мс мобильного FCP. Инлайн убрал круг из критического пути. Тем же изменением мы подтянули browserslist — Chrome, Edge и Firefox 111+, Safari и iOS 16.4+, — потому что маленькой таблицу стилей делает именно современная база.
26 июля мы его выключили. Изменились две вещи. Таблица стилей выросла примерно до 320 КБ сырыми (около 40 КБ в gzip); а встроенный CSS едет внутри HTML каждой навигации, и браузер никак не может закешировать его между страницами. Пока каждая страница была динамическим рендером, обмен был честным: HTML всё равно не кешировался, так что CSS ничего не терял, присоединяясь к нему. Как только маршруты стали пререндеренными и попали в кеш CDN, арифметика перевернулась: одна кешируемая таблица стилей, скачанная однажды, лучше 320 КБ, доставляемых заново внутри каждого документа.
Тот же рычаг, противоположный вывод, двадцать три дня спустя — и оба решения были верны в своём контексте.
Мы держим эту пару в голове всякий раз, когда нам цитируют очередное правило производительности. inlineCss не хорош и не плох. Это ставка на отношение между размером вашей таблицы стилей и кешируемостью вашего HTML, а это отношение мы изменили сами.
Девяносто анимаций, на которые никто не смотрел
Половина рантайма была не одной ошибкой, а тремя месяцами мелких. Бесконечные CSS-анимации, слои смешивания во всю страницу, WebGL-сцена и жадное ядро framer-motion в начальном бандле — каждое защитимо в момент появления и все вместе плавят телефон. Самое наглядное измерение: к тому моменту, как вы доскроллили до финального CTA на главной, одновременно живы около девяноста бесконечных анимаций. Композитору всё равно, что восемьдесят из них далеко за экраном.
Заслонка намеренно тупая. SectionViewportGate — клиентский компонент, который наблюдает каждую #main-content > section с полем корня в 160 px и переключает атрибут data-offscreen; остальное делает CSS через animation-play-state. Атрибуты переключаются вне React намеренно: прогонять девяносто колбэков наблюдателя через обновления состояния стоило бы дороже, чем анимации, которые мы пытались остановить.
Рядом мы потянулись за content-visibility: auto, который разрешает браузеру пропускать раскладку и отрисовку секций, до которых он ещё не доскроллил. Это изменение выехало с багом, который стоит пересказать.
/* nth-of-type, not nth-child. Three JSON-LD <script> children
precede the sections inside <main>. */
section:nth-of-type(n + 3) {
content-visibility: auto;
contain-intrinsic-size: auto 800px;
}Один псевдокласс — и целый класс очень запутанного CLS. Герой не должен попадать под это правило никогда.
Записанный как nth-child(n+3), селектор считает всех детей <main> — а первыми идут три тега JSON-LD <script>. Смещение уехало на три, и правило легло на каждую секцию, включая героя. content-visibility подразумевает contain: size, поэтому герой высотой 100svh сначала раскладывался по высоте заглушки в 800 px и лишь мгновением позже исправлялся: перекомпоновка на загрузке и сдвиг макета на первом же экране, порождённые изменением, вся цель которого была сделать первый экран дешевле. nth-of-type считает только элементы <section>. Одно слово — и герой снова вне области действия.
Каждый iPhone — устройство низшего класса
Наши WebGL-поверхности стоят за детектором класса качества. detectTier читает четыре сигнала: pointer: coarse, hardwareConcurrency ?? 4, deviceMemory ?? 4 и меньшую сторону экрана. Устройство считается телефоном, если у него грубый указатель и меньшая сторона меньше 700 px, а телефон повышается до среднего класса только при как минимум шести ядрах и как минимум шести гигабайтах заявленной памяти.
| Класс | Как устройство в него попадает | Потолок DPR |
|---|---|---|
| low | Любой телефон, не прошедший повышение по 6 ядрам / 6 ГБ, то есть любой iPhone | 1,25 |
| medium | Десктопы по умолчанию, повышенные телефоны и любой серверный рендер | 1,5 |
| high | Точный указатель с ядрами и памятью, которые это подкрепляют | 2 |
Таблица классов — и причина, по которой при SSR по умолчанию выбран средний: сервер знать не может, а средний это честная догадка.
Safari не сообщает deviceMemory вовсе. Значение по умолчанию ?? 4 поэтому безусловно проваливает порог ≥6, и каждый iPhone попадает в низший класс. Мы потратили час, считая это багом детекции, прежде чем заключили, что это правильный ответ, полученный случайно: у iOS Safari самый низкий лимит контекстов WebGL из всех поддерживаемых нами браузеров и самая большая готовность убить вкладку при нехватке памяти. Поэтому сцена теперь выходит из компонента до монтирования, если tier === "low", а значит, её чанк даже не скачивается. Самый дешёвый контекст WebGL — тот, который вы отказались создавать.
Остальная диета рантайма — список маленьких ампутаций, и каждая убирает работу, от которой сенсорные устройства всё равно не могли выиграть:
- Кодовое поле героя на касании рендерится статичным, вовсе без покадрового цикла. Курсора, за которым можно гнаться, нет, а бродячая линза стоила перерисовки канваса во весь экран на 60 fps — на первом же экране телефона. Её путь изменения размера к тому же пропускает пересборку с примерно 2600 вызовами
fillText, потому что на iOS схлопывание адресной строки считается изменением размера. - Оверлей кодовой линзы ограничен
pointer: fine. Это фиксированный канвас сmix-blend-mode— он облагал налогом каждый кадр прокрутки на телефонах ради эффекта, который касанием не вызвать. - Орбы в блоке избранных работ обменяли
filter: blur(28px)на запечённые радиальные градиенты в три остановки. Они анимируют масштаб, поэтому гауссиан пересчитывался каждый кадр; градиент, выглядящий так же, масштабируется бесплатно.will-changeтеперь ограничен указателями с наведением, а касание расплющиваетpreserve-3d. - Видео перекодированы в 960×540, что увело их с 11,2 МБ до 5,1 МБ. Их переименовали в
*-2.mp4, чтобы соблюсти наш контракт имён для неизменяемого кеша: ассеты отдаются сmax-age=31536000, immutable, поэтому изменённый файл требует изменённого имени, — а взамен повторные визиты пропускают около 100 МБ.
LazyMotion со второй попытки
Самый долгоиграющий пункт кампании начался с отчёта по бандлу, который винил 63 КБ неиспользуемого JavaScript. Мы пошли смотреть и обнаружили, что этот чанк — сам react-dom, а его не подрезают. Настоящим жиром оказался framer-motion: импорт motion где угодно жадно тянет анимационное ядро, а наша главная импортировала его в большинстве своих секций.
Попытка 3 июля обернула каждую секцию в собственный LazyMotion с включённым strict, что увело передачу главной с 284 КБ до 269 КБ. Именно строгий режим делает экономию настоящей: он запрещает обычные компоненты motion.*, поэтому один залётный импорт не может тихо втянуть ядро обратно. Зубы у него тоже есть: нам пришлось убить императивный animate() из framer внутри нашего хука считающихся чисел и переписать его маленьким easeOutQuart на requestAnimationFrame, потому что одного этого импорта хватало, чтобы вернуть анимационное ядро в критичный для LCP граф. А переменную цикла с именем m в компоненте орбиты gravitas пришлось переименовать, потому что она перекрывала пространство имён m из framer.
А потом всё это откатили. Откат чистый, полный, с автосгенерированным телом коммита и без указанной причины — а значит, самое честное, что мы можем сказать, это что мы не знаем, что тогда сломалось. Мнения у нас есть; доказательств нет. Этот пробел сам по себе урок про гигиену коммитов, и именно поэтому повторный заход выглядел иначе.
// One shared provider. The feature bundle is loaded asynchronously,
// so it streams in after first paint instead of blocking it.
const loadDomAnimation = () =>
import("framer-motion").then((m) => m.domAnimation);
<LazyMotion features={loadDomAnimation} strict>Форма, в которой это вернулось: один MotionProvider с асинхронным загрузчиком фич, а строгий режим держит в честности каждого потребителя.
Версия 26 июля заменила обёртки на каждую секцию одним общим MotionProvider и вошла в код только после того, как каждый потребитель на домашнем маршруте был проверен руками. Их три: витрина игр, рельс Лаборатории и обёртка наклона карточек. Домашнее правило теперь записано — секции главной используют m.* под провайдером, никогда motion.*, — и строгий режим следит за ним в рантайме, а не на код-ревью.
Одну ловушку стоит назвать, потому что на ревью она невидима. CardTilt держит постоянный тип элемента: он ограничивает поведение на уровне стилей и никогда не подменяет div на m.div. Подмена типа элемента на клиенте перемонтировала бы всё поддерево после гидратации — а для карточки, внутри которой канвас-сцена, это значит снести и заново собрать ровно то, что вы пытались удешевить. Ограничивать стилями некрасивее и правильно.
Начальный JavaScript главной остановился на 1365 КБ сырыми против прежних 1456 КБ, и ни один чанк не несёт анимационного ядра.
Последний ulp
Самый странный артефакт кампании к скорости отношения не имел. Один из наших постеров игр — у riftline — считает свои координаты SVG через тригонометрию, а React 19 при гидратации сверяет атрибуты, отрисованные на сервере, с отрисованными на клиенте. libm в Node и libm в Chromium расходятся в последней единице последнего разряда. Две строки отличаются цифрой, которую ни один человек никогда не увидит, React это замечает — и вы получаете предупреждение гидратации на статичной картинке. Лечится квантованием координат до того, как они станут атрибутами, что другими словами звучит так: если две реализации обязаны сойтись на числе, не просите их сходиться на нём целиком.
Оба коммита выехали с зелёными tsc, eslint и продакшен-сборкой и со всеми 27 уже существовавшими сквозными тестами. Последняя проверка важнее, чем кажется: почти каждое изменение здесь убирает работу, а убрать работу — самый лёгкий способ случайно убрать функциональность.
Тому, кто начинает такую же кампанию, мы сказали бы, что разделение и есть весь метод. Баги доставки и баги рантайма порождают одинаковые жалобы и почти не имеют общих починок; половина доставки здесь была одним структурным решением о том, где в дереве маршрутов живёт локаль, а половина рантайма — девяноста мелкими. Ни одну из них нельзя было найти, глядя на другую.
Инвариант, с которым мы теперь живём, нравится нам меньше всего. setRequestLocale обязан появляться на каждой локализованной странице, и, когда его нет, не падает ничего: страница просто перестаёт быть статической, и весь выигрыш в доставке тихо утекает по маршруту за раз. Если хотите увидеть, за какой сайт эти числа заплачены, Лаборатория — самое тяжёлое, что мы отдаём, и лучшая проверка того, держится ли диета. Перестройка главной страницы объясняет, почему там вообще столько всего рендерится, а шесть языков в одной кодовой базе — почему сегмент локали сидел там, где сидел.