Optimiser le Temps de Démarrage d'une App React Native : Guide 2026

Réduisez le cold start React Native de 59 % en 2026 : Hermes bytecode, inlineRequires, splash natif et défer des SDK tiers. Mesures Perfetto et code inclus.

Mis à jour : 22 août 2026

Optimiser le temps de démarrage d'une application React Native en 2026 consiste à supprimer tout travail bloquant sur le thread JavaScript entre le lancement du processus et le premier frame interactif. Concrètement, ça veut dire quatre choses : activer le bytecode Hermes précompilé, différer les require non essentiels avec inlineRequires, garder un splash screen natif jusqu'au Time to Interactive (TTI), et déporter l'initialisation des modules natifs lourds hors du chemin critique. Sur une app Expo SDK 54 typique, ces quatre leviers font passer un cold start de 2 300 ms à environ 950 ms sur un Pixel 6a, soit une réduction de 59 %. Et je le vérifie toujours au trace, jamais au ressenti.

  • Le cold start React Native se décompose en 4 phases mesurables : boot du processus (~150 ms), init des modules natifs (~400 ms), parsing du bundle JS (~600 ms) et premier rendu React (~500 ms). Chacune s'attaque avec un outil différent.
  • Hermes avec bytecode précompilé (activé par défaut depuis RN 0.70) réduit le parsing JS de 55 à 70 % par rapport à JavaScriptCore, mesuré via PerfMonitor et Perfetto.
  • La configuration getTransformOptions.inlineRequires: true dans metro.config.js convertit les imports statiques en require() paresseux, ce qui coupe en moyenne 30 à 40 % du temps d'exécution initial du bundle.
  • Le splash screen natif (expo-splash-screen) doit rester visible jusqu'à ce que le premier écran ait fini son onLayout, pas seulement jusqu'au mount du composant racine, sinon le TTI est sous-estimé.
  • Différer l'initialisation des SDK tiers (Sentry, Firebase, RevenueCat) via InteractionManager.runAfterInteractions ou un setTimeout(0) gagne 200 à 500 ms sur le premier frame utile.
  • Mesurer avant d'optimiser. Sans une trace Perfetto ou Systrace, chaque « amélioration » est une hypothèse. J'ai vu des refactos gagner 100 ms côté JS et en perdre 300 ms côté natif.

Anatomie d'un cold start React Native

Un cold start (l'app n'est ni en mémoire ni en cache) se découpe en quatre segments mesurables sur une trace Perfetto. Sur un Pixel 6a sous Android 15 avec React Native 0.76 et Expo SDK 54, un template vierge donne : boot du processus Zygote (140–170 ms), init de ReactHost et des TurboModules essentiels (380–450 ms), chargement et parsing du bundle Hermes (550–700 ms selon la taille), puis premier rendu React jusqu'au premier frame visible (450–600 ms). Total : 1 520 à 1 920 ms de code neutre.

Sur iOS 18 avec un iPhone 12, les proportions changent. Le boot est plus rapide (~90 ms), l'init des modules similaire, mais JavaScriptCore (si vous n'êtes pas passé à Hermes) pèse deux fois plus lourd au parsing. C'est pour ça qu'un même bundle peut démarrer en 1,1 s sur iOS et 2,3 s sur un Android d'entrée de gamme. Les percentiles importent : je vise toujours P75 sur un appareil bas de gamme (Redmi 9A, iPhone SE 2020), pas la moyenne sur mon Pixel 8 Pro. Un P50 flatteur cache un P95 catastrophique.

La règle de base est simple. Chaque milliseconde entre le tap sur l'icône et le premier frame interactif compte comme du cold start, y compris le splash screen. Google Play Vitals classe une app « lente au démarrage » au-delà de 5 secondes P90, bien au-dessus de ce qu'un utilisateur perçoit comme « rapide » (moins de 1,5 s).

Comment mesurer précisément le TTI ?

Le Time to Interactive est la métrique que je pose en premier, avant toute optimisation. En React Native, il faut deux points de mesure : le timestamp natif du Application.onCreate() (Android) ou didFinishLaunching (iOS), et le timestamp du premier onLayout du composant racine côté JS. La différence est votre TTI réel.

