A render count in the console tells you one thing: something re-rendered.
It won't tell you whether that render dropped a frame, or what else was fighting for the JS thread at that instant.
#The old way
import { useEffect, useRef } from 'react';
import { View, Text } from 'react-native';
function ProductCard({ title, price }: { title: string; price: number }) {
const renderCount = useRef(0);
renderCount.current += 1;
useEffect(() => {
console.log(`ProductCard rendered ${renderCount.current} times`);
});
return (
<View>
<Text>{title}</Text>
<Text>${price.toFixed(2)}</Text>
</View>
);
}
#The new way
import { useEffect } from 'react';
import { View, Text } from 'react-native';
import { performance } from 'react-native-performance';
function ProductCard({ title, price }: { title: string; price: number }) {
useEffect(() => {
performance.mark('product-card-start');
return () => {
performance.mark('product-card-end');
performance.measure('product-card-mount', 'product-card-start', 'product-card-end');
};
}, []);
return (
<View>
<Text>{title}</Text>
<Text>${price.toFixed(2)}</Text>
</View>
);
}
import { PerformanceObserver } from 'react-native-performance';
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 16) {
console.warn(`${entry.name} took ${entry.duration.toFixed(1)}ms`);
}
}
});
observer.observe({ entryTypes: ['measure'] });
The marks share a clock with native frame traces. So you can line up product-card-mount against Instruments' Time Profiler on iOS, or Perfetto on Android, and read off exactly what native work was running during a dropped frame.
#Why it matters
- A timestamped mark points at one specific dropped frame. A render count points at nothing in particular.
- Anything over roughly 16ms is one frame at 60fps, so that threshold surfaces real jank candidates and skips the noisy re-renders.
- Correlating a JS mark with a native trace tells you where the cost actually sits: your component, or a native commit underneath it.
#Gotcha
performance.now() and your marks run on the JS thread's clock. A dropped frame is a native/UI-thread event.
That gap bites. A render can measure fast in JS and still drop a frame, because it triggered an expensive synchronous native layout pass. Fabric committing a large tree does exactly that. Cross-check JS-side measures against a native trace rather than trusting JS timing alone.