Skip to content

Vaynerov Technologies

Мы не просто разрабатываем — мы создаём каждую строку кода и пиксель.

Все статьиРазборы

Как мы ускорили мобильный Safari: два бага, один симптом

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

Vaynerov TechnologiesСтудия
Опубликовано 11 мин чтения
На этой странице
  1. Все маршруты локалей были динамическими
  2. Кешируем базу, а не страницу
  3. Тот же рычаг, двадцать три дня спустя
  4. Девяносто анимаций, на которые никто не смотрел
  5. Каждый iPhone — устройство низшего класса
  6. LazyMotion со второй попытки
  7. Последний ulp

Любой отчёт о производительности от живого человека приходит сжатым. Наш гласил, что на 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>, как и обязан.

Было: один корневой layout читает локаль из состояния запроса, и всё дерево под ним уходит в динамику. Стало: четыре корневых layout делят компонент, а не родителя, и дерево локалей статическое или ISR.

Вторая половина этого перехода постраничная: 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 ГБ, то есть любой iPhone1,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 МБ.
Доставка до и после. Столбцы TTFB в обоих случаях — измерение на localhost; у телефона числа были хуже по обе стороны изменения.

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 обязан появляться на каждой локализованной странице, и, когда его нет, не падает ничего: страница просто перестаёт быть статической, и весь выигрыш в доставке тихо утекает по маршруту за раз. Если хотите увидеть, за какой сайт эти числа заплачены, Лаборатория — самое тяжёлое, что мы отдаём, и лучшая проверка того, держится ли диета. Перестройка главной страницы объясняет, почему там вообще столько всего рендерится, а шесть языков в одной кодовой базе — почему сегмент локали сидел там, где сидел.