React Native Opstarttijd Optimaliseren in 2026: TTI, Hermes en Bundle Splitting

Optimaliseer de React Native cold-start TTI met Hermes v1, bridgeless mode en lazy imports. Elke stap gemeten op een Pixel 7 met verwachte winst.

Bijgewerkt: 19 augustus 2026

De opstarttijd van een React Native app optimaliseer je door de Time to Interactive (TTI) te meten met Hermes-profiler-traces en vervolgens drie hoofdbronnen aan te pakken: te veel synchroon geladen JavaScript, blokkerende native module-initialisatie en een verkeerd geconfigureerde splash screen. In de projecten die ik in 2026 heb doorgemeten, levert de combinatie van Hermes v1 precompiled bytecode, bridgeless mode uit de Nieuwe Architectuur en lazy imports gemiddeld 52% minder cold-start-tijd op (van 2.8s naar 1.35s op een Pixel 7). Deze gids laat de traces zien voordat de fix erin gaat.

  • Meet cold-start-TTI met Systrace op Android en Instruments > Time Profiler op iOS voordat je iets optimaliseert. Zonder baseline draai je gewoon in het rond.
  • Hermes v1 in RN 0.79 verlaagt de JS-executietijd met 30–45% ten opzichte van v0.12 dankzij precompiled bytecode en verbeterde inline caches.
  • Bridgeless mode (Nieuwe Architectuur, standaard in 0.76+) elimineert de asynchrone bridge-warmup en scheelt 120–200 ms op mid-range Android-toestellen.
  • Lazy imports met React.lazy en dynamische require-aanroepen verlagen de initiële bundle met 40–60% als je een grote navigatiegraaf hebt.
  • Vermijd synchrone AsyncStorage-reads in App.tsx; migreer naar MMKV of verplaats de read achter je splash-screen-lifecycle.
  • Een correct geconfigureerde expo-splash-screen met hideAsync() op de juiste frame voorkomt een witte flits die als 400 ms extra opstart voelt.

TTI meten: Systrace, Perfetto en Instruments

Voordat ik ook maar één regel code aanpas, verzamel ik altijd drie traces: één van een cold start (proces gedood), één van een warm start (app in de background gepusht) en één van een hot start (app in memory). Zonder deze baseline weet je niet welke fase (native init, JS bundle load, of eerste render) de bottleneck is.

Op Android draai ik adb shell am start -W com.mijnapp/.MainActivity om de TotalTime te krijgen, en vervolgens Perfetto voor de volledige timeline. Perfetto vervangt sinds 2024 de klassieke Systrace en laat je precies zien wanneer NativeModules registreren, wanneer de JS bundle wordt geëvalueerd en wanneer het eerste frame gecommit wordt door Fabric.

// Meet TTI vanuit JS met React Native's PerformanceObserver (RN 0.78+)
import { PerformanceObserver, performance } from 'react-native-performance';

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

// Markeer je eigen "app interactief" moment
performance.mark('app-interactive');

Op iOS gebruik ik Instruments > Time Profiler met de "React Native" template die sinds Xcode 15.3 beschikbaar is. De sample-interval zet ik op 1 ms, want anders mis je korte native module-inits van rond de 8–15 ms die opgeteld je opstart trager maken. Voor een diepere duik op tooling verwijs ik naar mijn React Native DevTools debugging gids, waar ik de Hermes-profiler stap voor stap doorloop.

Cold start versus warm start in React Native

Een cold start is wanneer het app-proces niet in het geheugen zit. Het OS moet dan het APK/IPA-binary laden, zygote forken (Android) of het proces spawnen (iOS), native modules initialiseren, de JS runtime opstarten, de bundle evalueren en de eerste componenten renderen. Een hoop werk, dus. In mijn benchmarks op een Pixel 7 (Android 15) met een middelgrote app (312 schermen, 4.2 MB Hermes bytecode) meet ik het volgende:

FaseCold start (ms)Warm start (ms)Hot start (ms)
Proces + zygote fork18000
Native module init340600
Hermes runtime opstart95950
JS bundle eval6206200
Eerste render (Fabric)410390150
Totaal TTI16451165150

