Skill: Measure TTI (Time to Interactive)

May 25, 2026 · View on GitHub

Set up performance markers to measure app startup time and track TTI improvements.

Quick Command

npm install react-native-performance
// Mark when screen is interactive
import performance from 'react-native-performance';

useEffect(() => {
  performance.mark('screenInteractive');
}, []);

When to Use

  • App startup feels slow
  • Need baseline metrics for optimization
  • Setting up performance monitoring
  • Comparing TTI across releases

Prerequisites

  • react-native-performance library (recommended)
npm install react-native-performance

Note: This skill involves visual timeline diagrams and profiler output. Use agent-device for cold-start evidence; install it through the environment's approved/trusted path or ask the user if verification needs it and it is missing. Timeline interpretation may still require exported metrics or human review.

Understanding TTI

Time to Interactive: Time from app icon tap to displaying usable content.

Startup Types

TypeDescriptionMeasure?
ColdApp not in memory, full init✅ Yes
WarmProcess exists, activity recreated❌ Skip
HotApp in background, resumed❌ Skip
Prewarmed (iOS)iOS pre-initialized app❌ Filter out

Only measure cold starts for consistent metrics.

React Native Startup Pipeline

TTI Warm Start Diagram

The diagram shows a warm start (app was in memory):

UI Thread:

  1. init native processinit native app
  2. Gap while user is away (e.g., "5h break from using the app")
  3. JS bundle loadRootView render

JS Thread (runs in parallel):

  • init entrypointregisterComponent

Pipeline markers:

1. Native Process Init     (nativeLaunchStart → nativeLaunchEnd)
2. Native App Init         (appCreationStart → appCreationEnd)  
3. JS Bundle Load          (runJSBundleStart → runJSBundleEnd)
4. RN Root View Render     (contentAppeared)
5. React App Interactive   (screenInteractive) ← This is TTI

Step-by-Step Implementation

1. Detect Cold Start

iOS (Swift):

let isColdStart = ProcessInfo.processInfo.environment["ActivePrewarm"] != "1"

Android (Kotlin):

class MainApplication : Application() {
    var isColdStart = false
    
    override fun onCreate() {
        super.onCreate()
        
        var firstPostEnqueued = true
        Handler().post { firstPostEnqueued = false }
        
        registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
            override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
                unregisterActivityLifecycleCallbacks(this)
                if (firstPostEnqueued && savedInstanceState == null) {
                    isColdStart = true
                }
            }
            // ... other callbacks
        })
    }
}

2. Check Foreground State

Only measure when app starts in foreground.

iOS:

var isForegroundProcess = false

override func application(_ application: UIApplication, 
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    if application.applicationState == .active {
        isForegroundProcess = true
    }
    return true
}

Android:

private fun isForegroundProcess(): Boolean {
    val processInfo = ActivityManager.RunningAppProcessInfo()
    ActivityManager.getMyMemoryState(processInfo)
    return processInfo.importance == IMPORTANCE_FOREGROUND
}

3. Set Up Performance Markers

Using react-native-performance:

Native (iOS):

import ReactNativePerformance

RNPerformance.sharedInstance().mark("appCreationStart")
// ... app init ...
RNPerformance.sharedInstance().mark("appCreationEnd")

Native (Android):

import com.oblador.performance.RNPerformance

RNPerformance.getInstance().mark("appCreationStart")
// ... app init ...
RNPerformance.getInstance().mark("appCreationEnd")

4. Mark Screen Interactive (JavaScript)

import performance from 'react-native-performance';

export default function HomeScreen() {
    useEffect(() => {
        // Mark when meaningful content is displayed
        performance.mark('screenInteractive');
    }, []);
    
    return <TabNavigator />;
}

5. Collect and Report Metrics

import performance from 'react-native-performance';

const collectTTIMetrics = () => {
    const entries = performance.getEntriesByType('mark');
    
    // Calculate durations
    const metrics = {
        nativeInit: getMarkDuration('nativeLaunchStart', 'nativeLaunchEnd'),
        appCreation: getMarkDuration('appCreationStart', 'appCreationEnd'),
        jsBundleLoad: getMarkDuration('runJSBundleStart', 'runJSBundleEnd'),
        tti: getMarkDuration('nativeLaunchStart', 'screenInteractive'),
    };
    
    // Send to analytics
    analytics.track('app_performance', metrics);
};

Built-in Markers

react-native-performance provides automatic markers:

MarkerDescription
nativeLaunchStartProcess start (pre-main)
nativeLaunchEndNative init complete
runJSBundleStartJS bundle loading starts
runJSBundleEndJS bundle loaded
contentAppearedRN root view rendered

Listening to Native Events

iOS (JS Bundle Load):

NotificationCenter.default.addObserver(
    self,
    selector: #selector(onJSLoad),
    name: NSNotification.Name("RCTJavaScriptDidLoadNotification"),
    object: nil
)

Android (JS Bundle Load):

ReactMarker.addListener { name ->
    when (name) {
        RUN_JS_BUNDLE_START -> { /* mark start */ }
        RUN_JS_BUNDLE_END -> { /* mark end */ }
        CONTENT_APPEARED -> { /* mark content */ }
    }
}

Target Metrics

MetricGoodAcceptableNeeds Work
TTI< 2s2-4s> 4s
JS Bundle Load< 500ms500ms-1s> 1s
Native Init< 500ms500ms-1s> 1s

Note: Targets vary by app complexity and device tier.

Common Pitfalls

  • Including prewarmed starts: iOS prewarming skews metrics
  • Measuring warm/hot starts: Only cold starts are meaningful
  • Wrong screenInteractive placement: Mark when truly interactive, not just mounted
  • Not filtering background launches: Push notifications can start app in background