Core Web Vitals performance measurement for React Native. Get Lighthouse-style performance scores for your mobile app components.
- π Three Core Metrics β TTFF, TTI, and FID (mapped from web Core Web Vitals)
- π― Lighthouse-Style Scoring β 0-100 performance score with category ratings
- πͺ Simple Hook API β Drop-in integration with any React Native component
- π± Mobile-Optimized Thresholds β Stricter than web, calibrated for native apps
- π§ Zero Dependencies β Only requires React Native (no external packages)
- π Analytics Ready β Easy integration with any analytics service
npm install @indeed/react-native-lighthouse
# or
yarn add @indeed/react-native-lighthouse
# or
pnpm add @indeed/react-native-lighthouseimport { usePerformanceMeasurement } from '@indeed/react-native-lighthouse';
function ProductScreen({ productId }) {
const { markInteractive, panResponder, score } = usePerformanceMeasurement({
componentName: 'ProductScreen',
onReport: (metrics, score) => {
// Send to your analytics
analytics.track('screen_performance', {
screen: 'ProductScreen',
ttff: metrics.timeToFirstFrameMs,
tti: metrics.timeToInteractiveMs,
fid: metrics.firstInputDelay?.firstInputDelayMs,
score: score.overall,
});
},
});
const [product, setProduct] = useState(null);
useEffect(() => {
fetchProduct(productId).then(setProduct);
}, [productId]);
// Mark interactive when data is loaded
useEffect(() => {
if (product) {
markInteractive();
}
}, [product, markInteractive]);
return (
<View {...panResponder.panHandlers}>
{product ? <ProductContent product={product} /> : <Loading />}
</View>
);
}When users first see content β Maps to LCP (Largest Contentful Paint)
Measured from component mount to when the first frame is rendered. This tells you how quickly users see something on screen.
When users can interact β The most critical metric for mobile apps
Measured from component mount to when you call markInteractive(). Call this when your component is ready for user interaction (data loaded, UI ready).
Input responsiveness β Maps to INP/TBT (Interaction to Next Paint)
Measured automatically when users first touch the screen. Uses PanResponder to capture the delay between user input and when processing begins.
These are aspirational thresholds designed for high-performance native apps. They are stricter than official platform guidelines and based on human perception research.
| Metric | Good | Needs Improvement | Poor |
|---|---|---|---|
| TTFF | < 300ms | 300-800ms | > 800ms |
| TTI | < 500ms | 500-1500ms | > 1500ms |
| FID | < 50ms | 50-150ms | > 150ms |
Based on Jakob Nielsen's response time research, 100ms feels instantaneous to users. Our 300ms "good" threshold provides buffer while staying well under the 1-second limit where users lose their flow of thought. There is no official native mobile standard for component-level render times.
Derived from Google's app startup guidelines. Google Play considers cold starts > 5 seconds as "bad behavior", with industry best practice targeting < 2 seconds. For individual components (assuming ~4 major components per screen), 500ms keeps total screen TTI under 2 seconds.
Well-supported by academic research. Studies show users can perceive touch latency as low as 5-10ms during drag operations, and commercial devices currently have 50-200ms latency. Our 50ms threshold aligns with the upper bound of imperceptible delay.
| Source | Metric | Threshold |
|---|---|---|
| Google Play (Android Vitals) | Cold start "bad" | > 5 seconds |
| Google Play | Warm start "bad" | > 2 seconds |
| Google Play | Frozen frame | > 700ms |
| Web Core Web Vitals | LCP good | < 2,500ms |
| Web Core Web Vitals | INP good | < 200ms |
| Jakob Nielsen | "Instantaneous" | < 100ms |
| Jakob Nielsen | "Flow maintained" | < 1,000ms |
Our thresholds are intentionally stricter because:
- β Native apps have pre-bundled code (no network fetch for JS/HTML)
- β No parsing overhead (unlike web browsers)
- β Users expect native apps to feel faster than web
- β Component-level measurement (not full app startup)
If the default thresholds don't fit your use case, you can provide your own:
import { calculatePerformanceScore } from '@indeed/react-native-lighthouse';
// More lenient thresholds aligned with Google's app startup guidelines
const relaxedThresholds = {
ttff: { good: 1000, poor: 3000 },
tti: { good: 2000, poor: 5000 },
fid: { good: 100, poor: 300 },
};
const score = calculatePerformanceScore(metrics, relaxedThresholds);Metrics are combined into a single 0-100 score using weighted averages:
| Metric | Weight | Rationale |
|---|---|---|
| TTI | 45% | Mobile users expect immediate interactivity |
| FID | 30% | Touch interactions must feel instant |
| TTFF | 25% | Visual feedback matters but less than interactivity |
| Score | Category | Meaning |
|---|---|---|
| 90-100 | Excellent | Exceptional performance |
| 75-89 | Good | Solid performance |
| 50-74 | Needs Improvement | Noticeable issues |
| 0-49 | Poor | Significant problems |
Main hook for measuring component performance.
| Option | Type | Default | Description |
|---|---|---|---|
componentName |
string |
required | Name for identification in logs |
namespace |
string |
undefined |
Group prefix (e.g., 'checkout', 'profile') |
fidTimeout |
number |
5000 |
Ms to wait for FID before logging |
debug |
boolean |
__DEV__ |
Enable console logging |
onMetricsReady |
function |
undefined |
Called when metrics update |
onInteractive |
function |
undefined |
Called when markInteractive() is called |
onReport |
function |
undefined |
Called with final metrics and score |
| Property | Type | Description |
|---|---|---|
markInteractive |
() => void |
Call when component is ready for interaction |
metrics |
PerformanceMetrics | null |
Current performance metrics |
panResponder |
PanResponder |
Attach to root View for FID measurement |
score |
PerformanceScore | null |
Current Lighthouse-style score |
Calculate a performance score from metrics.
import { calculatePerformanceScore } from '@indeed/react-native-lighthouse';
const score = calculatePerformanceScore({
timeToFirstFrameMs: 250,
timeToInteractiveMs: 400,
mountStartTimeMs: 1000,
firstFrameTimeMs: 1250,
});
console.log(score);
// { overall: 95, breakdown: { ttff: 100, tti: 100, fid: 100 }, category: 'excellent' }function HomeScreen() {
const { markInteractive, panResponder } = usePerformanceMeasurement({
componentName: 'HomeScreen',
});
const [data, setData] = useState(null);
useEffect(() => {
loadHomeData().then((data) => {
setData(data);
markInteractive();
});
}, [markInteractive]);
return (
<ScrollView {...panResponder.panHandlers}>
{data ? <HomeContent data={data} /> : <Skeleton />}
</ScrollView>
);
}function CheckoutScreen() {
const { markInteractive, panResponder, score } = usePerformanceMeasurement({
componentName: 'CheckoutScreen',
namespace: 'checkout',
onReport: (metrics, score) => {
// Amplitude
amplitude.track('screen_performance', {
screen_name: 'checkout',
ttff_ms: metrics.timeToFirstFrameMs,
tti_ms: metrics.timeToInteractiveMs,
fid_ms: metrics.firstInputDelay?.firstInputDelayMs ?? null,
score: score.overall,
category: score.category,
});
// Or Firebase
analytics().logEvent('performance', {
component: 'CheckoutScreen',
score: score.overall,
});
},
});
// ... rest of component
}function SearchResults({ query }) {
const { markInteractive, panResponder } = usePerformanceMeasurement({
componentName: 'SearchResults',
});
const [results, setResults] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
setIsLoading(true);
searchAPI(query)
.then(setResults)
.finally(() => setIsLoading(false));
}, [query]);
// Mark interactive only when we have results and loading is complete
useEffect(() => {
if (!isLoading && results) {
markInteractive();
}
}, [isLoading, results, markInteractive]);
return (
<FlatList
{...panResponder.panHandlers}
data={results}
renderItem={({ item }) => <ResultItem item={item} />}
ListEmptyComponent={isLoading ? <Loading /> : <NoResults />}
/>
);
}Track performance for nested components:
function ProductDetailScreen() {
const { markInteractive: markScreenInteractive, panResponder } = usePerformanceMeasurement({
componentName: 'ProductDetailScreen',
namespace: 'product',
});
return (
<ScrollView {...panResponder.panHandlers}>
<ProductHeader />
<ProductGallery />
<ProductActions onReady={markScreenInteractive} />
</ScrollView>
);
}
function ProductActions({ onReady }) {
const { markInteractive } = usePerformanceMeasurement({
componentName: 'ProductActions',
namespace: 'product',
onInteractive: onReady, // Chain to parent
});
const [inventory, setInventory] = useState(null);
useEffect(() => {
checkInventory().then((data) => {
setInventory(data);
markInteractive();
});
}, [markInteractive]);
return <ActionButtons inventory={inventory} />;
}Full TypeScript support with exported types:
import type {
PerformanceMetrics,
PerformanceScore,
PerformanceHookResult,
UsePerformanceMeasurementOptions,
PerformanceThresholds,
MetricWeights,
} from '@indeed/react-native-lighthouse';Contributions are welcome! Please read our Contributing Guide for details.
MIT Β© [Your Name]
Built with β€οΈ for the React Native community