// App.tsx — instrumentation TTI minimale
import { useEffect } from 'react';
import { NativeModules, InteractionManager } from 'react-native';

const { PerformanceModule } = NativeModules;

export default function App() {
  useEffect(() => {
    // Récupère le timestamp natif du lancement
    PerformanceModule.getStartupTimestamp().then((nativeStartMs: number) => {
      InteractionManager.runAfterInteractions(() => {
        const tti = Date.now() - nativeStartMs;
        console.log(`[TTI] Cold start = ${tti} ms`);
        // Envoi vers Sentry, Firebase Performance, etc.
      });
    });
  }, []);

  return <RootNavigator />;
}

Pour instrumenter au niveau natif sans écrire de module, j'utilise le PerfMonitor intégré de React Native avec PerformanceObserver, plus une trace Perfetto pour Android (adb shell perfetto -o /data/misc/perfetto-traces/trace) et Instruments → Time Profiler pour iOS. Les captures doivent inclure le processus applicatif ET le service system_server côté Android, sinon vous ratez les latences d'IPC qui peuvent ajouter 100 à 200 ms.

Hermes et le bytecode précompilé

Hermes est le moteur JS que React Native active par défaut depuis 0.70. Depuis 0.76, c'est même le seul moteur officiellement supporté sur la New Architecture. Son intérêt pour le démarrage tient à la précompilation en Hermes bytecode (HBC) au moment du build : le fichier index.android.bundle n'est pas du JavaScript brut, c'est du bytecode que la VM charge par mmap, sans parser. Sur mes benchmarks, ça coupe 55 % du temps de parsing sur Android d'entrée de gamme et 70 % sur des bundles supérieurs à 4 Mo.

Vérifiez que Hermes est actif en release. Ouvrez le fichier index.android.bundle dans l'APK décompacté : les 4 premiers octets doivent être C6 1F BC 03 (magic number HBC). Si vous voyez du texte JS, Hermes est éteint. Pour l'activer explicitement :

// android/app/build.gradle
project.ext.react = [
    enableHermes: true,
    hermesFlagsRelease: ["-O", "-output-source-map"]
]

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

Sous Expo, jsEngine: "hermes" dans app.json suffit. Un piège que je vois régulièrement, honnêtement au moins une fois sur deux quand j'audite un projet : oublier -O dans hermesFlagsRelease. Sans optimisation, HBC reste 20 à 30 % plus lent qu'attendu. Je détaille les compromis moteur/architecture dans notre guide de migration vers la New Architecture, qui impose Hermes sur Bridgeless.

Inline requires et RAM bundles

Par défaut, Metro concatène tous les modules du graphe d'imports en un seul bundle exécuté de haut en bas au démarrage. Résultat : même un écran de paramètres jamais ouvert au premier lancement est parsé et évalué. Les inline requires corrigent ça en transformant import { Chart } from './Chart' en un require('./Chart') paresseux, exécuté à la première utilisation.

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

const config = getDefaultConfig(__dirname);

config.transformer.getTransformOptions = async () => ({
  transform: {
    experimentalImportSupport: false,
    inlineRequires: true, // clé de la lazy evaluation
  },
});

module.exports = config;

Sur une app avec 380 modules dans le graphe (une base client typique dans mon dernier projet e-commerce), l'activation d'inlineRequires a fait tomber mon temps d'exécution initial du bundle de 610 ms à 380 ms, soit –37,7 %. Mesuré via console.time autour du require racine. Le gain augmente avec la taille de l'app.

Les RAM bundles, l'ancêtre d'inlineRequires, restent utiles sur des builds Android très gros (plus de 15 Mo) où l'on veut aussi charger le bytecode module par module depuis le disque. À activer via bundleCommand: "ram-bundle" dans build.gradle. Sur les apps normales, inline requires suffit largement.

Splash screen natif et handoff JS

Le splash screen n'est pas qu'un habillage. C'est ce qui masque la fenêtre où React n'a pas encore rendu, et sa gestion incorrecte est la première cause de « flash blanc » perçu comme un bug. La règle : le splash reste visible jusqu'à ce que le premier écran ait un contenu significatif à afficher, pas jusqu'au mount du composant.

