What the problem actually is
The feed looks fine in simulator.
It looks acceptable in code review.
Then on a real device, scroll feels sticky. Frames drop during fast movement. Cells arrive a moment too late. The feed technically works, but it does not feel stable.
That is scroll jank.
In image-heavy feeds, the cause is usually not one huge mistake. It is several small pieces of work landing on the frame budget at the same time:
- image decode
- image resizing
- layout recalculation
- text measurement
- cache misses
Why teams usually misdiagnose it
The first guess is often “we need more caching”.
Sometimes the first guess is “SwiftUI is slow” or “Auto Layout is slow”.
Those guesses can be directionally true, but they are usually too vague to fix the real bottleneck.
The better question is:
Which work is happening too late for the frame the user is currently seeing?
That changes the problem from general performance anxiety into pipeline timing.
The implementation boundary that matters
The key boundary is between work that must happen before a cell is visible and work that can happen earlier, later, or elsewhere.
If decode and resizing happen only after the cell is already on screen, scroll pays for it.
If the view system has to keep recomputing dimensions because image size is unknown, scroll pays for it.
If cache policy stores original oversized assets but not display-ready variants, scroll pays for it again and again.
The user does not care which subsystem caused the hitch.
They only feel that the feed did not keep up with the finger.
A concrete pattern to fix it
The most reliable pattern is:
- Pre-size media using known layout dimensions.
- Decode off the main thread.
- Cache display-ready variants, not only original assets.
- Measure feed readiness and scroll hitch at visible user moments.
This is simplified pseudocode, not production code.
struct ImageRequestKey: Hashable {
let url: URL
let targetSize: CGSize
let scale: CGFloat
}
final class FeedImagePipeline {
func image(for key: ImageRequestKey) async throws -> UIImage {
if let cached = displayCache[key] {
return cached
}
let data = try await networkClient.data(for: key.url)
let decoded = try await decoder.decodeDownsampledImage(
from: data,
targetSize: key.targetSize,
scale: key.scale
)
displayCache[key] = decoded
return decoded
}
}
Notice what the cache key includes.
It is not just the URL.
It includes the display target, because that is the user-visible cost you are trying to stabilize.
How to verify the fix
Do not verify this by feel alone.
Use:
- Core Animation or scroll hitch metrics
- Time Profiler for main-thread spikes
- signposts around image decode and binding
- testing on lower-powered real devices
Then check visible user moments:
- first feed readiness
- first fast flick after initial load
- repeated revisit of an already viewed segment
If scroll is still paying for decode or size negotiation after the fix, the pipeline boundary is still wrong.
What still goes wrong in production
Teams often cache the original full-size image and assume the hard work is done. It is not, if every display still resizes or decodes at render time.
Another common mistake is mixing dynamic cell height calculation with late image size discovery, so layout churn continues after the cell becomes visible.
The third is measuring averages only. Average frame time can look acceptable while visible hitching remains terrible.
The better contract is:
The feed should render display-ready media with known size, decoded before the visible moment that depends on it.
That is what reduces scroll jank in real image-heavy iOS feeds.