Cold Start e TTI in React Native nel 2026: Come Ridurre il Tempo di Avvio

Come ridurre il cold start di React Native nel 2026 sotto i 2 secondi su Android mid-tier: Hermes V1, inlineRequires, Nuova Architettura e code splitting, con tracce reali e una checklist da applicare in ordine di ROI.

Cold Start React Native 2026: Fix TTI

Aggiornato: 7 Luglio 2026

Ottimizzare il cold start di React Native nel 2026 significa portare il Time to Interactive (TTI) sotto i 2,0s su Android di fascia media e sotto 1,2s su iPhone 13, agendo in quest'ordine: attivare Hermes V1 (default da RN 0.84), abilitare la Nuova Architettura, applicare inlineRequires per rimandare la valutazione dei moduli, e infine ridurre il bundle iniziale con code splitting. Ho profilato decine di app negli ultimi due anni, e vi dico una cosa: nessun trucco stilistico batte una traccia registrata con Systrace o Instruments prima di toccare il codice. Davvero, nessuno.

  • Il cold start medio di un'app pulita su RN 0.84 con Hermes V1 è sceso a 300–600ms su dispositivi moderni, con TTI sotto 1 secondo.
  • Hermes elimina il costo di parsing JavaScript grazie alla compilazione AOT del bytecode; questo da solo produce un guadagno del 25–40% sul cold start.
  • L'ordine di ottimizzazione conta: prima Hermes (parse), poi inlineRequires (valutazione), poi bundle size, e infine lavoro post-frame con InteractionManager.
  • Il TTI va misurato con strumenti nativi (Xcode Instruments, Android Studio System Trace) e monitorato in produzione con Sentry o Firebase Performance.
  • Un caso reale documentato ha portato il cold start da 3,4s a 1,9s su Android mid-range con quattro interventi mirati, senza riscrivere alcuna feature.
  • Ogni release deve avere un budget di performance nel CI: senza soglie automatiche, le regressioni entrano silenziosamente.

Cos'è il TTI e cosa conta come cold start

Il Time to Interactive (TTI) è il tempo che passa dal tocco dell'icona al momento in cui la prima schermata risponde all'input dell'utente in modo affidabile. Non è quando l'app "appare": una splash screen che si mostra in 400ms mentre il thread JS è ancora bloccato per altri 1500ms conta come TTI di 1,9s, non 0,4s.

In un cold start il processo è freddo, il bundle non è in memoria, i moduli nativi non sono registrati. In un warm start il processo esiste ma l'attività è distrutta. In un hot start l'attività è viva ma in background. Le ottimizzazioni descritte qui aggrediscono principalmente il cold start, cioè il caso peggiore, quello che gli utenti percepiscono come "l'app è lenta ad aprirsi".

Nel 2026 i target di riferimento sono: cold start TTI sotto 2,0s su Android mid-tier (classe Pixel 6a), sotto 1,2s su iPhone 13, latenza di interazione sotto 100ms, FPS di scroll sopra 58 al 99esimo percentile. Se il vostro budget interno non fissa questi numeri, ogni ottimizzazione è un'opinione. Io li scrivo direttamente nel package.json come metadata e li leggo da uno script CI. È un trucco stupido, ma funziona.

Come misurare il cold start prima di ottimizzare

Regola numero uno del mio lavoro: non ottimizzare niente che non compaia in una traccia del profiler. Ho visto team spendere sprint interi a rifare la state management perché "sembrava lento", quando il collo di bottiglia era un import sincrono di un modulo da 400KB. Prima di toccare qualsiasi cosa, registrate una traccia riproducibile. Punto.

Su Android, usate adb con la modalità cold-launch e leggete il TotalTime di ActivityManager:

# Kill del processo per garantire un cold start reale
adb shell am force-stop com.example.myapp

# Lancio misurato: TotalTime è il TTI approssimativo del sistema
adb shell am start -W -n com.example.myapp/.MainActivity

