React Native startup time optimizacija 2026: TTI mjerenje, cold start i Hermes bytecode

Praktičan vodič za React Native startup optimizaciju u 2026. Kako profilirati TTI, koristiti Hermes bytecode i inline requires te postaviti CI regresijski budget. S mjerenjima s Pixel 6, konfiguracijama i realnim brojevima iz produkcije.

Ažurirano: 2. rujna 2026.

React Native startup time je vrijeme od tap-a na ikonu aplikacije do trenutka kad korisnik može smisleno interagirati s prvim ekranom. Mjeri se kao Time to Interactive (TTI) i u 2026. bi na srednjem Android uređaju trebao biti ispod 1500 ms za cold start i ispod 400 ms za warm start. U ovom vodiču pokazujem kako TTI zapravo profiliram, koje brojke gledam u Perfettu i Instrumentsima te koje četiri poluge (Hermes bytecode, inline requires, lazy native module init, i Fabric bridgeless renderer) daju najveći povrat u milisekundama.

  • Cold start = proces se pokreće od nule; warm start = proces još u memoriji. Optimizacije koje ubrzavaju cold start (bytecode, inline requires) često nemaju efekt na warm start.
  • Hermes bytecode precompilation (.hbc) skida 30–45 % parse+compile vremena JS bundle-a u odnosu na JSC. Na Pixel 6 mjerim pad s ~820 ms na ~470 ms.
  • Inline Requires (Metro getTransformOptions) odgađa evaluaciju modula do trenutka prve upotrebe. TTI na velikom projektu (2400 modula) pao je s 1.9 s na 1.3 s.
  • Bridgeless Mode + Fabric u 0.76+ eliminiraju bridge init koraka (~120 ms na starijim Android uređajima) i omogućuju synchronous native module poziv.
  • Za mjerenje koristite react-native-performance + PerformanceObserver, Perfetto na Androidu i Instruments (Time Profiler) na iOS-u. Nikad ne vjerujte Date.now() u JS-u.
  • Splash screen preko expo-splash-screen hide-a se tek nakon SplashScreen.hideAsync(). Ne skrivajte prerano jer korisnik vidi blank ekran.

Što je TTI u React Native kontekstu?

Time to Interactive je razmak od trenutka kad OS pokreće proces aplikacije do trenutka kad korisnik može tap-nuti gumb i dobiti odgovor. To nije isti trenutak kao "prvi frame nacrtan". U React Nativeu možete imati vidljiv splash ili čak prvi ekran, ali JS thread još parse-a bundle i evaluira module. U profileru to izgleda kao vidljiv UI koji ignorira tap-ove, a touch handleri se pokreću tek 300–600 ms kasnije.

U 2026. koristim sljedeće mile-stone-e kad radim baseline mjerenje:

  • Process start: kernel spawna proces (Android Zygote fork, iOS exec).
  • Native init: UIApplicationDelegate/MainActivity završava onCreate, React host se inicijalizira.
  • Bundle load: .hbc (Hermes) ili .jsbundle se mmap-a u memoriju.
  • JS runtime init: Hermes VM se diže, globalni polyfilli i Metro helpers se evaluiraju.
  • Module evaluation: svi zahtijevani moduli se izvršavaju (najveća poluga za optimizaciju).
  • First render commit: Fabric commit-a prvi shadow tree; UI vidljiv.
  • TTI: JS thread idle, touch handleri odgovaraju.

Na Pixel 6 s Androidom 15 i tipičnom Expo SDK 53 aplikacijom (~1800 modula, Reanimated 4, Expo Router v6) tipično mjerim: proces start → native init 180 ms, bundle load 40 ms, JS init 210 ms, module eval 620 ms, first render 90 ms, JS idle 120 ms. Ukupno oko 1260 ms TTI. Za dublji uvid u Fabric commit fazu preporučujem naš vodič kroz novu arhitekturu React Nativea.

Cold start vs warm start (različiti problemi)

Google i Apple broje ovo drugačije, no za React Native profiling ja koristim tri kategorije. Cold start znači da proces ne postoji u memoriji, dakle nakon reboot-a, force-quit-a ili nakon što OS ubije aplikaciju zbog memory pressure. Ovdje plaćate cijeli lanac: process fork, native init, Hermes VM boot, bundle parse. Ovo je slučaj koji korisnici zapravo opažaju jer se poklapa s vidljivim splash ekranom.