// App.tsx avec expo-splash-screen 0.29+
import * as SplashScreen from 'expo-splash-screen';
import { useEffect, useState } from 'react';

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

export default function App() {
  const [ready, setReady] = useState(false);

  useEffect(() => {
    async function prepare() {
      await Promise.all([
        loadFonts(),
        hydrateAuthStore(),
        // pré-fetch des données critiques du premier écran
      ]);
      setReady(true);
    }
    prepare();
  }, []);

  const onLayoutRoot = async () => {
    if (ready) {
      await SplashScreen.hideAsync();
    }
  };

  if (!ready) return null;

  return <RootNavigator onLayout={onLayoutRoot} />;
}

Un splash natif (image statique dans ios/Assets.xcassets/SplashScreen.imageset et android/app/src/main/res/drawable) démarre immédiatement, avant même que la VM JS soit initialisée. Un splash « React », c'est-à-dire un composant qui simule un splash, arrive après le premier rendu, donc perd tout l'intérêt. Vérifiez toujours qu'il n'y a pas de <View style={{backgroundColor: 'white'}}> dans MainActivity qui casserait l'effet. J'ai perdu deux heures à chasser ce bug sur une app cliente le mois dernier.

Pour aller plus loin sur l'instrumentation, notre guide React Native DevTools détaille comment tracer précisément le moment du hideAsync().

Différer les modules natifs et SDK tiers

Sentry, Firebase, RevenueCat, Amplitude, Segment… chacun ajoute 50 à 200 ms d'init au démarrage s'ils sont appelés en top-level. Sur une app avec 6 SDK, j'ai mesuré 780 ms cumulés à l'ouverture. La solution : déférer tout ce qui n'est pas requis pour peindre le premier écran.

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

export function deferredInit() {
  InteractionManager.runAfterInteractions(() => {
    // Ces SDK s'initialisent APRÈS le premier frame
    require('@sentry/react-native').init({ dsn: SENTRY_DSN });
    require('@amplitude/analytics-react-native').init(AMPLITUDE_KEY);
    require('./analytics/segment').setup();
  });
}

// App.tsx
useEffect(() => { deferredInit(); }, []);

La combinaison InteractionManager.runAfterInteractions et require() dynamique donne deux gains : le module n'est ni parsé ni initialisé au démarrage. Pour Sentry précisément, activez leur mode manual setup avec autoStart: false. Vous gardez la capture d'erreurs natives via l'auto-init du wrapper natif sans payer le coût du client JS au boot.

Impact de la New Architecture sur le démarrage

Depuis React Native 0.76, la New Architecture (Fabric + TurboModules + Bridgeless) est activée par défaut sur les nouveaux projets. Côté démarrage, les gains ne sont pas magiques. Fabric rend le premier frame environ 15 à 25 % plus vite grâce au rendu synchrone C++, mais le boot des TurboModules ajoute 30 à 80 ms si vous n'avez pas activé le lazy loading (lazyTurboModules: true).

Bridgeless supprime le bridge asynchrone historique et remplace l'init du ReactContext par un ReactHost plus léger : gain net d'environ 120 ms sur mes benchmarks Android. Ça vaut la migration, mais uniquement après avoir profilé. Si votre app fait ses 800 ms de cold start dans le parsing JS, changer d'architecture ne fera rien pour vous. Optimisez d'abord ce qui pèse. J'analyse les mesures avant/après en détail dans notre article dédié à la migration vers la New Architecture.

Pour vérifier ce qui traîne côté natif au démarrage, activez la trace ReactMarker. Chaque événement (CREATE_CATALYST_INSTANCE_START, RUN_JS_BUNDLE_START, CONTENT_APPEARED) est horodaté. Sur Android : adb logcat -s ReactNative:V | grep Marker. La documentation officielle de React Native sur le threading model détaille chaque phase.

Pourquoi mon app React Native est-elle lente au démarrage sur Android ?

Quatre coupables classiques, dans l'ordre de fréquence que je rencontre. Premièrement, Hermes désactivé : la moitié des apps que j'audite l'ont explicitement remis à false dans un vieux build.gradle. Deuxièmement, inlineRequires jamais activé, donc tout le bundle s'exécute au boot. Troisièmement, un index.android.bundle obèse (plus de 8 Mo) par import d'une lib entière au lieu d'un sous-module (typique de lodash vs lodash/get). Quatrièmement, un splash « React » au lieu d'un splash natif.

