Mobile performance lab
16.7 Milliseconds
A phone at 60 Hz gets 16.7 ms to draw each frame. Scroll a 10,000-card feed and see who spends the budget.
Authored model, not a measurement of your device
60 Hz: 90 of 90 frames dropped on screen. Not smooth yet at 60 Hz.
Modelled phone
Budget per frame: 16.7 ms
React Native: Performance Overview (opens in a new tab)Android Developers: Slow rendering (opens in a new tab)web.dev: Rendering performance (opens in a new tab)
- Garden crew · 1 dLost and found: a blue umbrellaCard #1 · 220 reactions
- Garden crew · 2 dMorning run: 5 km, every pace welcomeCard #2 · 67 reactions
- Building team · 2 dRooftop garden needs volunteers this weekendCard #3 · 80 reactions
- Chess club · 10 hPhoto walk along the shore on SaturdayCard #4 · 364 reactions
- Garden crew · 2 dSoup night: bring your own bowlCard #5 · 157 reactions
- Neighbours · 1 dMorning run: 5 km, every pace welcomeCard #6 · 273 reactions
- Garden crew · 36 minLibrary opens an hour later on MondayCard #7 · 308 reactions
- Building team · 1 dWater is off for an hour on TuesdayCard #8 · 390 reactions
A fictional feed of 10,000 generated cards. This page always virtualises it in your browser, so the page stays smooth. The timeline models what a phone would do with the costs you switch on. The strip above the feed stands in for the header: it shows the top card and runs on the header’s thread, so a JS-driven header stops while JS is busy and the list keeps scrolling.
Frame timeline
JankyGoal: Get the standard fling to zero dropped frames at 60 Hz, then at 120 Hz, changing as few switches as you can. The feed must still open in under a second.
Not smooth yet at 60 Hz.Switches changed from the first draft: 0
Scroll the phone feed to record a gesture (up to 4 seconds).
- Drops the viewer sees
- 90 / 90
- JS frames missed
- 90
- UI frames missed
- 32
- Worst JS frame
- 93.2 ms559%
- Worst UI frame
- 30.6 ms184%
What spent the budget
Frame 1 / 90 · 0.00 sdropped on screen
JS threadOver budget
23.2 of 16.7 ms · 139%
- Scroll event and list window1.2 ms
- Render new rows · 1 row1.4 ms
- Row layout events · 1 row1.6 ms
- Re-render the screen for header state0.8 ms
- Re-render every mounted row · 121 rows18.2 ms
UI threadOver budget
16.70 of 16.67 ms · 100.2%
- Draw and composite2.8 ms
- Create native views · 1 row0.7 ms
- Measure new rows · 1 row2.4 ms
- Upload photos on first draw · 1 row10.8 ms
Over by only 0.03 ms, and still dropped: a frame that misses its deadline is not shown late, it is not shown at all. Android Developers: Slow rendering (opens in a new tab)
Opening the feed: 10 rows rendered before the first frame: 30 ms of JS, then 98.6 ms of UI work, 129 ms in all.
Costs
Janky90 of 90 frames dropped
Each switch adds one cost to the feed. Switch off as few as you can.
A ScrollView renders all its children at once. Scrolling is cheap once it is open, but opening costs 10,000 rows, and every parent re-render reaches all of them. FlatList renders rows as they approach and removes those far off screen.
React Native: ScrollView (opens in a new tab)React Native: Optimizing FlatList Configuration (opens in a new tab)
The draft hands FlatList an inline renderItem, so every screen re-render gives it new props and every mounted row renders again. FlatList is a PureComponent: with stable props it would skip the update. React.memo re-renders a row only when its own props change; the check itself still costs a little per row.
React Native: FlatList (opens in a new tab)React Native: Optimizing FlatList Configuration (opens in a new tab)
Decoded at 4 bytes per pixel, one photo takes about 45.8 MB instead of 1 MB for a card-sized one, and its first draw uploads all of it to the GPU. Send a thumbnail no bigger than the slot.
Android Developers: Loading large bitmaps efficiently (opens in a new tab)Android Developers: Slow rendering (opens in a new tab)React Native: Optimizing FlatList Configuration (opens in a new tab)
The next page lands mid-fling. The model charges 70 ms to JSON.parse all 2,000 cards in one go: one unbroken task, longer than four frames at 60 Hz. web.dev counts any task over 50 ms as a long task. In the model, pages of 50 cards (2.5 ms) keep each parse inside one frame.
web.dev: Optimize long tasks (opens in a new tab)React Native: Performance Overview (opens in a new tab)
Each scroll event sets state, so the screen re-renders on the JS thread every frame and the header freezes whenever that thread is busy. With the native driver (animating transform, not height) the header runs on the UI thread, and a blocked JS thread no longer shows.
React Native: Performance Overview (opens in a new tab)React Native: Animations, using the native driver (opens in a new tab)
The list learns each new row’s height from a layout pass and a layout event sent back to the JS thread. This is not the web’s layout thrashing, where reads force synchronous layouts in a loop: the list measures asynchronously. It is still layout work for every new row, and fixed-height rows with getItemLayout skip it.
React Native: Performance Overview (opens in a new tab)React Native: Optimizing FlatList Configuration (opens in a new tab)web.dev: Avoid large, complex layouts and layout thrashing (opens in a new tab)
Model and limits
- Every cost is an authored constant for a mid-range phone, chosen to show the shape of the problem. None of them is a measurement. A frame over budget by any amount counts as dropped, as on Android, so a few frames land just over the line.
- Each lane starts work on a vsync. A lane that is still busy at the next vsync skips that frame, and its input waits, coalesced, for the next free frame.
- The viewer sees a drop when the UI thread misses a frame, and the whole screen stops, or when the JS thread misses one while the header is animated from JS, and the header stops while the list keeps scrolling. Native scrolling does not wait for JS.
- Rows render as they enter the FlatList window (21 screens, the default), and rows scrolled past the window are never rendered. The real list renders up to 10 new rows per batch (maxToRenderPerBatch), 50 ms apart (updateCellsBatchingPeriod). The model spreads that rate over frames, with at most one full batch at once. Scroll faster than that and the screen shows blank rows, as FlatList does.
- Image cost is charged when a card is first drawn on screen with its content, because uploading a large bitmap to the GPU can take several milliseconds. A blank row costs nothing until it fills in. Decoding is not charged: the model assumes it happens on a background thread.
- Not modelled: network time, garbage collection, the old bridge versus JSI, thermal throttling and memory limits.
Sources
- React Native: Performance Overview (opens in a new tab)
- web.dev: Rendering performance (opens in a new tab)
- Android Developers: Slow rendering (opens in a new tab)
- React Native: Optimizing FlatList Configuration (opens in a new tab)
- React Native: FlatList (opens in a new tab)
- React Native: ScrollView (opens in a new tab)
- React Native: Animations, using the native driver (opens in a new tab)
- web.dev: Optimize long tasks (opens in a new tab)
- web.dev: Avoid large, complex layouts and layout thrashing (opens in a new tab)
- Android Developers: Loading large bitmaps efficiently (opens in a new tab)
- React Native: About the New Architecture (opens in a new tab)
Why this lab
Umut builds React Native and Expo apps, including the Pometop mobile app, Ritmiva and Bumble, his social travel app. This lab lays out the frame budget every screen in apps like these has to fit into. The numbers are not traces from those apps.