Warm start znači da je proces još u memoriji, samo je aktivnost pauzirana. Nema JS init-a, moduli su već evaluirani, Fabric shadow tree je perzistiran. Cilj je < 400 ms. Ako mjerite više, sumnja pada na AppState handler koji radi teški fetch ili re-hidraciju MMKV-a.

Hot start je samo dovoženje aktivnosti u foreground, gotovo trivijalno. Ali čest problem su prekomjerne useEffect resubscribe operacije koje se okidaju svaki put kad se app vrati u foreground.

Kako mjeriti startup time (profiler-first)

Pravilo broj jedan: console.log(Date.now() - startTime) nije mjerenje. Timestamps iz JS-a ne uzimaju u obzir bridge init, a native side stopwatch nema kontekst gdje ste u module evaluationu. Koristite react-native-performance koji dodaje browser-standard PerformanceObserver API i emit-a runJsBundleEnd, nativeLaunchEnd, contentAppeared markere iz native koda.

// App.tsx
import performance, {
  PerformanceObserver,
  setResourceLoggingEnabled,
} from 'react-native-performance';
import { InteractionManager } from 'react-native';

setResourceLoggingEnabled(true);

new PerformanceObserver((list) => {
  list.getEntries().forEach((entry) => {
    if (entry.name === 'runJsBundleEnd' ||
        entry.name === 'nativeLaunchEnd' ||
        entry.name === 'contentAppeared') {
      console.log(`[perf] ${entry.name} @ ${entry.startTime.toFixed(0)}ms`);
    }
  });
}).observe({ type: 'react-native-mark', buffered: true });

// TTI mark: pozovi nakon što je prvi ekran mount-an i interakcije idle
export function markTTI() {
  InteractionManager.runAfterInteractions(() => {
    performance.mark('tti');
    const nav = performance.getEntriesByName('nativeLaunchStart')[0];
    const tti = performance.getEntriesByName('tti')[0];
    console.log(`[perf] TTI: ${(tti.startTime - nav.startTime).toFixed(0)}ms`);
  });
}

Za dublje razumijevanje što se stvarno događa u tih 1200 ms, koristim Perfetto na Androidu (adb shell perfetto -o /data/misc/perfetto-traces/trace -t 10s -b 32mb sched freq idle am wm gfx view) i Instruments (Time Profiler) na iOS-u. U Perfettu tražim Choreographer#doFrame gap-ove i JavaScriptCore/hermes::vm::Runtime slice-ove.

React DevTools Profiler za render fazu

Nakon što native mjerenja identificiraju da je JS module eval usko grlo, pokrećem React DevTools u profiler modu i snimam prvi render. Sve komponente s > 16 ms self-timea su kandidati za lazy import. Detalji su u vodiču za React Native debugiranje s DevTools, Reactotron i Hermes profilerom.

Hermes bytecode precompilation

Hermes je od 0.70 default engine, ali u 2026. s Reactom 19 dobivate još i ahead-of-time bytecode compilation (.hbc) koja se događa u Metro build phase-i, ne runtime. Umjesto da uređaj parse-a JavaScript source, mmap-a već compile-an bytecode i skače ravno u interpretaciju. Na Pixel 6 mjerim pad JS init faze s 820 ms (JSC) na 470 ms (Hermes bytecode), otprilike 43% brže.

U novim projektima Hermes je auto-enabled. Provjerite u android/app/build.gradle:

project.ext.react = [
  enableHermes: true,
  hermesFlagsRelease: [
    "-O",              // O2 optimizacije
    "-output-source-map",
    "-emit-binary",    // .hbc, ne source
  ],
]

Za Expo, app.json:

{
  "expo": {
    "jsEngine": "hermes",
    "android": { "jsEngine": "hermes" },
    "ios": { "jsEngine": "hermes" }
  }
}

Inline Requires i RAM bundle

Default Metro build evaluira sve module top-of-file. Ako imate 2400 modula, svih 2400 se izvrši prije nego što prvi ekran mount-a. Znači, iako korisnik na Login ekranu ne treba HomeScreen, ProfileScreen ili SettingsScreen, sve to ipak plaća. Inline Requires prepravlja bundle tako da require('./HomeScreen') poziv živi unutar render funkcije i evaluira se tek prilikom prve upotrebe.

