Scroll lag, animation stutter, frame drops. Users tell you “the app is slow” but the root cause is hard to nail down. CPU profile looks fine. No memory pressure. The problem is in the rendering layer.
iOS rendering is built on Core Animation. Every view is a CALayer, every property is animatable. Used wrong, the rendering pipeline chokes. The Xcode Instruments Core Animation tool isolates this problem.
Here is my debugging workflow.
Rendering pipeline basics
iOS targets 60fps (iPhone 15 Pro+ hits 120fps). Every frame has 16.67ms (60fps) or 8.33ms (120fps). In that window CPU and GPU have to complete rendering.
If the frame is not ready in time: a frame drop. The user feels jank. Trust threshold: the odd drop is unnoticed, continuous drops land you in “the app is slow” territory.
Core Animation pipeline:
- Commit phase: UIKit hands the layer tree to CA.
- Render server: sends the layer tree to the GPU.
- GPU compositing: layers are combined.
- Display: paint to the screen.
The bottleneck can be in any of those four.
Instruments Core Animation tool
Xcode, Product > Profile > Core Animation template.
Once open you get:
- FPS meter: actual frame rate.
- Dropped frames counter.
- Offscreen render counter.
- Blended layer counter.
Run the app, execute the suspected slow flow, watch the metrics.
FPS near 60, you are fine. Dropping to 40, problem. Below 20, serious.
Offscreen rendering
The most common performance killer. If the GPU has to render a layer to an offscreen buffer first and then copy it into the main buffer, that is an “offscreen pass”. Expensive.
Causes of offscreen rendering:
1. corner radius + masksToBounds / masksToCornerCurve:
view.layer.cornerRadius = 8
view.layer.masksToBounds = true // Offscreen render!Fix: performance improved in iOS 13+. Heavy corner radius is still costly.
Alternative: continuous cornerCurve plus a smaller radius.
2. A shadow with no shadowPath:
view.layer.shadowRadius = 10
view.layer.shadowOpacity = 0.3
// shadowPath not set → offscreen renderFix: set an explicit shadowPath:
view.layer.shadowPath = UIBezierPath(rect: view.bounds).cgPathWith shadowPath set, the GPU computes the shadow much faster.
3. Rasterize:
view.layer.shouldRasterize = trueUseful when used well. Used wrong, it causes continuous re-rasterization, which is very expensive.
4. masksToBounds + non-opaque content:
This combination complicates the rendering pipeline.
Blended layers
Layers with opacity under 1.0 or a transparent background render as “blended”. The GPU blends that layer’s pixels against the layers beneath. CPU-intensive.
In Instruments blended layers show up with red/green highlighting.
Fixes:
1. Make it opaque:
view.backgroundColor = .white // solid color
view.isOpaque = true2. Move the background from parent to child:
// Before
parent.backgroundColor = .clear
child.backgroundColor = .white
// After
parent.backgroundColor = .white
child.backgroundColor = .clear // transparent OK now3. Use a path for shadows (again): shadow opacity creates transparency.
Color Misaligned Images
If an image’s bounds are not aligned to the pixel grid, the GPU does extra work.
In the simulator, Debug > Color Misaligned Images turns it on. Misaligned images highlight yellow.
Fixes:
1. Align frames to integers:
let newFrame = view.frame.integral
view.frame = newFrame2. Set the correct scale factor:
imageView.layer.contentsScale = UIScreen.main.scale3. Prepare image sizes to match device resolution.
Usually a minor optimization, but tangible on dense UIs.
Real scenario: tableview lag
In Dentii, the tableview showing brushing history was laggy during scroll.
Instruments Core Animation:
FPS: 30 to 40 (target 60). Dropped frames: 20%. Offscreen passes: high.
Investigation:
class HistoryCell: UITableViewCell {
func configure(with session: BrushingSession) {
// Avatar image with circular crop
avatarView.layer.cornerRadius = 30
avatarView.layer.masksToBounds = true // Offscreen!
// Drop shadow around entire cell
contentView.layer.shadowRadius = 5
contentView.layer.shadowOpacity = 0.2
// No shadowPath!
}
}Fixes:
- Shadow path:
contentView.layer.shadowPath = UIBezierPath(roundedRect: contentView.bounds, cornerRadius: 8).cgPath- Pre-crop the avatar: compose the image as circular already and add it to the asset catalog. No corner radius needed.
If pre-cropping is not an option:
avatarView.layer.cornerRadius = 30
avatarView.layer.masksToBounds = true
avatarView.layer.shouldRasterize = true // Cache rendered version
avatarView.layer.rasterizationScale = UIScreen.main.scaleWith rasterize, corner clipping happens once. Reuse.
Result: FPS 55 to 60, dropped frames at 2%. Acceptable.
Real scenario 2: janky animation
In CVCrafter the template picker swipe animation was choppy. Worse on iPad.
Instruments showed:
- Render server phase 25ms (frame budget 16ms).
- Heavy CPU during the commit phase.
The code:
func animateTransition(...) {
UIView.animate(withDuration: 0.3) {
self.oldView.alpha = 0
self.newView.alpha = 1
// Both views staying on screen during animation
}
}Two views on screen at the same time, both blended (alpha changing). The GPU blends every frame.
Fix:
- Layer tree reduction. Only the necessary layers visible during the animation.
- Explicit completion:
func animateTransition(...) {
UIView.animate(withDuration: 0.3, animations: {
self.oldView.alpha = 0
self.newView.alpha = 1
}) { _ in
self.oldView.removeFromSuperview() // Clean up
}
}- Use the transition API:
UIView.transition(from: oldView, to: newView, duration: 0.3, options: .transitionCrossDissolve)iOS optimizes internally.
Result: smooth 60fps animation.
Image-heavy screen optimization
Dentii’s “all brushings” screen had hundreds of thumbnails. Scroll was janky.
An image loading problem. Every cell triggered an image load, main thread busy.
Fix: async image loading:
class ImageLoader {
let cache = NSCache<NSString, UIImage>()
func load(url: URL, completion: @escaping (UIImage?) -> Void) {
let key = url.absoluteString as NSString
if let cached = cache.object(forKey: key) {
completion(cached)
return
}
URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
guard let data = data, let image = UIImage(data: data) else {
DispatchQueue.main.async { completion(nil) }
return
}
self?.cache.setObject(image, forKey: key)
DispatchQueue.main.async { completion(image) }
}.resume()
}
}In the cell:
func configure(with session: BrushingSession) {
imageView.image = nil // placeholder
imageLoader.load(url: session.thumbnailURL) { [weak self, currentID = session.id] image in
// Cell reuse check: still the same session?
guard self?.currentSessionID == currentID else { return }
self?.imageView.image = image
}
}Third-party alternative: SDWebImage, Kingfisher. Production proven.
SwiftUI performance
SwiftUI has a different rendering model. Modifier chains are recursive, view body recomputes.
Performance tools:
Instruments SwiftUI template. View body recomputation frequency.
Self._printChanges(): call it inside a view body. Logs which dependency changed and why it re-rendered.
var body: some View {
let _ = Self._printChanges() // Log changes
return VStack { ... }
}Useful to spot unnecessary re-renders.
SwiftUI-specific optimizations:
- @State vs @Binding discipline. Wrong ownership causes wasted re-renders.
- ForEach identify correctly. Wrong id strategy and the whole list recomputes.
- EquatableView wrapper. Skip re-render when nothing changed.
- @ViewBuilder minimize state dependency. Small scope ViewBuilders.
General principles
What I learned from Core Animation debugging:
- Profile first, optimize second. Fix on data, not on assumption.
- shadowPath for every shadow. Dramatic performance.
- Transparency is expensive. Prefer opaque.
- cornerRadius + masksToBounds with caution. Better in iOS 13+, still not free.
- Rasterize sparingly. Yes for static content, no for dynamic.
- Async image loading. Do not block the main thread.
- Recycling correctly. Cell reuse, watch the async operation.
Takeaway
Core Animation performance is an advanced iOS dev skill. Instruments profiling plus knowledge of common patterns plus iteration.
Smooth 60fps is the standard experience. Anything below and the user feels quality loss. Make performance profiling a discipline before every release.
80% of the fixes: shadowPath, opaque backgrounds, async loading, cell reuse. Stay on top of those four patterns and you prevent most performance issues.