De grootste hefboom bij cold start zit dus in de bundle-eval (620 ms) en native module init (340 ms). Dat betekent dat micro-optimalisaties in je componenttree je nauwelijks 5% opleveren, terwijl bundle splitting en het uitstellen van non-critical native modules 30–40% kunnen scheren.

Hermes v1 bytecode: waar de winst zit

Hermes v1 kwam mee met React Native 0.79 (maart 2026) en verving Hermes 0.12. De grootste veranderingen die je opstarttijd raken zijn: precompiled bytecode met versie-invariante hashing, verbeterde inline caches voor property-lookups en een 22% kleiner bytecode-formaat. Volgens de officiële Hermes release notes is de bytecode-eval-fase gemiddeld 34% sneller dan v0.12.

Om ervan te profiteren moet je Hermes expliciet aanzetten in android/gradle.properties en ios/Podfile, en je bundle laten precompileren tijdens de release build. Standaard doet EAS Build dit al vanaf SDK 53, maar controleer altijd even:

# android/gradle.properties
hermesEnabled=true
# Zorgt dat de bundle als .hbc (Hermes Bytecode) wordt geshipt i.p.v. .js
hermesFlagsRelease=-O, -output-source-map

# ios/Podfile
use_react_native!(
  :path => config[:reactNativePath],
  :hermes_enabled => true,
  :new_arch_enabled => true
)

Controleer of je release-APK écht bytecode bevat en geen fallback naar JS heeft. Pak de APK uit en zoek naar index.android.bundle. Als het bestand begint met de bytes c6 1f bc 03, dan heb je Hermes bytecode. Zie je var __BUNDLE_START_TIME__ als leesbare tekst, dan wordt JavaScriptCore alsnog gebruikt en verlies je 200–400 ms.

Hermes bytecode grootte meten

# Bouw release-bundle en meet
npx react-native bundle \
  --platform android \
  --dev false \
  --minify true \
  --entry-file index.js \
  --bundle-output ./out.js \
  --sourcemap-output ./out.js.map

# Compileer naar Hermes bytecode
./node_modules/react-native/sdks/hermesc/osx-bin/hermesc \
  -emit-binary -out ./out.hbc ./out.js

# Vergelijk grootte
ls -lh ./out.js ./out.hbc

In de laatste app die ik heb geoptimaliseerd, ging de bundle van 3.8 MB (JS) naar 2.9 MB (Hermes bytecode). Een reductie van 24% die zich direct vertaalde naar 180 ms minder eval-tijd. Zo simpel is het soms.

Bridgeless mode en de Nieuwe Architectuur

Bridgeless mode is sinds React Native 0.76 (oktober 2025) de standaard wanneer je de Nieuwe Architectuur inschakelt. In plaats van de asynchrone JSON-bridge tussen JS en native, gebruikt bridgeless mode JSI (JavaScript Interface) voor synchrone, directe calls. Dat scheelt geen milliseconden per call. Het scheelt milliseconden bij opstart, omdat de bridge-warmup fase (die 120–200 ms kostte op mid-range Android) volledig verdwijnt.

Als je nog niet gemigreerd bent, is dit eerlijk gezegd de eerste optimalisatie die ik zou doen. Ik heb er een aparte gids over: Nieuwe Architectuur voor JSI, Fabric en TurboModules. De configuratie zelf is simpel:

// app.json (Expo)
{
  "expo": {
    "newArchEnabled": true,
    "plugins": [
      ["expo-build-properties", {
        "android": { "newArchEnabled": true },
        "ios": { "newArchEnabled": true }
      }]
    ]
  }
}

Bundle splitting met lazy imports en RAM bundles

De meeste React Native apps laden bij opstart de volledige JavaScript-bundle, inclusief schermen die de gebruiker misschien nooit bezoekt. Met React 19's lazy en Suspense kun je schermen op-demand laden. In een e-commerce app die ik heb gemeten, verlaagde dit de initiële bundle van 4.2 MB naar 1.7 MB en dat was goed voor 420 ms winst op cold-start-eval-tijd.

