← All topics/Performance & optimization

Question 1

Diagnosing scrolling jank

You're building a feed with complex cells, images, gradients, async content, dynamic text, and scrolling feels janky. Walk me through how you would diagnose and fix it.

Follow-ups

  • What tools would you use?
  • How do you tell layout, rendering, image decoding, and main-thread work apart?

Answer outline

Reproduce the problem first on the same device class, using a Release build and a realistic amount of data. Note whether the jank is constant on every frame or spikes when new rows appear or images finish loading.

Profile before you guess. Attach a real device, open Instruments, and start with Time Profiler filtered to the main thread and sorted by weight. Look for main-thread stacks doing image decode, JSON parsing, regex, or repeated layout passes.

Most jank comes from one of four causes, and the trace tells you which:

  1. 1.Layout (constraint thrash, long NSISEngine stacks): simplify constraints, split heavy cells, cache row heights, and remove layoutIfNeeded() storms.
  2. 2.Rendering (offscreen layers, blur, shadows): reduce layer effects, or rasterize deliberately where the content is static.
  3. 3.Images (large assets hitting the main thread): downsample to display size and decode off the main thread.
  4. 4.Main-thread work (JSON parsing, disk I/O): move it into async/await pipelines and touch UI only on the main actor.

SwiftUI has its own version of this problem. Unstable identity in ForEach, or a row body that reads more state than it needs, rebuilds rows on every change and looks like layout jank. In UIKit the equivalent is cell reuse that rebuilds subviews every time the cell is configured instead of resetting them.

Principles

  • Test one hypothesis at a time, because two changes in one trace tell you nothing about either.
  • A frame budget is about 16 ms at 60 Hz and 8 ms at 120 Hz, so decode and layout must fit.
  • Wrong identity in SwiftUI or wrong reuse in UIKit causes extra rebuilds that drop frames just like slow layout does.
  • Decode images off the main thread and at display size, since oversized bitmaps are the most common single cause of feed jank.

Load and decode in the background, then assign the finished image on the main actor:

Decode off the main thread
Task {
    let data = try await loader.data(for: url)
    let image = await decodeOffMain(data)  // CPU work off the main thread
    await MainActor.run { imageView.image = image }
}

Follow-up angles

  • Put os_signpost intervals around cell configuration and image assignment, then match spikes in the trace to those named regions.