A feed that runs clean for the first screen and then snags is one cell doing too much on the main thread as it scrolls into view: decoding an image, re-solving layout, or formatting text before the next frame is due. Which cause it is comes straight off an FPS trace read beside the CPU timeline.
It glides for a screen, then catches
You flick down a feed and it moves cleanly for the first few rows, then it snags, drops a beat, and jumps forward to catch up. It gets worse the faster you scroll and the further down the list you go. On your phone the stutter is barely there, but a tester on an older device calls it unusable. You already confirmed cells are being reused, and that changed nothing.
Every recycled cell is doing main-thread work
The scroll itself is cheap. What costs you is the work each cell does as it comes into view, on the main thread, inside the window where the next frame is due.
Three causes account for most of it.
Synchronous image decode is the common one. When you hand an image view a UIImage backed by compressed JPEG, PNG, or HEIC data, UIKit defers the decode until display time, and that decode runs on the main thread as the cell scrolls in. Full-resolution images downsampled to a thumbnail are the worst case: you pay the decode and the resize on every row. Decode and downsample off the main thread instead, with CGImageSourceCreateThumbnailAtIndex, or by calling the synchronous UIImage.preparingForDisplay() from a background queue, or its async prepareForDisplay(completionHandler:) form which decodes off the main thread for you, then hand back a ready image. SwiftUI’s AsyncImage fetches off the main thread but its decode is opaque and never downsampled, so its hitch is the oversized textures and the layout shift when the placeholder swaps for the image.
Self-sizing layout is the second. Auto Layout re-solves the constraint system for every self-sizing cell as it appears, and a deep constraint hierarchy makes each solve expensive. In SwiftUI, an unstable ForEach identity or heavy layout inside a LazyVStack forces the same re-measurement per row.
The third is work you buried in the render path: allocating a DateFormatter per row (both the allocation and the configuration are costly), building an AttributedString with data detectors for links and mentions, or parsing numbers, all inside cellForRowAt or a SwiftUI body. Do it once when the model loads and cache the result.
One more to rule out is offscreen rendering: unrasterized shadows without a shadowPath, or corner radius with masksToBounds, push Core Animation into offscreen passes. The render server then misses vsync and you drop frames while the CPU looks idle, which is a different signature in a trace.
React Native lands in the same place through FlatList. A plain ScrollView renders every row up front with no virtualization. A FlatList with a heavy renderItem, no React.memo on the item, inline closures minting fresh props each render, and no getItemLayout to skip measurement drops frames on the JS thread the same way image decode drops them on iOS.
The FPS trace tells you which cause it is
Start local. Instruments’ Time Profiler shows you main-thread hot paths, and the Animation Hitches template flags the exact frames that missed. On React Native, the Perf Monitor separates JS FPS from UI FPS. Reach for those first.
The problem is that the stutter usually will not reproduce where you can attach them. Scroll jank is device-dependent: your dev machine runs a fast simulator or a current phone with frame-time headroom, so a hitch that costs a few milliseconds hides. It bites on the older device your user has, or under a 120Hz budget where each frame is 8.3ms and there is no slack. And reproducing it needs a long list with real data volume and a repeatable scroll gesture, because flicking by hand never lines two runs up.
What settles it is a durable runtime record, and on that record the FPS graph reads the problem directly. It sits flat at the refresh rate, then drops on a cliff the instant the scroll step fires, and the depth and cadence of that dip already narrow the cause. Put the CPU trace beside it. Frames dropping while the main thread spikes on a per-row cadence is decode or layout, and the spikes line up with the row height on the same timeline. Frames dropping while the main thread is not the busy one point past your code at the GPU, the offscreen-rendering case. The network waterfall rules a fetch in or out: a clean waterfall through the dip means the frames are going to local formatting rather than a stalled image request.
One limit bounds where that record can come from. A cloud simulator or emulator runs on the host machine’s silicon, not a phone’s, so per-cell work that blows a phone’s 8.3ms budget can run well under it on a fast host and never drop a frame. What a scripted run captures cleanly is which cell work is heavy on the CPU trace, on a chosen model and OS, as a repeatable record you can share; the frame-budget verdict itself you still take to Instruments on the physical device where it stutters.
Script the scroll on a cloud device
Instruments is the right first stop when the jank reproduces on hardware you hold. When it does not, or when you want a record the whole team can open, this is the runtime-debugging side: boot the model and OS build your tester was on, install your build, load the list at real volume, and drive the scroll as a scripted step so every run lines up. The tweetie demo is the worked example, a SwiftUI feed with synchronous work planted in every cell body, heavy enough to blow the budget even on a fast host, behind an on-screen jank toggle: flip the toggle on, run the scroll flow, and the FPS trace dips exactly where the CPU trace lights up the main thread. Run it as an agent test to collect the report from a cloud device, or open a live session and scroll it by hand to feel the hitch. What the cloud device gives you is the repeatable gesture, the chosen OS and model, and a time-aligned trace.
revyl auth login
revyl build --platform ios
revyl test run "scroll-timeline" --build
# or reproduce the scroll by hand in a live session
revyl device start --platform ios