// metro.config.js
const { getDefaultConfig } = require('expo/metro-config');

const config = getDefaultConfig(__dirname);

config.transformer.getTransformOptions = async () => ({
  transform: {
    experimentalImportSupport: false,
    inlineRequires: true,  // KLJUČ
  },
});

module.exports = config;

Iskreno, ovo je jedna od onih promjena od jedne linije koja mi je najviše puta spasila release. Na našem production projektu (2400 modula, ~4.2 MB bundle) TTI je pao s 1.9 s na 1.3 s, znači oko 31%. Trade-off: prvi tap na dugme koje otvara ranije neučitan ekran ima jednokratnu 40–80 ms cijenu module eval-a. To rješava InteractionManager.runAfterInteractions(() => require('./HeavyScreen')) na idle vremenu nakon TTI-a.

Kad inline requires ne pomaže

Ako je 80% bundle-a u root App.tsx ili navigatoru koji import-a sve ekrane, inline requires vam neće puno pomoći. Sve je i dalje "used" prije prvog renderiranja. Rješenje je lazy route registration; Expo Router v6 to radi automatski za file-based route-e, pod uvjetom da nemate manualne import-e iz layout-a. Ovo sam propustio na jednom projektu gdje smo iz _layout.tsx import-ali sve ekrane radi TypeScript typinga i onda se čudili zašto inline requires ništa ne pomaže.

Lazy native module inicijalizacija

Native moduli s teškim init()-om (Firebase, Sentry, MMKV s velikim initial data, Reanimated global config) doprinose native init fazi. U 2026. s TurboModules-om init je lazy po default-u, modul se instancira tek na prvi NativeModules.MyModule.method() poziv. No ako u MainApplication.kt imate Firebase.initializeApp(this) u onCreate, izgubili ste 60–120 ms.

// MainApplication.kt - LOŠE
override fun onCreate() {
  super.onCreate()
  Firebase.initializeApp(this)          // 120 ms na Pixel 6
  Sentry.init(this) { it.dsn = "..." }  // 45 ms
  SoLoader.init(this, false)            // 15 ms
}

// BOLJE - odgodi na idle
override fun onCreate() {
  super.onCreate()
  SoLoader.init(this, false)  // ovo mora rano
  Handler(Looper.getMainLooper()).postDelayed({
    Firebase.initializeApp(this)
    Sentry.init(this) { it.dsn = "..." }
  }, 2000)  // ili nakon prvog Fabric commit-a
}

Fabric, Bridgeless Mode i startup

Bridgeless Mode (default od 0.76) eliminira legacy async bridge inicijalizaciju, otprilike 120 ms na starijim Android uređajima. Fabric renderer commit-a prvi shadow tree synchronous, što znači da prvi frame može biti draw-an u istoj VSync periodi kao module eval end. Za native module poziv nemate više MessageQueue serialization, već direktan JSI call.

Za enable u legacy projektima (android/gradle.properties):

newArchEnabled=true
bridgelessEnabled=true

Provjerite kompatibilnost svih third-party native modula. MMKV, Reanimated 4, expo-sqlite, react-native-screens svi rade bridgeless u 2026. Za dublji uvod pogledajte vodič kroz optimizaciju performansi React Nativea s FlashList v2, Reanimated 4 i Hermesom.

Splash screen bez blank flash-a

Klasičan bug: splash se sakriva u useEffect nakon što Root komponenta mount-a, ali JS module eval nije završio. Korisnik vidi bijeli ekran 300 ms prije prvog render-a. Rješenje: expo-splash-screen preventAutoHideAsync, pa hideAsync tek nakon onLayout-a prvog stvarnog ekrana.

import * as SplashScreen from 'expo-splash-screen';

// Odmah pri startupu, prije bilo kakvog import-a ekrana
SplashScreen.preventAutoHideAsync();

export default function RootLayout() {
  const [fontsLoaded] = useFonts({ Inter: require('./Inter.otf') });
  const [dataReady, setDataReady] = useState(false);

  useEffect(() => {
    hydrateMMKV().then(() => setDataReady(true));
  }, []);

  const onLayoutRootView = useCallback(async () => {
    if (fontsLoaded && dataReady) {
      await SplashScreen.hideAsync();
      markTTI();  // označi TTI trenutak
    }
  }, [fontsLoaded, dataReady]);

  if (!fontsLoaded || !dataReady) return null;

  return (
    <View onLayout={onLayoutRootView} style={{ flex: 1 }}>
      <Stack />
    </View>
  );
}

