Cold Start w React Native 2026: Optymalizacja TTI, Hermes V1 i Bridgeless Mode
Kompletny przewodnik po skracaniu cold startu w React Native 2026. Pomiar TTI w Instruments i Perfetto, Hermes V1, Nowa Architektura z Bridgeless Mode oraz cięcie bundla poniżej 4 MB, z konkretnymi patchami z produkcji.
Cold start w React Native to czas od dotknięcia ikony do momentu, w którym pierwszy ekran reaguje na dotyk. Szczerze mówiąc, jeśli w 2026 nie schodzisz poniżej 2,0 s na Pixelu 6a i 1,2 s na iPhone 13, tracisz użytkowników jeszcze przed pierwszym tapem. Osiągnięcie tych liczb wymaga trzech rzeczy: pomiaru TTI w buildzie release, przełączenia na Nową Architekturę z Hermes V1 (domyślne od 0.84) oraz odchudzenia bundla poniżej 4 MB. W tym przewodniku pokazuję kompletny workflow z trace'ami — od pomiaru, przez profiling, do konkretnych patchy, które przycinają startup o 30–58%.
Cold start (TTI) w release buildzie powinien mieścić się poniżej 2000 ms na mid-range Androidzie i 1200 ms na iPhone 13. Wszystko powyżej boli konwersję w sklepach.
Hermes V1 (domyślny od RN 0.84) skraca TTI o 25–58% względem JSC dzięki prekompilacji bytecode'u, więc parsowanie źródeł znika z krytycznej ścieżki startu.
Nowa Architektura (Fabric + TurboModules + Bridgeless) eliminuje asynchroniczny bridge, a synchroniczne wywołania natywne przez JSI mierzalnie skracają time-to-first-render.
Utrzymuj bundle JS pod 4 MB źródła. Powyżej tej granicy parsing wraca do wykresu flame nawet z Hermesem; Metro tree-shaking i dynamiczne require() ładują ekrany na żądanie.
Blokujące SDK w didFinishLaunching / onCreate (analytics, crash reporting, auth) to najczęstsza pojedyncza przyczyna wolnego startu. Mierz z Instruments App Launch przed każdą kolejną integracją.
Splash screen z expo-splash-screen nie skraca cold startu, a jedynie maskuje go; realne skrócenie wymaga profilingu i cięć.
Czym jest cold start i jak różni się od warm/hot startu
Cold start to sytuacja, w której system operacyjny musi utworzyć nowy proces aplikacji, załadować binarkę, zainicjować JS runtime i wyrenderować pierwszy interaktywny ekran. Nic nie jest w pamięci, nic nie jest wcielone w cache. To najgorszy scenariusz, jaki użytkownik zobaczy. To na nim optymalizujemy, bo mierzalnie wpływa na retencję dnia pierwszego.
Warm start zachodzi, gdy proces został wygaszony przez system, ale część zasobów (bundle w cache dyskowym, prekompilowane widoki) jest jeszcze pod ręką. Mierzę go osobno, bo w release buildzie potrafi być 40% szybszy od cold. Hot start: aplikacja siedzi w tle, wraca w kilkadziesiąt milisekund. Jego nie optymalizujemy JS-em, to głównie wznowienie ostatniej klatki UI.
Definicje z 2026 przyjmuję za dokumentacją Androida (Cold/Warm/Hot to formalne pojęcia w Google Play Console, sekcja Android Vitals). Na iOS Apple używa App Launch, Instruments template, gdzie cold odpowiada scenariuszowi „first launch after reboot lub 3+ minuty po killu".
Krytyczna metryka nazywa się Time-To-Interactive (TTI): moment, w którym pierwszy ekran nie tylko widać, ale można w niego dotknąć i coś się dzieje. To nie to samo co First Contentful Paint (FCP). Między FCP a TTI zdarza się blokada JS trwająca sekundy, jeśli w useEffect na topie stacka odpalasz synchronous fetch.
Jak zmierzyć TTI w React Native 2026 (Instruments, Perfetto, JS marks)
Zasada zero, którą łamią wszyscy początkujący: zawsze mierz release build. Debug build z metro serverem, remote debuggerem i sourcemapami potrafi mieć cold start 3–5× wolniejszy, więc te liczby są bezużyteczne. Odpalam npx expo run:ios --configuration Release albo npx react-native run-android --variant=release.
iOS: Xcode Instruments, App Launch template
Xcode → Product → Profile → App Launch. Instrument nagrywa cały handshake między systemem a procesem aplikacji. Interesują mnie trzy sekcje w trace: App Launch (blok „dyld", „static runtime init", „UIApplication init"), Main Thread i Time Profiler. Zwykle to UIApplicationMain → -[AppDelegate application:didFinishLaunchingWithOptions:] zjada 400–800 ms na SDK-ach. To pierwszy target.
Android: Perfetto + adb
Od 2026 rekomenduję Perfetto zamiast systrace (systrace jest deprecated). Rejestruję trace poleceniem:
Potem odpalam aplikację przez adb shell am start-activity -W -S com.mojaapp/.MainActivity. Flag -W zwraca ThisTime (czas tej konkretnej aktywności) i TotalTime (od procesu). To najprostszy pomiar cold startu w CI.
Marks JavaScript: window.performance
Wewnątrz JS-a zaznaczam własne markery. Wstrzykuję je w index.js, tuż przed AppRegistry.registerComponent:
import { AppRegistry } from 'react-native';
import App from './App';
// Marker 0: moduł JS załadowany
performance.mark('js-loaded');
AppRegistry.registerComponent('MojaApp', () => App);
// W App.tsx, w useEffect na najwyższym poziomie:
useEffect(() => {
performance.mark('first-render');
performance.measure('tti', 'js-loaded', 'first-render');
const entry = performance.getEntriesByName('tti')[0];
console.log(`[TTI] ${entry.duration.toFixed(1)}ms`);
}, []);
W release buildzie ten log trafia do logcat / Console.app. Wysyłam go do Sentry / Datadog przez custom span, dostajesz p50/p95 z produkcji zamiast jednego pomiaru z laptopa. To ten sam patern, który opisałem w przewodniku po debugowaniu w React Native DevTools po wycofaniu Flippera.
Hermes V1 i bytecode: dlaczego skraca cold start o 30%+
Hermes to silnik JS napisany przez Metę pod cold start mobilny. Kluczowa różnica względem JSC/V8: kompilacja JS → bytecode dzieje się na build machine, nie na urządzeniu. Aplikacja ładuje gotowy Hermes Bytecode Bundle (.hbc). Parsing i AST znikają z krytycznej ścieżki startu.
W benchmarkach zebranych na 2026: TTI spada z 3,8 s (JSC) do 1,6 s (Hermes) w średniej wielkości aplikacji — 58% skrócenia. Realne cyfry produkcyjne (Expensify, publikowane 2025) są skromniejsze: 7,6% total TTI na Androidzie, 2,5% na iOS. Twoja aplikacja wyląduje gdzieś pomiędzy. Bytecode jest też 10–30% mniejszy od źródła JS, co daje bezpośredni zysk na rozmiarze APK/IPA.
Włączanie Hermesa (od 0.84 domyślne)
Od React Native 0.84 Hermes V1 jest domyślnym silnikiem. Jeśli startujesz nowy projekt, nie musisz nic robić. Dla migracji z JSC:
// android/gradle.properties
hermesEnabled=true
// ios/Podfile
:hermes_enabled => true
// Po zmianie:
cd ios && pod install
cd .. && npx react-native run-ios --configuration Release
Static Hermes to preview compilera, który prekompiluje typed JS do natywnego kodu ARM64. W wewnętrznych benchmarkach Mety pokazuje ×3 speedup na hot code paths względem standardowego Hermesa. Wymaga typed dialektu (TypeScript z --strict + adnotacje typu w hot pathach). Na produkcję rekomenduję poczekać do stabilnego release'u. Obserwuj oficjalny słownik architektury React Native. W międzyczasie standardowy Hermes V1 wystarczy dla większości scenariuszy.
Nowa Architektura, Bridgeless Mode i wpływ na startup
Nowa Architektura to trzy elementy: Fabric (nowy renderer), TurboModules (moduły natywne przez JSI) i Bridgeless Mode (całkowita eliminacja starego bridge'a). Od RN 0.76 jest domyślna dla nowych projektów; od 0.82 stary bridge jest w praktyce usunięty.
Wpływ na cold start? W trace'ach z Perfetto po migracji widzę dwa efekty. Po pierwsze, znika inicjalizacja bridge'a (JSON serialization thread pool, message queue), a to zwykle 80–150 ms na Androidzie. Po drugie, TurboModules są ładowane leniwie, moduł nie jest inicjalizowany, dopóki JS go nie zawoła. W starej architekturze wszystkie moduły natywne były ładowane eagerly przy starcie.
Real-world dane z migracji publikowanych w 2025–2026 mówią o skróceniu cold startu średnio o 40% po pełnym przejściu na Bridgeless. To zgadza się z moimi trace'ami. Na jednym z projektów kliencki spadek to było 2,4 s → 1,4 s (Pixel 6a, release build, 100 iteracji, mediana).
Odchudzanie bundla JS: Metro tree-shaking i lazy loading ekranów
Nawet z Hermesem parsing bytecode'u nie jest zerowy (przekonałem się o tym w audycie sklepu e-commerce, gdzie sam Hermes bez dalszego cięcia dał tylko 12% zysku). Kiedy bundle przekracza 4 MB źródła (~1,3 MB po Hermesie), parsing wraca do trace'a jako 200–400 ms na średnim Androidzie. Trzy techniki, które mierzalnie tną rozmiar.
1. Analiza bundla przed cięciem
Bez pomiaru nie ma optymalizacji. Generuję source-map explorer:
Wynik to interaktywny sunburst, gdzie od razu widać, że moment waży 240 kB (podmieniam na date-fns lub natywny Intl.DateTimeFormat), lodash zajmuje 90 kB (importujesz per-funkcję: import debounce from 'lodash/debounce', nie import _ from 'lodash'), a react-native-vector-icons ma 1,2 MB ikon, których używasz 12.
2. Metro tree-shaking (RN 0.79+)
Metro od wersji 0.79 obsługuje eksperymentalny tree-shaking dla modułów ES6. Włączam w metro.config.js:
inlineRequires: true to najważniejszy flag dla cold startu. Metro przepisuje wszystkie top-level require() na inline'y wykonywane przy pierwszym użyciu. W praktyce oznacza to, że kod ekranu Settings nie ładuje się, dopóki użytkownik nie dotknie Settings. Mierzalny efekt: 15–25% szybszy TTI w apkach z >30 ekranami.
3. Lazy loading ekranów przez React.lazy
Dla dużych ekranów (analytics dashboards, edytory, mapy) używam React.lazy z Suspense:
W kombinacji z Expo Router: opakowuję cały ekran w lazy(), bo router już obsługuje Suspense boundary. Zysk: 400 kB kodu chart.js nie idzie do initial bundle.
Zanim JS w ogóle się załaduje, natywny host aplikacji musi wystartować. To tu żyją najgorsze grzechy: analytics SDK w didFinishLaunching, crash reporter parsujący pliki DSYM, feature flags czekające na sieć. Trace pokaże ci to jednoznacznie.
iOS: AppDelegate
Reguła: application:didFinishLaunchingWithOptions: zwraca YES jak najszybciej. Wszystko, co nie jest niezbędne do pokazania pierwszego ekranu, przesuwam do dispatch_async na background queue albo do UIApplicationDidBecomeActiveNotification. Przykład, źle:
Zysk w trace'ach: 300–600 ms na iPhone SE. Widać to od razu w Instruments jako skrócenie bloku „static runtime init".
Android: MainActivity + Application.onCreate
Ten sam pattern po stronie Androida. Analytics/crash SDK do WorkManager albo do onResume(). Jeśli używasz expo-modules-core, moduły są rejestrowane leniwie w Nowej Architekturze, więc nic dodatkowego nie robisz. Jeśli masz legacy autolinking, sprawdź PackageList.java. Każdy pakiet tam wymieniony ładuje się przy starcie.
Startup profiler w Android Studio
Android Studio Hedgehog+ ma dedykowany Startup Profiler. Odpalam Run → Profile 'app' → wybieram „Cold" i „Start recording on app launch". Nagranie pokazuje wszystkie klasy załadowane w Application.onCreate() z czasem. Od razu widać, że FirebaseInitProvider zjada 180 ms, mimo że aplikacja nie potrzebuje Firebase w pierwszej sekundzie.
Splash screen z expo-splash-screen: kiedy pomaga, a kiedy oszukuje
Splash screen to obraz pokazywany do momentu, aż aplikacja jest gotowa. Nie skraca cold startu, a jedynie go maskuje. Ale robi to skutecznie, jeśli używasz go świadomie. W Expo SDK 56 expo-splash-screen obsługuje ręczne zwolnienie:
import * as SplashScreen from 'expo-splash-screen';
import { useEffect, useState } from 'react';
SplashScreen.preventAutoHideAsync();
export default function App() {
const [ready, setReady] = useState(false);
useEffect(() => {
async function prepare() {
await Promise.all([
loadFonts(),
prefetchUserProfile(),
warmDatabase(),
]);
setReady(true);
}
prepare();
}, []);
useEffect(() => {
if (ready) SplashScreen.hideAsync();
}, [ready]);
if (!ready) return null;
return <MainNavigator />;
}
Sztuczka: użytkownik zobaczy pierwszy „prawdziwy" ekran zamiast bootstrap loadera. Ale uwaga na antywzorzec: developerzy trzymają splash aż do końca uwierzytelniania, przez co splash trwa 4 sekundy zamiast 1,2. Reguła kciuka: splash nigdy dłużej niż 1,5 s, potem pokazujesz UI z placeholderami (skeleton screens) i doładowujesz dane w tle.
Dlaczego moja aplikacja React Native uruchamia się wolno?
W 90% audytów, które robię, przyczyna leży w jednym z pięciu miejsc, w tej kolejności prawdopodobieństwa:
SDK ładowane synchronicznie w didFinishLaunching / Application.onCreate. Analytics, crash reporting, remote config. Trace Instruments pokazuje bloki 100–500 ms na [FIRApp configure], SentryClient init itp. Naprawa: dispatch na background queue.
Bundle JS > 4 MB bez tree-shakingu i inline requires. Naprawa: inlineRequires: true w metro.config, React.lazy dla dużych ekranów, source-map-explorer do znalezienia zwycięzców.
Ciężka synchroniczna praca w root useEffect. fetch do backendu, deserializacja 5 MB JSON-a z AsyncStorage, odpalanie kamery. Naprawa: przenieś do InteractionManager.runAfterInteractions albo warunkowego renderu po pierwszej klatce.
Duże grafiki w initial bundle. PNG 2,4 MB w assets/, którego splash używa. Naprawa: SVG lub WebP, ładowanie przez expo-image z placeholderem.
Stary JSC zamiast Hermesa. Zaskakująco częste w legacy projektach. Naprawa: hermesEnabled=true i pomiar różnicy.
Bez trace'a nie zgaduj. Widziałem projekty, które przez tydzień „optymalizowały" React re-rendery, żeby dowiedzieć się, że problem to jeden WKWebView inicjalizowany na starcie. Zawsze najpierw Instruments albo Perfetto, potem hipoteza.
Checklist produkcyjny przed wysłaniem do sklepu
Zanim wysyłam build do TestFlight / Play Console internal testing, przechodzę tę listę. Każdy punkt jest mierzalny:
Cold start (mediana z 20 pomiarów) < 2000 ms na Pixel 6a, < 1200 ms na iPhone 13. Mierz w release, na fizycznym urządzeniu.
Hermes włączony, potwierdzone przez global.HermesInternal.
Nowa Architektura włączona, wszystkie zależności zgodne (sprawdzenie w React Native Directory).
Bundle JS < 4 MB źródła (raport z source-map-explorer w CI).
Metro inlineRequires: true w produkcyjnej konfiguracji.
Żadne SDK w didFinishLaunching / Application.onCreate, wszystko async.
Splash screen < 1500 ms, po nim skeleton, nie loader.
Startup trace zapisany jako baseline, porównuję go po każdym większym release'ie.
p50 i p95 TTI monitorowane w Sentry / Datadog Real User Monitoring. Bez tego optymalizujesz na własnym laptopie, nie na sprzęcie użytkowników.
Ile powinien wynosić cold start w React Native w 2026?
Cel to poniżej 2000 ms na mid-range Androidzie (Pixel 6a) i poniżej 1200 ms na iPhone 13, mierzone jako TTI w release buildzie. Powyżej 3 s użytkownicy zaczynają masowo porzucać aplikację. Dane Google Play pokazują 20% wzrost uninstalli między 2 a 5 sekundą startu.
Czy Hermes zawsze przyspiesza aplikację React Native?
W 95% scenariuszy tak. Hermes V1 skraca TTI o 25–58% względem JSC dzięki prekompilacji bytecode'u. Wyjątek to aplikacje z bardzo intensywnym JIT-owym hot-pathem (skomplikowane obliczenia matematyczne), gdzie V8/JSC z pełnym JIT-em może być lokalnie szybszy. Dla 99% aplikacji CRUD/social/e-commerce Hermes to jednoznaczna wygrana.
Jak zmierzyć TTI w produkcji, nie tylko na laptopie?
Wysyłam własne markery performance.mark jako custom span do Sentry lub Datadog Real User Monitoring. Dostajesz p50/p95/p99 z realnych urządzeń użytkowników. Firebase Performance Monitoring i Android Vitals w Play Console dają startup time out-of-the-box, ale tylko na Androidzie.
Czy splash screen liczy się do cold start time?
Zależy od metryki. TTI (Time to Interactive) liczy do pierwszego dotykalnego ekranu, splash się do tego wlicza. Formalny „cold start" w Android Vitals liczy do pierwszej klatki twojego Activity, więc splash zdefiniowany w theme (Android) jest wliczony, ale JS-owy splash z expo-splash-screen już nie. Zawsze mierz TTI dla realnego doświadczenia użytkownika.
Czy Bridgeless Mode wymaga przepisania natywnych modułów?
Nie, biblioteki wspierające Nową Architekturę (TurboModules + Fabric) działają w Bridgeless bez zmian. Legacy moduły na starym bridge'u wymuszą compat mode, tracąc zysk 40% na cold starcie. Sprawdź kompatybilność zależności w React Native Directory z filtrem „New Architecture".
Praktyczny przewodnik po Maestro E2E dla React Native w 2026: instalacja w Expo, pierwszy scenariusz YAML, Maestro Studio, Maestro Cloud oraz integracja z EAS Build i GitHub Actions.
Nitro Modules to type-safe framework JSI dla React Native. Poznaj Nitrogen, model HybridObject, konfigurację Expo oraz benchmarki wydajności vs Turbo Modules.