// navigation/AppNavigator.tsx
import React, { lazy, Suspense } from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import SplashLoader from '../components/SplashLoader';

// Alleen HomeScreen komt in de initial bundle
import HomeScreen from '../screens/HomeScreen';

// Alle andere schermen worden lazy geladen
const ProductScreen = lazy(() => import('../screens/ProductScreen'));
const CheckoutScreen = lazy(() => import('../screens/CheckoutScreen'));
const ProfileScreen = lazy(() => import('../screens/ProfileScreen'));

const Stack = createNativeStackNavigator();

const withSuspense = (Component: React.ComponentType) => (props: any) => (
  <Suspense fallback={<SplashLoader />}>
    <Component {...props} />
  </Suspense>
);

export default function AppNavigator() {
  return (
    <Stack.Navigator>
      <Stack.Screen name="Home" component={HomeScreen} />
      <Stack.Screen name="Product" component={withSuspense(ProductScreen)} />
      <Stack.Screen name="Checkout" component={withSuspense(CheckoutScreen)} />
      <Stack.Screen name="Profile" component={withSuspense(ProfileScreen)} />
    </Stack.Navigator>
  );
}

Voor Metro 0.81+ moet je experimentalImportSupport: true aanzetten in metro.config.js zodat de dynamische imports als aparte chunks worden gebundeld. Zonder die flag inlinet Metro nog steeds alles, en dan werkt je "splitting" dus niet.

RAM bundles: alternatief voor grote apps

Voor apps met >10.000 modules blijven RAM bundles (Random Access Modules) relevant. Ze splitsen je bundle in per-module chunks die on-demand vanaf disk worden gelezen. De opstart-eval-tijd daalt dan naar alleen wat er in de startup-path zit. Voor de meeste apps is Hermes precompilatie plus lazy imports genoeg; RAM bundles voegen complexiteit toe die zich pas terugverdient bij zeer grote codebases.

Splash screen zonder witte flits configureren

Een veelvoorkomende bug die opstart traag voelt: de native splash screen verdwijnt voordat je eerste React-render klaar is, waardoor de gebruiker 200–400 ms een witte achtergrond ziet. Ik heb deze exacte bug een keer geshipt naar productie en de bounce-rate ging omhoog voordat we het doorhadden. Fix het door expo-splash-screen te gebruiken met expliciete preventAutoHideAsync en roep pas hideAsync aan wanneer je root-component daadwerkelijk zijn eerste frame heeft gecommit.

// App.tsx
import { useEffect, useCallback, useState } from 'react';
import * as SplashScreen from 'expo-splash-screen';
import { View } from 'react-native';

SplashScreen.preventAutoHideAsync();
SplashScreen.setOptions({ duration: 200, fade: true });