U 2026. Expo SDK 53 dodaje SplashScreen.setOptions({ fade: true, duration: 200 }) za suptilniji prijelaz. Ali oprez: fade od 400 ms je 400 ms u kojima korisnik ne može tap-nuti. Meni je 150–200 ms zlatna sredina.

CI regresijski budget za TTI

Optimizacija bez regresije reprezentacije je iluzija. Postavite budget: TTI cold start < 1500 ms na Pixel 6 API 34. Napravite CI job koji na svakom PR-u pokreće Maestro flow koji mjeri TTI 10 puta, uzima medijan, i fail-a build ako je iznad budget-a + 10%. Osobno sam ovo naučio na teži način: na jednom projektu nam je novi Firebase Analytics SDK tiho dodao 180 ms cold start-u i primijetili smo tek tri release-a kasnije.

# .github/workflows/perf.yml
- name: Measure cold start TTI
  run: |
    for i in {1..10}; do
      adb shell am force-stop com.mycompany.app
      sleep 2
      adb shell am start-activity -W -n com.mycompany.app/.MainActivity \
        | grep "TotalTime" | awk '{print $2}'
    done | sort -n | awk 'NR==5{print "median:", $1}'

Za detaljniji E2E setup s Maestrom pogledajte naš vodič za React Native testiranje s Maestrom, Detoxom i Jestom. Kombinirano s Firebase Test Lab-om ili BrowserStackom, dobit ćete matricu uređaj-verzija-startup koja rano prijavljuje regresije.

Često postavljana pitanja

Zašto je moja React Native aplikacija spora prilikom pokretanja?

Najčešće su tri uzroka: JS module eval s prevelikim brojem modula bez inline requires-a (tipično > 400 ms na 2000+ modula), sinkroni native module init u MainApplication.onCreate (Firebase, Sentry, Analytics), i JSC umjesto Hermes engine-a. Profilirajte s react-native-performance markerima i idite od najvećeg pika prema dolje.

Koje je razumno vrijeme za React Native cold start?

Na srednjem Android uređaju (Pixel 6, Snapdragon 720G klasa) ciljajte < 1500 ms TTI za cold start. iPhone 13 i noviji tipično su ispod 900 ms. Niže od 1000 ms na Androidu je odlično, ali zahtijeva agresivnu upotrebu inline requires-a i lazy native init-a.

Kako Hermes poboljšava startup u odnosu na JavaScriptCore?

Hermes precompile-a JS u bytecode u build time (.hbc), pa uređaj ne mora parse-ati source code prilikom svakog startup-a. To skida 30–45% parse+compile faze. Na Pixel 6 mjerim pad s ~820 ms na ~470 ms. Dodatno, Hermes ima manji memory footprint (~10–15 MB), što smanjuje šanse da OS ubije proces.

Je li Inline Requires siguran za production?

Da, u 2026. je inlineRequires: true standardna preporuka za production. Trade-off je jednokratni 40–80 ms hit prvi put kad se ekran otvori. Kompenzira se pozivom InteractionManager.runAfterInteractions(() => require('./HeavyScreen')) nakon TTI-a, tako da se sljedeći tap ne osjeti.

Kako mjeriti TTI na produkcijskim uređajima korisnika?

Sentry Performance Monitoring hvata ui.load transaction i emit-a app_start_cold / app_start_warm span-ove. Firebase Performance Monitoring ima analognu _app_start traceu. Za custom TTI (nakon što je JS thread idle), koristite performance.mark('tti') u kombinaciji sa Sentry Sentry.metrics.distribution('tti_ms', value).

Utječe li broj native modula na cold start?

S TurboModules-om, samo import-anje modula je jeftino, jer se instanciranje događa lazy na prvi poziv. Ali autolinking i SoLoader.loadLibrary koji učitava .so file svakog modula ima real cost: oko 5–15 ms po modulu. Aplikacije s 40+ native modula mogu izgubiti 200+ ms samo na tome. Audit-ajte s adb shell dumpsys package com.mycompany.app | grep nativeLibraryPath.

Carlos Mendoza
O Autoru Carlos Mendoza

Mobile performance engineer who profiles for a living. Has spent more hours in Flipper than he'll admit.