Senior / Staff iOS — interview prep
Performance & optimization
jank · Instruments · main thread · images · memory · launch · lists · concurrency · regressions
Time ProfilerMainActorDownsamplingNSCacheLaunch P95Stable IDsBounded tasks
01
Diagnosing scrolling jank
Q1Fast interview flow
- Reproduce on real device, Release, realistic data volume.
- Start with Time Profiler + frame timelines to find where time is spent.
- Separate root cause: layout, rendering, image decode, or main-thread work.
- Fix one bottleneck at a time and re-profile.
Common high-impact fixes
- Move decode/JSON/regex work off main.
- Reduce expensive effects (blur, shadow, transparency layers).
- Keep cells/rows cheap; avoid heavy work in hot render paths.
- Use signposts to correlate spikes with your own code phases.
Signpost a critical path
import os.signpost
let log = OSLog(subsystem: "com.yourapp.feed", category: "perf")
os_signpost(.begin, log: log, name: "LoadPage")
defer { os_signpost(.end, log: log, name: "LoadPage") }
02
Instruments starter map
Q2| Symptom | Start with | Look for |
|---|---|---|
| High CPU / hangs | Time Profiler | Main-thread hotspots, sync I/O, decode/parsing in hot paths |
| Memory keeps growing | Allocations + Memory Graph | Net growth by generation, retained object chains, cache policy |
| Leak suspicion | Leaks + Memory Graph | Retain cycles, unbalanced observer/delegate ownership |
| Scroll stutter | Core Animation + Time Profiler | Dropped frames, long commits, layout/render hotspots |
03
Main-thread contention
Q3Rule to memorize
- Main thread = UI updates only.
- Move network, decode, file/DB work off main.
- Return to main only for final UI mutation.
Verification
- Before/after profile with same scenario.
- Main-thread stacks should get shorter and less frequent.
- UI smoothness and touch latency should improve together.
Off-main pipeline, UI on main
func refresh() async throws {
let dto = try await api.fetch() // off-main work
let viewState = await mapper.map(dto) // off-main work
await MainActor.run { apply(viewState) } // UI only
}
04
Image pipeline essentials
Q4What to do
- Downsample to display size before showing image.
- Use memory cache for hot reuse and disk/URLCache for cold reuse.
- Cancel in-flight loads on cell reuse and re-check item identity.
What hurts
- Decoding full-res images for tiny thumbnails.
- Unbounded caches that slowly push app toward OOM.
- Applying stale async image results to reused rows.
Downsample decode pattern
let source = CGImageSourceCreateWithData(data as CFData, nil)!
let opts: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: 256,
kCGImageSourceCreateThumbnailWithTransform: true
]
let cg = CGImageSourceCreateThumbnailAtIndex(source, 0, opts as CFDictionary)!
05
Memory growth and OOMs
Q5i
Distinguish leaks (objects should die but stay alive) from high but valid usage (large datasets/images with weak limits).
Debug loop
- Reproduce the same flow repeatedly with Allocations generations.
- Take Memory Graph snapshots and inspect retain paths to root.
- Confirm growth source, then add explicit cap/eviction policy.
Typical fixes
- Cap caches and purge on memory warning.
- Page large feeds instead of loading everything at once.
- Break retain cycles in closures, delegates, and observers.
06
Launch time
Q6Keep launch lean
- Prioritize first interactive frame; defer non-critical work.
- Avoid blocking network and heavy synchronous I/O at launch.
- Audit expensive singleton/static initialization paths.
Measure correctly
- Track P50/P95 launch on same OS/device class.
- Use signposts for launch phases to localize regressions.
- Change one thing, then compare same metric.
07
Table/collection performance
Q7Core mechanics
- Reuse must reset state and cancel async work in
prepareForReuse. - Use prefetch APIs for upcoming rows and cancel when no longer needed.
- Use stable IDs (same item keeps same unique id every reload) for diffable and SwiftUI lists.
Layout costs
- Deep constraints and aggressive self-sizing can dominate scroll time.
- Use good estimated sizes and keep row hierarchy simple.
- Profile with Core Animation FPS and Time Profiler.
Estimated height baseline
tableView.estimatedRowHeight = 120
tableView.rowHeight = UITableView.automaticDimension
08
Concurrency strategy (memorize)
Q8!
Memorize this line: UI on main, mutable state serialized, independent work parallel (bounded).
Practical rules
- Shared mutable state (DB/cache writes) gets one writer (actor/serial queue).
- Only parallelize independent CPU work, with a small cap.
- Prefer structured concurrency; avoid detached task spray.
How to measure
- Baseline on a real device in Release.
- Change one thing.
- Compare the same metric: time, FPS, CPU, and memory.
Bound parallelism
await withTaskGroup(of: Void.self) { group in
for batch in batches.prefix(4) {
group.addTask { await process(batch) }
}
}