iosNudge

A list scrolls with stutters. How do you find and fix the problem?

MediumAsked oftenPerformanceUIKit 2 min read

TL;DR

Stutters are hitches: a frame is not ready in time. At 60 Hz you have about 16.7 ms per frame. At 120 Hz (ProMotion), about 8.3 ms. Common causes: heavy cellForRowAt, image decoding on the main thread, complex Auto Layout, and offscreen rendering (shadows, masks). Process: measure with Instruments, fix the biggest cause, measure again.

Step 1: measure

  • Instruments > Animation Hitches: shows each hitch and whether it came from your app (commit phase) or the render server.
  • Time Profiler: shows which functions run on the main thread while scrolling.
  • Test on an older, real device in a Release build.
  • Simulator numbers are not reliable for this.

Step 2: common causes and fixes

1. Too much work when configuring a cell

// Bad: formatter created and text parsed for every cell
func configure(with post: Post) {
    let formatter = DateFormatter()
    formatter.dateStyle = .medium
    dateLabel.text = formatter.string(from: post.date)
    bodyLabel.attributedText = try? NSAttributedString(markdown: post.body)
}

Fix: reuse formatters, and prepare display data before scrolling, in the view model.

struct PostRow {
    let title: String
    let dateText: String         // already formatted
    let body: NSAttributedString // already parsed
}

2. Image decoding on the main thread

Setting a large JPEG on an image view decodes it on the main thread at display time. A big photo can take many milliseconds.

// Decode and resize off the main thread (iOS 15+)
let thumbnail = await image.byPreparingThumbnail(ofSize: CGSize(width: 120, height: 120))
imageView.image = thumbnail

Also cache the result. Do not decode the same image again for each reuse.

3. Offscreen rendering

Some effects force the GPU to draw into an extra buffer. Shadows without a path are a classic example.

// Bad: the system must compute the shadow shape every frame
cardView.layer.shadowOpacity = 0.2

// Good: give the shape
cardView.layer.shadowPath = UIBezierPath(roundedRect: cardView.bounds, cornerRadius: 12).cgPath

Set shadowPath in layoutSubviews so it follows size changes. Masks and shouldRasterize used wrongly can also hurt.

4. Expensive layout

  • Deep nested stack views in every cell.
  • Many constraints added and removed during configuration.
  • Self-sizing cells that measure complex text.

Fix: flatter hierarchies, activate constraints once in init, and only change constants or isHidden later. Give a good estimatedRowHeight (or estimated item size) so the scroll bar does not jump.

5. Prefetching

extension FeedViewController: UICollectionViewDataSourcePrefetching {
    func collectionView(_ cv: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) {
        for path in indexPaths { imageLoader.prefetch(items[path.item].imageURL) }
    }
    func collectionView(_ cv: UICollectionView, cancelPrefetchingForItemsAt indexPaths: [IndexPath]) {
        for path in indexPaths { imageLoader.cancel(items[path.item].imageURL) }
    }
}

Start downloads before cells appear. Cancel them when the user scrolls the other way.

6. Main thread blocked by other work

JSON parsing, database queries or reloadData() on every small change. Move heavy work off the main actor. Use a diffable data source with reconfigureItems instead of reloading everything.

SwiftUI lists

The same ideas apply:

  • Use List or LazyVStack, not VStack, for long content.
  • Stable IDs in ForEach.
  • Keep row body cheap. Pre-format text in the model.
  • Downsample images before display.
  • Use the SwiftUI instrument in Xcode 26 to find long body updates.

Common follow-up questions

  • What is the render server? A separate system process that composites layers on the GPU. A hitch can happen in your app (commit) or there (render).
  • Does cornerRadius cause offscreen rendering? With masksToBounds it can. It is cheaper than it used to be, so measure. Complex masks and shadows without a path are usually the bigger problem.
  • What is shouldRasterize? It caches a layer as a bitmap. Good only for static content. If the content changes, it re-renders every time and gets slower.
  • How do you find hitches in production? MetricKit reports scroll hitch rates (MXAnimationMetric).