Performance
Profiling with Instruments, launch time and hitches, image-decoding cost, binary size, the sanitizers, and gating regressions in CI.
13 questions
JuniorTheoryVery commonWhat is Instruments, and how do you profile a running app with it?
What is Instruments, and how do you profile a running app with it?
Instruments is Xcode's profiling suite. You launch a build with Product ▸ Profile, pick a template like Time Profiler or Allocations, and it records the running process. You read real CPU, memory, and timing from live samples.
Common mistakes
- ✗Thinking Instruments analyzes source statically rather than sampling a running process
- ✗Confusing Instruments with the debugger's memory graph or the crash-log symbolicator
- ✗Believing a single template measures everything instead of choosing one per concern
Follow-up questions
- →Why should you profile a Release build rather than a Debug build?
- →How does the Time Profiler template differ from the Allocations template?
MiddlePerformanceVery commonA table view drops frames while scrolling — in what order do you check the likely causes?
A table view drops frames while scrolling — in what order do you check the likely causes?
Check the cheap causes first. Confirm cells dequeue and reuse; move image decoding and disk or network work off the main thread; cache expensive heights; avoid offscreen passes from shadows; then profile with Time Profiler for the rest.
Common mistakes
- ✗Blaming the GPU or device instead of checking reuse and main-thread work
- ✗Decoding images or laying out cells on the main thread during a scroll
- ✗Skipping profiling and guessing at the cause instead of measuring it
Follow-up questions
- →Why does decoding a JPEG on the main thread stutter a scroll?
- →Which offscreen-rendering effects most often cost frames inside a cell?
MiddlePerformanceCommonWhy does a 4000×3000 JPEG cost ~48 MB of RAM once shown, and how does decode-time downsampling fix it?
Why does a 4000×3000 JPEG cost ~48 MB of RAM once shown, and how does decode-time downsampling fix it?
A JPEG is compressed on disk, but showing it decodes to an uncompressed bitmap of about width × height × 4 bytes — 4000 × 3000 × 4 ≈ 48 MB — whatever the file size. Downsampling at decode time via ImageIO decodes straight to the drawn pixel size, so RAM matches the view.
Common mistakes
- ✗Assuming an image's RAM cost equals its compressed file size on disk
- ✗Resizing after decoding the full bitmap instead of downsampling at decode time
- ✗Thinking a small image-view frame limits the size of the decoded bitmap
Follow-up questions
- →Why does resizing a UIImage after loading not lower the peak memory?
- →How does ImageIO decode straight to a thumbnail without the full bitmap?
MiddlePerformanceCommonIn app launch, what is pre-main versus post-main time, and what makes a cold start slow?
In app launch, what is pre-main versus post-main time, and what makes a cold start slow?
Pre-main runs before main() — dyld loading and linking libraries, binding, rebasing, runtime setup with static initializers. Post-main is your code up to the first frame, like didFinishLaunching. Measure both with the App Launch instrument; cold starts slow from too many frameworks.
Common mistakes
- ✗Swapping the definitions of pre-main and post-main launch time
- ✗Believing dyld, linking, and static initializers are free at launch
- ✗Thinking more dynamic frameworks make a cold start faster, not slower
Follow-up questions
- →Why do many dynamic frameworks lengthen pre-main time specifically?
- →What synchronous work in
didFinishLaunchingmost often delays the first frame?
MiddleDebuggingCommonProve the main thread is blocked during a 400 ms UI hang, and by what
Prove the main thread is blocked during a 400 ms UI hang, and by what
Confirm it with a tool, not a guess — Main Thread Checker or a hang report flags the stall, and the Time Profiler heaviest stack on Thread 0 shows ~396 ms in one synchronous call. Here DataStore.loadAll() parses JSON on the main thread, blocked on disk I/O.
Common mistakes
- ✗Guessing at the cause instead of confirming the stall with a tool
- ✗Assuming a background thread is blocked when the main-thread stack is stuck
- ✗Blaming heavy rendering or a leak rather than a synchronous main-thread call
Follow-up questions
- →Which signal in Time Profiler tells you the main thread stalled rather than ran?
- →Why does a
__read_nocancelleaf prove the block is disk I/O?
MiddleTheoryCommonWhat do Thread Sanitizer, Address Sanitizer, and Main Thread Checker each catch, and why not ship them on?
What do Thread Sanitizer, Address Sanitizer, and Main Thread Checker each catch, and why not ship them on?
Thread Sanitizer (TSan) detects data races, Address Sanitizer (ASan) catches memory errors like overflows and use-after-free, and Main Thread Checker flags off-main UIKit calls. Each heavily instruments the binary, so you use them in debug and CI, never in Release.
Common mistakes
- ✗Assuming a sanitizer can stay enabled in a shipping Release build
- ✗Confusing what each tool catches — races vs memory errors vs off-main calls
- ✗Thinking sanitizers are free compile-time checks with no runtime cost
Follow-up questions
- →Why does Thread Sanitizer need a specially instrumented build to observe a data race?
- →What kind of bug does Address Sanitizer catch that a plain crash log would not?
MiddlePerformanceCommonRead an Instruments Time Profiler call tree to find the hot path
Read an Instruments Time Profiler call tree to find the hot path
Invert the call tree so heavy self-time leaves rise to the top, hide system libraries so only your frames remain, and focus on the main thread. start has high total but ~0 self. The hot path is -[FeedCell buildAttributedText:] at ~62% self.
Common mistakes
- ✗Reading the top total-time frame instead of inverting for self time
- ✗Leaving system libraries visible and blaming a framework frame
- ✗Treating high total time as the cost rather than high self time
Follow-up questions
- →Why does
startshow high total time but near-zero self time? - →When would you keep system libraries visible instead of hiding them?
SeniorPerformanceCommonAn app binary is 180 MB — what makes it big, how do you measure it, and what cuts it?
An app binary is 180 MB — what makes it big, how do you measure it, and what cuts it?
Size comes mainly from assets, the executable __TEXT, and embedded frameworks. Measure from the App Store Connect size report and the linkmap, not the compressed .ipa on disk. Here 96 MB is images, so compress assets and use on-demand resources, then dead-strip code.
Common mistakes
- ✗Thinking source lines, not assets and frameworks, drive binary size
- ✗Measuring the compressed .ipa instead of the App Store size report
- ✗Adding dynamic frameworks or bitcode expecting them to shrink the app
Follow-up questions
- →Why is the App Store download size different from the
.ipaon disk? - →How does dead-code stripping decide which symbols to remove?
MiddleDebuggingOccasionalMemory grows but the Leaks tool finds nothing — what is an abandoned object graph?
Memory grows but the Leaks tool finds nothing — what is an abandoned object graph?
An abandoned object graph is reachable memory, held by a strong reference, that is never used again, so Leaks — which reports only unreachable blocks — misses it. Allocations generational analysis shows each screen leaving ~12 MB persistent, the retained graph.
Open full question →Common mistakes
- ✗Assuming any memory growth without a leak is fragmentation and unfixable
- ✗Believing the Leaks instrument catches every kind of memory growth
- ✗Confusing an abandoned graph with a retain cycle or an autorelease-pool issue
Follow-up questions
- →Why does the Leaks instrument report zero while memory is still climbing?
- →How does marking a generation per screen isolate the retained objects?
MiddlePerformanceOccasionalHow do you instrument code with the signpost API os_signpost so a custom interval shows in Instruments?
How do you instrument code with the signpost API os_signpost so a custom interval shows in Instruments?
Create an OSSignposter on the .pointsOfInterest category and wrap the work in a begin/end pair — beginInterval before it, endInterval with the returned state after. The named interval then shows in the Points of Interest track.
Common mistakes
- ✗Timing with Date and print instead of a signpost API
- ✗Emitting a single log line and expecting it to become an interval
- ✗Assuming only Apple framework events can appear in Points of Interest
Follow-up questions
- →Why must the end signpost use the state returned by the begin call?
- →How does the Points of Interest track differ from the Time Profiler instrument?
SeniorPerformanceOccasionalMoving to the 120 Hz ProMotion display exposed hitches — what is the frame budget, and what fixes them?
Moving to the 120 Hz ProMotion display exposed hitches — what is the frame budget, and what fixes them?
At 120 Hz the frame budget is ~8.3 ms — half of the 16.7 ms at 60 Hz — so work that fit before now overruns and hitches. Animation Hitches reports the hitch-time ratio and longest hitch, here 41 ms from decode inside layoutSubviews. Move decode off-main and cache layout.
Common mistakes
- ✗Thinking a higher refresh rate gives more time per frame, not less
- ✗Reading the instrument as average fps instead of the hitch-time ratio
- ✗Assuming the 16.7 ms budget is unchanged at 120 Hz
Follow-up questions
- →Why does the same code hitch at 120 Hz but not at 60 Hz?
- →What does the hitch-time ratio measure that raw frame rate misses?
SeniorDebuggingOccasionalScroll jank appears only on older devices and only after 5 minutes — reproduce, measure, root-cause it
Scroll jank appears only on older devices and only after 5 minutes — reproduce, measure, root-cause it
Reproduce on the old device and drive it five minutes — the accumulated state, not a cold launch, matters. Measure with Time Profiler and Allocations together. Memory climbs to 540 MB with ImageCache on top — an unbounded cache causing pressure. Bound the cache.
Common mistakes
- ✗Profiling a fresh launch on a new device instead of the aged state on an old one
- ✗Dismissing time-dependent jank as not reproducible
- ✗Ignoring memory growth as a cause of scroll jank
Follow-up questions
- →Why does memory pressure show up as scroll jank rather than a crash first?
- →How would you confirm the image cache is the growth, not a leak?
SeniorDesignRareYour iOS app keeps regressing on performance between releases — a heavy view controller doubled cold-start time, a careless cell change halved scroll smoothness, and a leaked cache pushed memory over the OS memory-kill (jetsam) limit on older devices — and each was only noticed by users after shipping. Design a performance regression gate in CI that blocks a merge when a pull request makes things measurably worse. Decide what to measure (for example cold and warm launch time, the scroll hitch ratio, and peak or steady-state memory), how to collect those numbers deterministically on a fixed device or simulator so readings are comparable run to run, where thresholds and baselines come from, and how the gate reports a failure to the author without being so flaky that teams learn to ignore it. Explain how you keep the baseline honest as the app legitimately grows, and how you tell a real regression apart from measurement noise.
Your iOS app keeps regressing on performance between releases — a heavy view controller doubled cold-start time, a careless cell change halved scroll smoothness, and a leaked cache pushed memory over the OS memory-kill (jetsam) limit on older devices — and each was only noticed by users after shipping. Design a performance regression gate in CI that blocks a merge when a pull request makes things measurably worse. Decide what to measure (for example cold and warm launch time, the scroll hitch ratio, and peak or steady-state memory), how to collect those numbers deterministically on a fixed device or simulator so readings are comparable run to run, where thresholds and baselines come from, and how the gate reports a failure to the author without being so flaky that teams learn to ignore it. Explain how you keep the baseline honest as the app legitimately grows, and how you tell a real regression apart from measurement noise.
Measure a small stable set — launch time, hitch ratio, peak memory — on a fixed device, averaging runs to beat noise. Keep a baseline from main; a PR fails when it exceeds that baseline plus a noise margin, only on sustained regressions.
Common mistakes
- ✗Measuring on shared CI hardware so numbers are not comparable between runs
- ✗Using one absolute threshold instead of a baseline plus a noise margin
- ✗Failing on a single noisy run, so the team learns to ignore the gate
Follow-up questions
- →How do you keep the baseline honest as the app legitimately grows over time?
- →How do you separate a real regression from run-to-run measurement noise?