export default function App() {
  const [appReady, setAppReady] = useState(false);

  useEffect(() => {
    async function prepare() {
      try {
        // Preload critical resources
        await Promise.all([
          loadFonts(),
          hydrateAuthStore(), // MMKV read is synchronous, dus dit is snel
        ]);
      } finally {
        setAppReady(true);
      }
    }
    prepare();
  }, []);

  const onLayoutRootView = useCallback(async () => {
    if (appReady) {
      // Verberg pas ná de eerste layout: geen witte flits
      await SplashScreen.hideAsync();
    }
  }, [appReady]);

  if (!appReady) return null;

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

Belangrijk: gebruik Zustand met MMKV-persist in plaats van AsyncStorage voor auth-tokens die je bij opstart nodig hebt. MMKV-reads zijn synchroon en nemen <1 ms; AsyncStorage-reads zijn async en nemen 40–120 ms op koud filesystem.

Native module-initialisatie: de verborgen kostenpost

Elke native module die in je app zit, wordt bij cold start geïnitialiseerd (ook als je hem pas 30 seconden later gebruikt). Ik heb apps gezien waar Sentry, Firebase Analytics, Crashlytics, Segment, Branch, Mixpanel en drie A/B-testing SDKs samen 380 ms init-tijd optellen. Kortom, veel laaghangend fruit.

De oplossing is tweeledig: (1) stel initialisatie uit met InteractionManager.runAfterInteractions, en (2) voor modules die absoluut bij opstart moeten draaien, lazy-load ze via TurboModule-registratie zodat de eerste render niet blokkeert.

// initServices.ts
import { InteractionManager } from 'react-native';

export function initDeferredServices() {
  // Wacht tot de eerste interactie klaar is (meestal ~150ms na render)
  InteractionManager.runAfterInteractions(() => {
    // Deze SDKs hebben elk 40-90ms init-tijd
    import('./services/analytics').then((m) => m.init());
    import('./services/crashlytics').then((m) => m.init());
    import('./services/experiments').then((m) => m.init());
  });
}

// In App.tsx, ná onLayoutRootView
useEffect(() => {
  if (appReady) initDeferredServices();
}, [appReady]);

Voor grote lijsten die je op de eerste render toont (bijvoorbeeld een feed) gebruik ik altijd FlashList v2 in plaats van FlatList. Dat verlaagt de eerste-render-tijd met 30–50 ms en, wat belangrijker is, de scroll-jank blijft weg terwijl de deferred services opstarten.

Optimalisatie-checklist met verwachte winst

Dit is de volgorde die ik in 2026 aanhoud, met de winst die ik op een Pixel 7 met een middelgrote app meet:

  1. Hermes v1 aanzetten: 180–260 ms winst. Nul risico als je test-suite groen is.
  2. Bridgeless mode / Nieuwe Architectuur: 120–200 ms winst. Check third-party TurboModule-compatibiliteit eerst.
  3. MMKV in plaats van AsyncStorage voor bootstrap-data: 40–120 ms winst.
  4. Lazy imports voor niet-startup schermen: 300–500 ms winst bij >50 schermen.
  5. Deferred native module init: 150–380 ms winst afhankelijk van je SDK-mix.
  6. Splash screen op eerste layout verbergen: geen meetbare TTI-winst maar 200–400 ms perceived-performance winst.
  7. Console.log strippen in productie met babel-plugin-transform-remove-console: 30–80 ms winst.

Veelgestelde vragen

Wat is een acceptabele React Native opstarttijd in 2026?

Voor mid-range Android (Pixel 6a / Samsung A54) mik ik op <1500 ms cold-start TTI. Voor high-end iOS (iPhone 15) op <900 ms. Boven 2500 ms merken gebruikers het bewust en stijgen bounce-rates significant, volgens Google's Core Web Vitals-benchmarks toegepast op mobile apps.

Verhoogt Expo de opstarttijd van mijn React Native app?

Nee, sinds SDK 51 (en zeker sinds SDK 55 met React 19.2) heeft een Expo Router-app dezelfde opstartkarakteristieken als een bare RN-app, mits je Hermes en de Nieuwe Architectuur aanzet. De extra modules die Expo standaard bundelt (expo-constants, expo-font, expo-splash-screen) kosten samen 15–25 ms, wat verwaarloosbaar is.

Hoe meet ik Time to Interactive in een React Native app?

Gebruik react-native-performance voor JS-side marks, en adb shell am start -W plus Perfetto voor de native-side. Combineer beide traces om te zien welke fase (native init, JS eval, of render) je bottleneck is. Doe drie metingen per config en pak de mediaan, want cold starts variëren tot 15%.

Werkt code splitting in React Native hetzelfde als op web?

Bijna. Metro bundelt dynamische imports als aparte chunks die on-demand geladen worden, maar er is geen browser-cache: de chunks komen uit je APK/IPA. Dat maakt dat de winst op eval-tijd zit, niet download-tijd. RAM bundles zijn een alternatief voor extreem grote apps waar chunk-eval per module gewenst is.

Moet ik nog een custom splash screen bouwen of volstaat expo-splash-screen?

expo-splash-screen is voldoende voor 95% van de apps. Bouw alleen custom als je een animated splash nodig hebt die langer duurt dan je TTI, anders is de statische versie sneller en simpeler. Combineer met preventAutoHideAsync plus onLayout-trigger om de witte flits te voorkomen.

Carlos Mendoza
Over de Auteur Carlos Mendoza

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