Optimizing Mobile Animations for 60 FPS
60 FPS means 16.67 ms per frame. If even one frame takes 17+ ms, Instruments shows dropped frame. On 120 Hz displays (ProMotion), threshold is even stricter: 8.3 ms. Users with iPhone 13 Pro and Pixel 8 feel this physically.
Where Frames Are Lost: Diagnostics First
Before optimizing anything — open Xcode Instruments with Core Animation template. Run on real device (simulator doesn't count: different GPU). Look at two graphs: FPS and CPU Usage. Red columns on FPS-graph — dropped frames.
On Android — Android Profiler in GPU/CPU mode + Systrace for detailed trace. In Developer Options enable Profile GPU Rendering (displays bars on screen): if orange zone (Draw) and red (Sync/Upload) regularly exceed 16ms-line — there's problem.
Typical finding: shadow animation (shadowRadius, shadowOffset on CALayer) recalculates Gaussian blur on CPU every frame. On iPhone SE 2nd gen this can give 8–10 ms just for shadow.
Main Principle: GPU Layers vs CPU Rendering
Animate only transform and opacity — not recommendation, this is law of performance. These properties handled by Compositor thread directly, without main thread involvement and without drawRect: call.
Everything else triggers Layout → Display → Prepare → Commit cycle:
| Property | Where Drawn | Dropped Frames |
|---|---|---|
transform, opacity |
GPU Compositor | No |
backgroundColor |
GPU (CALayer) | Rarely |
bounds, frame |
CPU → GPU | Often |
cornerRadius + masksToBounds |
CPU (offscreen) | Often |
shadowPath (static) |
GPU | No |
shadowRadius (dynamic) |
CPU | Very often |
cornerRadius with masksToBounds = true — offscreen rendering. For each such layer, Core Animation does additional render pass. In Instruments: Debug → Color Offscreen-Rendered paints them yellow. Fix: set layer.shadowPath statically or use mask from vector image.
UIKit: Concrete Techniques
shouldRasterize — Use with Caution
layer.shouldRasterize = true caches layer as bitmap. Helps if content doesn't change. Kills if it does: cache invalidated every frame and redrawn more expensive than without it. Check through Instruments → Color Hits Green and Misses Red: red = invalidation, no help.
drawRect vs CALayer
Overriding drawRect: — nuclear option. If call happens during animation (e.g., bounds changes), main thread busy drawing. Alternative: move static content to separate CALayer with contents = image.cgImage, animate only transform.
CADisplayLink for Custom Animations
If writing custom animation on CADisplayLink — bind to preferredFramesPerSecond:
let displayLink = CADisplayLink(target: self, selector: #selector(tick))
displayLink.preferredFrameRateRange = CAFrameRateRange(
minimum: 60,
maximum: 120,
preferred: 120
)
displayLink.add(to: .main, forMode: .common)
On ProMotion devices this allows animations to run at 120 FPS. Without range specification, system may fix 60 even on 120 Hz display.
Lottie: Common Performance Issues
Lottie by default uses .automatic render mode. On complex animations with masks and trim paths, this often means CPU rendering. Force switch:
animationView.renderingEngine = .coreAnimation
Core Animation engine (.coreAnimation) renders through CALayers — without main thread participation. Limitation: doesn't support some complex effects (gradients through trim paths, some blending modes). Check in Lottie Diagnostics.
Compose: Recommendations
Modifier.graphicsLayer instead of direct layout parameter changes:
// Bad: calls relayout every frame
Box(modifier = Modifier.size(animatedSize))
// Good: only GPU transform, layout stable
Box(modifier = Modifier
.size(100.dp)
.graphicsLayer { scaleX = animatedScale; scaleY = animatedScale }
)
graphicsLayer works like layer.transform in UIKit — outside layout pass.
Avoid remember { mutableStateOf() } inside animation lambda. Each state update through mutableStateOf triggers recomposition. Use Animatable directly, or animateFloatAsState which updates only graphicsLayer without screen recompose.
Typical Optimization Cases
Client app's list with custom cells dropped to 40 FPS during scroll. Reason: each cell had layer.cornerRadius = 12 with masksToBounds = true and layer.shadowRadius = 8. Double offscreen render pass per cell. Solution: corner radius through UIBezierPath mask (one GPU pass), shadow through shadowPath with pre-calculated CGPath. FPS returned to 60 stably.
Optimization Process
Profile in Instruments / Android Profiler on real devices (minimum one slow device from target audience). Identify offscreen rendering, expensive draw calls, CPU-animations. Sequentially eliminate with measurement after each change. Regression testing on devices of different classes.
Timeframe Guidelines
Audit and fixing animations in existing application — 2–5 days depending on scale and number of problem areas.