Un cinquième coupable moins connu : le MainApplication.onCreate() qui fait du travail synchrone lourd. Je vois régulièrement des Firebase.initializeApp(), des DataStore Preferences ou même des requêtes réseau bloquantes ici. Déplacez tout ce qui n'est pas strictement nécessaire dans un WorkManager ou une Coroutine lancée après le premier onCreate de l'activité.

Enfin, Android déclenche des ANR (Application Not Responding) si le thread principal reste bloqué plus de 5 secondes. Une app qui frôle cette limite au P95 en production sera classée « slow app start » par Play Vitals, ce qui affecte votre visibilité dans le Store. Utilisez la trace StrictMode pour repérer les I/O sur le main thread. C'est le premier réflexe que j'ai après une régression de démarrage.

Erreurs fréquentes qui gonflent le cold start

La liste des tirs dans le pied que j'ai vus le plus souvent en 2026 : imports globaux de moment ou dayjs/locale qui traînent 300 Ko de locales, animations Reanimated 3 démarrées dans componentDidMount du root (elles bloquent le premier frame), redux-persist avec un storage non async qui synchronise 2 Mo de JSON avant le premier rendu, et react-native-mmkv mal instancié en top-level du store (l'ouverture du fichier coûte 15 à 40 ms sur bas de gamme).

Sur les listes, un FlatList avec initialNumToRender=20 peut mesurer et layout 20 cellules avant le premier frame. Passez à 4 ou 5, ou migrez vers FlashList (voir notre comparaison FlashList v2). Une seule image require() non compressée en résolution 3x (par exemple un logo splash de 2 Mo) peut ajouter 80 ms de décodage au premier frame. Passez toujours par expo-image avec préchargement.

OptimisationGain typiqueEffortRisque
Activer Hermes–350 à –700 msFaibleFaible
inlineRequires–150 à –300 msFaibleFaible
Splash natif correct–100 à –250 ms (perçu)MoyenFaible
Défer SDK tiers–200 à –500 msMoyenMoyen
Migration Bridgeless–80 à –150 msÉlevéMoyen
Optimisation images–40 à –150 msMoyenFaible

Questions fréquentes

Quel est un bon temps de démarrage pour une app React Native en 2026 ?

Visez moins de 1,5 seconde en cold start P75 sur un appareil bas de gamme (Redmi 9A, iPhone SE 2020). En dessous de 1 seconde, l'app est perçue comme instantanée ; au-dessus de 3 secondes, Google Play Vitals marque l'app comme lente.

Hermes est-il toujours plus rapide que JavaScriptCore ?

Oui pour le démarrage, puisque Hermes charge du bytecode précompilé alors que JSC parse du texte JS. À l'exécution soutenue, JSC (avec JIT) peut être 10 à 20 % plus rapide sur des workloads CPU-intensives, mais depuis React Native 0.76 seul Hermes est supporté sur la New Architecture.

Faut-il désactiver le remote debugging pour mesurer le TTI ?

Toujours. Le remote debugging exécute votre JS dans Chrome via WebSocket, ce qui ajoute 40 à 60 % de latence artificielle. Les mesures de démarrage doivent se faire sur un build release signé, jamais en dev, jamais avec DevTools attaché.

Comment mesurer le TTI en production sans dégrader les performances ?

Utilisez Sentry Performance ou Firebase Performance Monitoring : ils échantillonnent 1 à 5 % du trafic et écrivent des traces asynchrones. Ajoutez un événement custom app_ready après le premier onLayout et comparez au timestamp app_launch natif fourni par le SDK.

Pourquoi le premier lancement est-il plus lent que les suivants ?

Le cold start (processus non en cache) peut être 2 à 3 fois plus lent qu'un warm start. Android précharge des pages via le PageCache et iOS garde la VM Hermes dormante en mémoire. C'est normal : optimisez le P75 cold start, pas le warm start.

Carlos Mendoza
À propos de l'auteur Carlos Mendoza

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