Оптимизация холодного старта React Native в 2026: TTI, Hermes и inlineRequires
Пошаговый разбор оптимизации холодного старта React Native в 2026: Hermes и Static Hermes, inlineRequires в Metro, React.lazy для экранов, InteractionManager. Реальный кейс с 3,4 с до 1,9 с TTI на Pixel 6a, с кодом и таблицей метрик.
Оптимизация холодного старта React Native в 2026 — это сокращение TTI (Time-to-Interactive) до целевых 1,2 с на iPhone 13 и 2,0 с на Pixel 6a через три шага в строго определённом порядке: включить Hermes, добавить inlineRequires в Metro, вынести экраны в React.lazy. В моих трассах Perfetto эти три изменения стабильно дают минус 40–55% ко времени первого интерактивного кадра. И только после них имеет смысл резать размер бандла или переходить на Bridgeless.
TTI, это момент, когда пользователь может нажать кнопку и получить отклик, а не момент, когда закончилась анимация splash-скрина.
Реальная стоимость запуска, это evaluation time модулей, а не размер бандла: 3 МБ Hermes-байткода могут стартовать медленнее, чем 6 МБ с inlineRequires.
Правильный порядок оптимизации: Hermes, потом inlineRequires, потом React.lazy, дальше InteractionManager и уже под конец тонкая настройка Fabric.
На Android Hermes даёт минус 500–700 мс к TTI, на iOS около минус 100–200 мс из-за более сильного JIT у Apple silicon.
Замерять TTI нужно на release-сборке через react-native-performance от Shopify или маркеры Perfetto. Chrome DevTools для этого не годятся.
Static Hermes в 0.79+ добавляет AOT-компиляцию в машинный код: в моих замерах даёт ещё минус 15–20% на холодных запусках.
Что такое TTI и почему он важнее размера бандла
TTI (Time-to-Interactive) в React Native, это интервал от нажатия на иконку приложения до момента, когда первый рендер завершён, обработчики onPress подключены, а тап по кнопке приводит к видимой реакции. В отличие от веба, где TTI вычисляется алгоритмически по «тишине» в главном потоке, в мобильных приложениях сцены сами сообщают о готовности: данные загружены, UI отрендерён, gesture-handlers слушают.
В цикле запуска участвуют пять фаз, у каждой свои маркеры: nativeLaunchStart → nativeLaunchEnd (инициализация процесса), appCreationStart → appCreationEnd (создание нативного приложения), runJSBundleStart → runJSBundleEnd (загрузка JS-бандла), contentAppeared (первый кадр React) и, наконец, screenInteractive. Вот эта последняя метка и есть TTI. Именно с ней сравнивают все оптимизации.
Индустриальные цели на 2026 год: под 2,0 с на Android среднего класса (Pixel 6a) и под 1,2 с на iPhone 13. Всё выше, это прямые потери в удержании. По данным Indeed Engineering, TTI получил вес 45% в композитном perf-score именно потому, что задержку отклика пользователи воспринимают резче, чем медленный сплэш. И критически важно: уменьшить размер бандла ≠ уменьшить TTI. Бандл может весить 8 МБ, но если inlineRequires отдаёт большую часть модулей в отложенное вычисление, TTI будет ниже, чем у «стройного» 4-МБ бандла, который принудительно исполняет всё до первого рендера.
Как измерить холодный старт React Native
Правило номер один: меряем только на release-сборке. Debug-сборка тянет source maps, Metro-сокет и полный лог, поэтому TTI будет завышен в 2–4 раза, и все оптимизации выглядят «эффективнее», чем есть на самом деле. У меня в проекте перед каждым замером идёт npx react-native run-android --mode=release и xcodebuild -configuration Release. Скучно, но обязательно.
Второе: используйте библиотеку react-native-performance от Shopify. Она инжектит невидимую маркер-view, дожидается, пока нативная сторона реально прикрепит её к окну, и эмитит RenderPassReport с флагом interactive: true. По моему опыту, это единственный способ получить честный end-to-end TTI, включая bridge latency.
Третье: на Android поднимаем Perfetto systrace через официальный capture_systrace.sh. Обязательно добавьте android:profileable="true" в AndroidManifest.xml, иначе Perfetto не увидит JS-thread события. В трейсе смотрите два тега: ReactNative (события бриджа и Fabric) и имя вашего JS-runtime (Hermes). Полезный дополнительный инструмент, Sentry Mobile Vitals с автоматическим appStartCold. Я держу оба: Perfetto для точечных сессий, Sentry для агрегата по production.
Hermes и Static Hermes: настройка для максимальной скорости
С React Native 0.70 Hermes включён по умолчанию, но «по умолчанию» ≠ «оптимально». Дефолтный Hermes компилирует JS в байткод на этапе сборки: это уже даёт минус 30–50% к парсингу на старте. Дальше начинается тонкая настройка. Первое, что я включаю в проектах, это enableHermesLazy в ReactHost. Он откладывает компиляцию частей байткода до первого обращения, что стабильно снимает 80–150 мс на Pixel 6a.
В React Native 0.79 появился Static Hermes, AOT-компилятор, который транслирует типизированный JS в машинный код на этапе сборки, а не в байткод. Работает через аннотации в подмножестве TypeScript. В моих замерах на среднем Android даёт ещё минус 15–20% к TTI поверх обычного Hermes, но требует пересборки нативных модулей и пока не совместим с некоторыми legacy-либами на Bridge.
// metro.config.js, включаем Static Hermes для production
module.exports = {
transformer: {
hermesParser: true,
staticHermes: process.env.NODE_ENV === 'production',
},
};
Заводить Hermes без параллельного включения inlineRequires, это распространённая ошибка (я и сам так делал в первом проекте, где выкатывал Hermes). Байткод всё равно выполняется целиком при старте: Hermes убирает парсинг, но не убирает вычисление модулей. Порядок принципиален: сначала Hermes, потом inlineRequires, никак иначе.
inlineRequires: правильный порядок оптимизации
Metro-опция inlineRequires переносит вызовы require() из верха файла к месту первого использования модуля. По сути, каждый import Foo from './foo' превращается в ленивое обращение: модуль загружается и исполняется только при первом чтении. В большом приложении это сдвигает 40–70% работы модуль-графа за пределы TTI. Честно говоря, это самая недооценённая оптимизация в экосистеме.
После этого обязательно замерьте разницу. В моём последнем аудите крупного e-commerce приложения включение inlineRequires в одиночку срезало 380 мс на Pixel 6a и 90 мс на iPhone 13. Метрика: runJSBundleEnd → contentAppeared, снятая через маркеры Shopify.
Есть ловушка. Некоторые библиотеки полагаются на побочные эффекты при импорте (регистрация обработчиков, monkey-patching). Типичные жертвы: старые версии react-native-gesture-handler, react-native-reanimated, некоторые i18n-либы. Их нужно вынести в блоклист:
Ленивая загрузка экранов через React.lazy и Suspense
inlineRequires откладывает исполнение на уровне модулей, но React-компоненты первого экрана всё равно тянут весь дерево-граф зависимостей. Если HomeScreen импортирует ProfileScreen в статическом навигаторе, весь код профиля попадёт в TTI. Решение, это React.lazy для экранов, куда пользователь не идёт сразу.
Правило, которое я даю командам: лениво грузим экраны, а не микрокомпоненты. Разбиение кнопки на chunk, это оверхед в 15–30 мс на первый рендер и никакого выигрыша в TTI. А ленивая загрузка экрана «Настройки», куда идёт 3% пользователей, легко срезает 200–400 мс с холодного старта.
Ещё один инструмент, Metro tree-shaking для ESM-модулей. С React Native 0.78+ он работает из коробки для пакетов, экспортирующих "type": "module". Проверить, что дерево трясётся, можно через npx react-native bundle --dev false --analyze: в отчёте не должно быть неиспользуемых экспортов из ваших libs.
New Architecture, InteractionManager и Bridgeless-нюансы
New Architecture (Fabric + TurboModules + Bridgeless), это не серебряная пуля для TTI, но заметно ускоряет фазу runJSBundleEnd → contentAppeared. Fabric рендерит синхронно и умеет прерываться, TurboModules инициализируются лениво (только при первом обращении к нативному методу), а Bridgeless убирает JSON-сериализацию через bridge. В моих замерах на среднем Android переход на New Architecture дал минус 180 мс к первому кадру после включения Hermes и inlineRequires.
Подробный чеклист миграции я разобрал в статье про миграцию на New Architecture, Fabric и TurboModules. Тут важно только упомянуть, что Bridgeless-режим требует, чтобы все нативные модули были TurboModules. Одна legacy-либа на bridge, и вы теряете большую часть выигрыша.
Следующий уровень: InteractionManager. Всё, что не критично для первого кадра (аналитика, prefetch, регистрация push-токена, warm-up кэша), заворачиваем в:
В том же кейсе, что дал 3,4 → 1,9 с, вынос трёх фоновых init-задач через runAfterInteractions убрал 300 мс блокирующей работы. Метод простой, эффект стабильный.
Дополнительно для списков на первом экране: переезд с FlatList на FlashList v2 уменьшает время первого рендера ленты на 40–60%. FlashList вычисляет размеры ячеек синхронно и не делает лишних измерений в первом фрейме.
Реальный кейс: с 3,4 с до 1,9 с TTI
Приложение: маркетплейс на React Native 0.76, около 1200 экранов, 8 МБ JS-бандла, 220 нативных модулей. Baseline TTI на Pixel 6a (release, чистый reboot): 3,4 с. Целевая метрика: 2,0 с. Все замеры через @shopify/react-native-performance, среднее из 15 холодных стартов после adb shell am force-stop. Я специально гонял на «плохом» девайсе, а не на своём iPhone 15 Pro. Иначе цифры красивые, а retention страдает.
Шаг
Изменение
TTI после
Delta
0
Baseline (JSC, без оптимизаций)
3400 мс
0 мс
1
Включён Hermes с флагами -O
2800 мс
-600 мс
2
Добавлен inlineRequires: true
2420 мс
-380 мс
3
React.lazy для 40 второстепенных экранов
2190 мс
-230 мс
4
InteractionManager для 3 фоновых задач
1900 мс
-290 мс
5
Cache-first pattern для Remote Config
1900 мс*
0 мс
*Шаг 5 не влияет на первый холодный старт, но на возвращающихся пользователях убирает 1,5–3 с сетевого ожидания. Мы измеряем его отдельной метрикой timeToFullyDrawn.
Что не сработало и было откачено: попытка убрать react-native-svg в пользу PNG-ассетов сэкономила 40 мс, но добавила 3 МБ к APK и ухудшила Lighthouse-score. Замена @react-navigation/native на кастомный роутер сэкономила 60 мс, но сломала deep links, откат на следующий день. Мораль: меряйте каждый шаг, откатывайте то, что не даёт выигрыша на релиз-сборке.
Частые ошибки при оптимизации холодного старта
Первая и самая частая: оптимизация без замеров. Разработчик прочитал статью про lazy loading, обернул половину компонентов в React.lazy, TTI ухудшился на 200 мс (из-за оверхеда Suspense), но этого никто не заметил, потому что базовой метрики не было. Правило: сначала baseline, потом изменение, потом сравнение на 10+ прогонах.
Вторая: измерение в debug-сборке. Debug тянет Metro-сокет, HMR, полный source map. На моей практике TTI в debug превышает release в 2,5–4 раза, и относительная эффективность оптимизаций отличается: inlineRequires в debug почти не даёт эффекта, в release около 15–20%.
Третья: попытка ужать размер бандла до включения Hermes. Terser-минификация даёт минус 8% размера, но 0 мс TTI. Официальная документация Hermes прямо указывает: сначала engine, потом bundle-time оптимизации.
Четвёртая: забыть про android:profileable="true" в AndroidManifest.xml. Без этого флага Perfetto собирает трейс, но React Native events не видны, и вы гадаете, где именно теряется время. См. официальный гайд по профилированию React Native.
Пятая: оптимизировать под флагманы. Все замеры делайте на mid-tier Android (Pixel 6a, Galaxy A54) и iPhone 11/12. На iPhone 15 Pro любой код быстрый, и вы никогда не увидите проблем, которые в 99-м перцентиле убивают retention. Я на этом обжигался как минимум дважды.
Часто задаваемые вопросы
Что такое TTI в React Native простыми словами?
TTI (Time-to-Interactive), это время от нажатия на иконку приложения до момента, когда первый экран отрендерен и реагирует на тапы. Именно эту метрику пользователь ощущает как «скорость запуска», в отличие от размера бандла или длительности splash-скрина.
Насколько Hermes ускоряет запуск React Native?
На Android Hermes стабильно даёт минус 500–700 мс к TTI за счёт AOT-компиляции JS в байткод. На iOS выигрыш скромнее, порядка 100–200 мс, потому что JavaScriptCore на Apple Silicon имеет полноценный JIT. Static Hermes в 0.79+ добавляет ещё минус 15–20% сверху.
В чём разница между inlineRequires и React.lazy?
inlineRequires, это оптимизация Metro, откладывающая исполнение модулей до первого обращения (по всему коду сразу). React.lazy откладывает загрузку компонента на уровне React-дерева и требует Suspense. Их используют вместе: inlineRequires для всего графа, lazy для тяжёлых экранов.
Как использовать Perfetto для профилирования React Native?
Добавьте android:profileable="true" в AndroidManifest.xml, запустите приложение в release-сборке, соберите трейс через perfetto или capture_systrace.sh. В ui.perfetto.dev смотрите на треки ReactNative и Hermes. Там видны маркеры runJSBundle, contentAppeared и события Fabric.
Какие целевые значения TTI считаются нормой в 2026?
Отраслевые ориентиры на 2026: холодный старт под 1,2 с на iPhone 13 и под 2,0 с на Android среднего класса (Pixel 6a). Всё выше 2,5 с на mid-tier Android воспринимается пользователем как «медленное приложение» и статистически бьёт по retention первого дня.
Сравнение FlatList, FlashList v2 и Legend List в React Native 2026. Разбираем cell recycling и виртуализацию, оптимизируем FlatList, мигрируем на FlashList v2 для New Architecture, строим чат на Legend List — с рабочими примерами кода.