# Output atteso:
# Starting: Intent { cmp=com.example.myapp/.MainActivity }
# Status: ok
# LaunchState: COLD
# Activity: com.example.myapp/.MainActivity
# TotalTime: 1876
# WaitTime: 1892

Ripetete cinque volte, scartate min e max, prendete la mediana. Se non fate cinque run, state guardando rumore, non segnale (l'ho scoperto a mie spese su una PR che sembrava promettente e invece era un placebo statistico). Su iOS il metodo canonico è Xcode Instruments con il template App Launch, che segmenta il tempo tra pre-main, UIKit init e primo hitch.

Per una misura più aderente al TTI JS, aggiungete un marker manuale nel primo useEffect del root component:

// App.tsx
import { InteractionManager } from 'react-native';

export default function App() {
  useEffect(() => {
    const t0 = performance.now();
    InteractionManager.runAfterInteractions(() => {
      const tti = performance.now() - t0;
      // Inviare a Sentry / Firebase Performance
      console.log(`[perf] TTI: ${tti.toFixed(0)}ms`);
    });
  }, []);
  return <RootNavigator />;
}

Per il monitoraggio in produzione uso i tool ufficiali di profiling di React Native in sviluppo e Sentry Performance in produzione, con soglia di allerta a P75 > 2500ms. Se non tracciate il P75 di TTI in produzione, non sapete se le vostre ottimizzazioni funzionano su utenti reali. Onestamente, questa è la parte che quasi tutti saltano.

Hermes V1 e Static Hermes: la base della velocità

Hermes è il motore JavaScript sviluppato da Meta specificamente per le app React Native. Da RN 0.84 Hermes V1 è il motore predefinito e JavaScriptCore (JSC) è stato rimosso dalle build ufficiali. La differenza è misurabile: Hermes compila il vostro JavaScript in bytecode ottimizzato durante la build, non al lancio, eliminando la fase di parsing sul dispositivo. In pratica: 25–40% di cold start più rapido, ~30MB in meno di memoria, payload JS del bundle 15–25% più piccolo. Su un Redmi Note di fascia bassa il salto è ancora più netto, perché non c'è overhead di JIT compilation.

Per verificare che Hermes sia effettivamente attivo (non tutti i progetti migrati lo hanno correttamente configurato dopo l'upgrade, l'ho toccato con mano su tre app diverse) controllate il tipo runtime da JavaScript:

// Chiamare in un componente di debug
const isHermes = () => !!global.HermesInternal;
console.log('Motore JS attivo:', isHermes() ? 'Hermes' : 'JSC');

// Getters di dettaglio disponibili solo su Hermes
if (global.HermesInternal) {
  const version = global.HermesInternal.getRuntimeProperties();
  console.log('Hermes runtime:', version);
}

Nel android/gradle.properties confermate hermesEnabled=true; su iOS controllate che ENV['USE_HERMES'] nel Podfile non sia stato disattivato. Static Hermes, in beta nel 2026, compila JS AOT direttamente in codice macchina nativo per moduli critici: nelle mie prove il primo render di una schermata pesante è sceso di ulteriori 180ms su iPhone 12. È un opt-in per bundle specifici, non un'impostazione globale. La documentazione ufficiale è ancora scarna, ma vale la pena provarlo su un modulo isolato.

Nuova Architettura React Native e impatto sul TTI

La Nuova Architettura è il default da React Native 0.76, e il vecchio bridge asincrono è stato ritirato in 0.82. I dati dai casi di migrazione pubblicati nel 2025–2026 mostrano: cold start ridotto del ~40%, rendering più veloce del ~35%, memoria in calo del ~25% e latenza delle chiamate JS→nativo ridotta di ~40 volte. Non si tratta di micro-ottimizzazioni: JSI sostituisce la serializzazione JSON del bridge con chiamate sincrone dirette, Fabric elimina il thread shadow separato, e Codegen genera type-safe bindings tra JS e nativo evitando riflessione a runtime.

Per il cold start, gli effetti principali sono due. Primo: i TurboModules sono registrati lazy, quindi un'app che usa 30 moduli nativi non paga più il costo di 30 registrazioni al lancio se ne usa 3 nella prima schermata. Secondo: Fabric rende il primo commit più economico, perché la view hierarchy nativa è costruita in un solo pass, non riconciliata attraverso il bridge.

Se venite dalla vecchia architettura, la strategia di migrazione dettagliata è nel mio articolo sulla Nuova Architettura di React Native: JSI, TurboModules, Fabric e Codegen coprono l'80% dei guadagni. Verificate che la Nuova Architettura sia effettivamente attiva sul vostro binario:

# android/gradle.properties
newArchEnabled=true

# ios/Podfile
:fabric_enabled => true,
:new_arch_enabled => true

Se una libreria di terze parti non supporta ancora la Nuova Architettura, non forzate il fallback globale: usate un interop layer e migratela per ultima. Ogni fallback al bridge legacy rimette in gioco ~40× di latenza per chiamata nativa. In un progetto dell'anno scorso, un singolo modulo legacy per il video player faceva risalire il TTI di 200ms; l'abbiamo isolato con l'interop e il numero è tornato dov'era.

inlineRequires e InteractionManager: valutazione differita

Anche con Hermes e Nuova Architettura, il default di React Native è valutare l'intero bundle JS (tutti i moduli, tutte le schermate) prima di renderizzare il primo frame. Se il vostro bundle contiene 400 moduli ma la schermata iniziale ne usa 15, state pagando il costo di caricare gli altri 385. inlineRequires risolve esattamente questo: trasforma i require statici in top-level in chiamate lazy, valutate solo quando il modulo è effettivamente riferito.

Attivatelo nel metro.config.js:

// metro.config.js
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');

const config = {
  transformer: {
    getTransformOptions: async () => ({
      transform: {
        // Il fix più impattante sul cold start dopo Hermes
        experimentalImportSupport: false,
        inlineRequires: true,
      },
    }),
  },
};

module.exports = mergeConfig(getDefaultConfig(__dirname), config);

Nella mia esperienza, un'app da 25MB di bundle ha guadagnato 380ms di TTI solo attivando inlineRequires. C'è una controindicazione: alcuni moduli con side-effect al top-level (analytics, polyfill, monkeypatch) devono essere caricati esplicitamente. Il compilatore Metro emette warning per i pattern noti; leggeteli invece di ignorarli come faccio io ogni tanto.

InteractionManager è complementare: rimanda il lavoro pesante non-critico al post-frame. Codice tipico che rientra: fetch di dati non visibili, warm-up di caches, inizializzazione di SDK terzi. Il pattern:

import { InteractionManager } from 'react-native';

// Sbagliato: init sync nel primo render, blocca TTI
useEffect(() => {
  Analytics.init();       // 120ms
  CrashReporter.init();   // 90ms
  ExperimentSDK.load();   // 200ms
  prefetchUserProfile();  // 300ms
}, []);

// Corretto: 710ms spostati DOPO il primo frame interattivo
useEffect(() => {
  const task = InteractionManager.runAfterInteractions(() => {
    Analytics.init();
    CrashReporter.init();
    ExperimentSDK.load();
    prefetchUserProfile();
  });
  return () => task.cancel();
}, []);

Attenzione: se il vostro SDK di analytics ha bisogno di catturare l'evento "app_launch", posticiparlo con runAfterInteractions falsifica la sequenza. Marcate l'evento al mount e inviatelo dopo. Ho perso mezza giornata su questo, quindi ve lo dico.

Ridurre il bundle iniziale con code splitting

Un bundle più piccolo è un cold start più veloce, indipendentemente da qualsiasi altra ottimizzazione. Il tool base è react-native-bundle-visualizer, che produce una treemap del bundle Metro:

npx react-native-bundle-visualizer --platform android --dev false

Sul mio ultimo audit questo comando ha rivelato 4,2MB occupati da moment.js con tutti i locale caricati; sostituito con date-fns importato per singola funzione, il bundle è calato di 3,8MB. Bersagli tipici che compaiono nelle treemap: librerie di grafici che importano l'intero D3, SDK di pagamento che includono flow non usati, immagini in require('./huge.png') che Metro incorpora nel bundle.

Il code splitting per rotta con Expo Router o React Navigation usa dynamic import():

// Prima: import statico, caricato al bundle load
import HeavyChartsScreen from './screens/HeavyChartsScreen';

// Dopo: import dinamico, caricato solo quando si naviga alla rotta
const HeavyChartsScreen = React.lazy(() =>
  import('./screens/HeavyChartsScreen')
);

// Nella dichiarazione delle rotte
<Stack.Screen
  name="Charts"
  component={() => (
    <Suspense fallback={<LoadingSpinner />}>
      <HeavyChartsScreen />
    </Suspense>
  )}
/>

Per liste lunghe che dominano la schermata iniziale (feed, cataloghi, chat) l'ottimizzazione del render è spesso più impattante del bundle. Sto documentando pattern e benchmark nel confronto FlatList vs FlashList v2 vs Legend List. In sintesi: FlashList v2 rende il primo frame utile del 40% più rapidamente rispetto a FlatList sui dispositivi mid-tier.

Profiling nativo con Instruments e Systrace

Quando i tool JS-level dicono "il thread JS non è il collo di bottiglia", scendete al livello nativo. Non c'è alternativa: React DevTools e Reactotron non vedono cosa succede prima che il runtime JS sia inizializzato. Il tempo di pre-main su iOS, la chiusura del PackageManager su Android, il warm-up della GPU: sono tutti invisibili sopra la JVM/UIKit.

Su Android Android Studio System Trace (o Perfetto a riga di comando) cattura una traccia perfetta per analizzare l'avvio:

# Registrare 8 secondi di traccia con Perfetto
adb shell am force-stop com.example.myapp
adb shell perfetto -c - --txt -o /data/misc/perfetto-traces/trace \
  << 'EOF'
buffers { size_kb: 63488 }
data_sources {
  config {
    name: "linux.ftrace"
    ftrace_config { ftrace_events: "sched/sched_switch" }
  }
}
duration_ms: 8000
EOF
adb shell am start -n com.example.myapp/.MainActivity
adb pull /data/misc/perfetto-traces/trace ./trace.pftrace

Apritelo su ui.perfetto.dev e cercate il track di com.example.myapp: vedrete esattamente dove il main thread si blocca. Nel 70% dei casi che ho analizzato, il collo di bottiglia era una libreria terza che parseva JSON di configurazione al lancio. Sì, davvero.

Su iOS, Xcode Instruments con il template App Launch mostra tre fasi separate: pre-main, UIKit initialization, first frame. Il template Time Profiler approfondisce il singolo hitch. Callstack ufficiali su come leggere queste tracce sono documentati nella guida di Apple alla riduzione del launch time, che consiglio di leggere una volta e tenere aperta.

Perché la mia app React Native è lenta all'avvio?

Questa è la domanda che ricevo più spesso, e la risposta è quasi sempre una delle sette cause qui sotto, in ordine di frequenza dai miei audit:

  1. Hermes non è attivo: verificate global.HermesInternal a runtime. In 3 audit su 10, la migrazione era incompleta.
  2. inlineRequires disabilitato: cercate inlineRequires: true nel vostro metro.config.js. Se manca, aggiungetelo prima di qualsiasi altra cosa.
  3. SDK di analytics/crash-reporting inizializzati al mount: spostateli in InteractionManager.runAfterInteractions.
  4. Splash screen persistente troppo a lungo: se rimuovete la splash prima che il primo frame JS sia pronto, l'utente vede uno sfarfallio. Se aspettate troppo, il TTI perceived è alto. Il punto giusto è onLayout del root component.
  5. Bundle sopra i 5MB: girate react-native-bundle-visualizer. Se non lo avete mai fatto, tagliate almeno il 25% al primo tentativo.
  6. Immagini locali non ottimizzate incorporate nel bundle: require('./logo.png') con un PNG da 800KB blocca il caricamento del bundle. Usate WebP e expo-image con contentFit.
  7. Fetch sincrono di configurazione al primo render: await getRemoteConfig() in useEffect senza fallback locale trasforma un cold start in una connessione di rete cattiva.

Se nessuno di questi si applica, avete probabilmente un modulo nativo che blocca il main thread durante [RCTBridge init] (iOS) o ReactApplicationContext (Android). L'unico modo per trovarlo è una traccia nativa. Nessuna scorciatoia.

Checklist di ottimizzazione cold start per il 2026

Riporto qui la checklist che uso in ogni audit di performance, in ordine di ROI decrescente. Applicate un intervento alla volta e ri-misurate: se un cambiamento non produce un delta misurabile nella traccia, revertelo senza pensarci.

  1. Aggiornare a React Native ≥ 0.84 con Hermes V1 e Nuova Architettura abilitati.
  2. Attivare inlineRequires: true nel metro.config.js.
  3. Spostare tutti gli init di SDK terzi in InteractionManager.runAfterInteractions.
  4. Eseguire react-native-bundle-visualizer; ridurre il bundle sotto i 2,5MB prima di qualsiasi altra ottimizzazione.
  5. Convertire le rotte non-critiche a React.lazy con Suspense.
  6. Sostituire liste con FlashList v2 dove ci sono più di 50 elementi al primo render.
  7. Registrare una traccia con Xcode Instruments (iOS) e Perfetto (Android), identificare i 3 hitch più lunghi.
  8. Introdurre un budget di TTI nel CI: build failed se P75 > 2500ms su Pixel 6a.
  9. Monitorare in produzione con le linee guida ufficiali sulla Nuova Architettura e con Sentry Performance in P75.
  10. Rivedere il budget ogni release: le regressioni entrano quando smettete di guardare.

Domande frequenti

Qual è un cold start accettabile per un'app React Native nel 2026?

Il target di riferimento è TTI < 2,0s su Android mid-tier (Pixel 6a) e < 1,2s su iPhone 13. Un'app pulita su RN 0.84 con Hermes V1 e Nuova Architettura tipicamente raggiunge 300–600ms di cold start e sotto 1s di TTI su dispositivi moderni.

Hermes rende sempre l'app più veloce rispetto a JSC?

Nella maggior parte dei casi sì: Hermes elimina il costo di parsing grazie al bytecode AOT e riduce cold start del 25–40% e memoria di ~30MB. L'unico caso in cui JSC poteva competere era per app con carichi JS di lunga durata e bassa allocazione, ma nel 2026 JSC è stato rimosso dalle build ufficiali React Native.

Cosa fa esattamente inlineRequires e ha effetti collaterali?

inlineRequires trasforma i require statici in chiamate lazy valutate solo al primo utilizzo del modulo. L'effetto collaterale principale riguarda moduli con side-effect al top-level (polyfill, analytics, monkeypatch): questi vanno importati esplicitamente in un file di bootstrap perché la loro esecuzione non è più garantita al bundle load.

Come misuro il TTI in produzione senza rallentare l'app?

Usate un marker manuale nel primo useEffect del root component combinato con InteractionManager.runAfterInteractions, e inviate il valore a Sentry Performance o Firebase Performance. L'overhead è < 5ms e permette di tracciare il P75 di TTI per versione, dispositivo e paese senza SDK dedicati.

Il code splitting con React.lazy funziona con la Nuova Architettura?

Sì, React.lazy + Suspense funzionano nativamente su Fabric e sono raccomandati per rotte pesanti. L'unica cautela è evitare Suspense boundary troppo alti nell'albero: se il fallback rimpiazza l'intera schermata, l'utente percepisce un flash. Posizionate il boundary il più vicino possibile al componente lazy.

Carlos Mendoza
Sull'Autore Carlos Mendoza

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