Vedran Burojevic
← WritingJuly 12, 2026

Instruments beyond memory leaks: Time Profiler, hang detection, and energy audits

How to use Time Profiler, the Hangs instrument, and energy logging to find performance and battery problems before users report them.

On this page

Instruments is still treated too often as the tool you open after someone says “memory leak” with the confidence of a person who has not measured anything yet.

Memory debugging matters. Leaks, retain cycles, and runaway allocations can wreck an app. But Instruments is much more useful than that. It can show where the app spends CPU, why the main thread stalls, which work drains battery, and which assumptions were based mostly on optimism and a warm simulator.

Most performance problems do not arrive as one dramatic crash. They arrive as friction:

  • scrolling feels heavier after a release
  • app launch regresses on older devices
  • a background sync makes the phone warm
  • typing lags in a complex form
  • a list occasionally freezes during image decoding
  • battery usage rises without an obvious feature change

Those are Instruments problems.

1. Start with a question, not a template

Do not open Instruments and record everything because the interface has many buttons and confidence is apparently free.

Start with a specific question:

  1. Why does this screen take so long to become interactive?
  2. What is consuming CPU while the user scrolls?
  3. Why does the main thread stop for 400 ms during search?
  4. Which work continues after the view disappears?
  5. Does this background sync wake the device too often?
  6. Is the app doing expensive layout, decoding, parsing, or persistence work on the wrong thread?

The question determines the instrument.

Use Time Profiler when CPU time is the suspect. Use Hangs when the user-visible problem is main-thread responsiveness. Use energy tools when the issue is battery, wakeups, networking, location, sensors, or repeated background work.

A recording without a question turns into archaeology. You can still find something, but now the dig site is your afternoon.

2. Measure on a real device

The simulator is useful for development. It is a poor judge of production performance.

A real performance pass should use:

  • a physical device
  • a release or profiling build
  • realistic data volume
  • realistic network conditions when relevant
  • the same flow repeated several times
  • thermal state and battery level noted when they matter

The simulator runs on a Mac with different CPU, memory, storage, networking, and thermal behavior. It can hide expensive work, exaggerate other costs, and make device-specific issues invisible.

The most dangerous profile is the one that says the code is fine because the test environment was luxurious.

For serious work, keep a small device bench:

  1. one current device
  2. one older supported device
  3. one low-storage or heavily used device if the product is storage-sensitive
  4. one device running the oldest OS version you still support

You do not need a museum. You need enough friction to make the app tell the truth.

3. Use Time Profiler to find actual CPU cost

Time Profiler answers a blunt question: where did the CPU time go?

That makes it useful for:

  • slow launch paths
  • expensive screen transitions
  • list scrolling regressions
  • image processing
  • JSON parsing
  • database work
  • layout and rendering overhead
  • repeated formatting or date calculations
  • accidental work inside SwiftUI body

A practical Time Profiler workflow:

  1. Launch the app on device from Instruments.
  2. Start recording before the flow begins.
  3. Perform the same user action two or three times.
  4. Stop recording immediately after the flow finishes.
  5. Filter to the time range that matters.
  6. Inspect the heaviest call trees.
  7. Hide system libraries only after checking whether framework work is triggered by your code.
  8. Fix one concrete hotspot.
  9. Record again.

That last step is not optional. If the profile does not improve, the fix was decorative.

Time Profiler is especially good at exposing work that feels small in code review but expensive in aggregate:

var formattedAmount: String {
    let formatter = NumberFormatter()
    formatter.numberStyle = .currency
    formatter.currencyCode = currencyCode
    return formatter.string(from: amount as NSDecimalNumber) ?? ""
}

One formatter allocation is not interesting. Creating it for every visible row, on every update, while SwiftUI is diffing and laying out a list is more educational. The device will deliver the lecture. Instruments provides the transcript.

A better shape is usually to move expensive formatting behind a cached formatter, a view model, or a precomputed display value where the invalidation rules are clear.

The point is not to micro-optimize every line. The point is to stop paying repeated costs in places users touch constantly.

4. Profile launch as a sequence, not a single number

Launch performance is often discussed as one metric. In practice, it is a sequence of obligations.

A useful launch profile separates:

  1. process start
  2. dependency setup
  3. persistence initialization
  4. remote configuration or feature flag loading
  5. session restoration
  6. first route decision
  7. first visible frame
  8. first interactive frame

Time Profiler can show which pieces dominate CPU time. Signposts make the profile easier to read.

import os
 
private let log = OSLog(subsystem: "com.example.app", category: "Launch")
 
func loadInitialSession() async throws -> Session {
    let signpostID = OSSignpostID(log: log)
    os_signpost(.begin, log: log, name: "LoadInitialSession", signpostID: signpostID)
    defer {
        os_signpost(.end, log: log, name: "LoadInitialSession", signpostID: signpostID)
    }
 
    return try await sessionStore.load()
}

Signposts turn a wall of stacks into named intervals. That matters when a launch regression involves app code, framework work, persistence, networking fallbacks, and one innocent-looking dependency that decides startup is the perfect time to do housekeeping.

The rule is simple: if a phase matters to user experience, name it.

Then measure it across releases. A launch budget that is not tracked is not a budget. It is a hope with a timestamp.

5. Use the Hangs instrument for responsiveness

CPU cost and user-visible hangs are related, but not identical.

A flow can have moderate total CPU and still feel bad because the main thread blocks at the wrong moment. The Hangs instrument is useful because it focuses on responsiveness, not just aggregate work.

Use it when users report:

  • taps that do not respond immediately
  • keyboard input that stutters
  • scrolling that freezes briefly
  • sheets that pause before appearing
  • search that locks the UI
  • navigation that feels delayed

The usual causes are boring and therefore common:

  1. synchronous disk access on the main thread
  2. image decoding during scrolling
  3. JSON parsing before yielding control
  4. Core Data or SwiftData work in the UI path
  5. expensive SwiftUI layout invalidations
  6. waiting on locks, actors, or queues from the main actor
  7. doing “just one quick” network-adjacent operation synchronously

“Just one quick” is how many hangs introduce themselves before ruining the demo.

When the Hangs instrument points to main-thread work, do not blindly move everything to the background. First identify the ownership problem.

Ask:

  1. Does this work need to happen before the user can interact?
  2. Can it be split into a quick first result and slower refinement?
  3. Can the expensive part happen before this screen appears?
  4. Can cached state make the first render cheap?
  5. Does the UI need live objects, or would value snapshots be enough?
  6. Is the main actor being used as a convenient storage unit instead of a UI boundary?

Main-thread performance is mostly a design problem. Instruments shows the crime scene, but the fix usually lives in the architecture.

6. Make SwiftUI profiles readable

SwiftUI performance profiles can look noisy because a lot of framework work appears in the call tree. That does not make the data useless.

The trick is to connect framework cost back to app behavior.

Look for patterns like:

  • view bodies recomputed more often than expected
  • expensive computed properties read during body
  • broad observable objects invalidating too much UI
  • unstable identity causing list rows to rebuild
  • layout work triggered by dynamic measurement
  • image decoding or resizing in view code
  • formatters, sort operations, and filters running during render

A common smell:

var body: some View {
    List(items.sorted { $0.updatedAt > $1.updatedAt }) { item in
        ItemRow(item: item)
    }
}

That may be fine for 20 items. It becomes less adorable when the list grows, updates frequently, and the sort runs as part of rendering.

Prefer to move derived collections into a model layer with explicit invalidation:

@MainActor
final class ItemListModel: ObservableObject {
    @Published private(set) var visibleItems: [ItemSummary] = []
 
    func update(from items: [Item]) {
        visibleItems = items
            .sorted { $0.updatedAt > $1.updatedAt }
            .map(ItemSummary.init)
    }
}

That is not automatically faster. It is easier to reason about, easier to measure, and easier to change when the profile shows the next bottleneck.

SwiftUI rewards clear data flow. Instruments punishes vague data flow by making it expensive in public.

7. Use energy audits before users become the battery test lab

Energy problems are performance problems with a longer fuse.

The app may feel fine while it quietly burns battery through:

  • repeated background wakes
  • chatty networking
  • location updates that stay active too long
  • timers that fire when nothing useful happens
  • Bluetooth or sensor work without clear lifetime rules
  • CPU-heavy parsing, image processing, or sync work
  • retries that ignore connectivity and app lifecycle

Energy audits should be part of release work for apps that use background tasks, location, media, Bluetooth, HealthKit, motion, live activities, uploads, downloads, or sync.

A practical audit asks:

  1. What wakes the app?
  2. What keeps it awake?
  3. What work happens while the screen is off?
  4. How often does networking happen?
  5. Do retries back off properly?
  6. Does location or sensor work stop when the user no longer needs it?
  7. Does background work finish quickly and honestly?

The Energy Log and related diagnostics can show CPU, network, display, location, and wakeup behavior. Pair that with app-level logs and signposts so the trace explains why the work happened.

Battery drain is rarely caused by one villain. It is usually a committee of small decisions meeting too often.

8. Design background work with a budget

Background work needs a budget because the system already has one. Ignoring it does not make the budget larger. It just makes the app less reliable.

For sync, uploads, imports, and maintenance jobs, define:

  • maximum run time
  • maximum batch size
  • retry policy
  • cancellation behavior
  • network requirements
  • power requirements
  • user-visible state
  • observability events

Then profile the job on device.

A sync job that processes 5 records during development may look harmless. The production version might process 5,000 records after the user restores from backup, changes devices, or returns after a month offline.

Profile the unpleasant path, not the demo path.

Good background work behaves like this:

  1. it starts for a specific reason
  2. it records what it is about to do
  3. it processes bounded work
  4. it yields or cancels when required
  5. it persists progress safely
  6. it backs off after failure
  7. it reports a useful outcome

Bad background work behaves like a raccoon in the vents: mysterious noise, occasional heat, and no clear owner.

The system will eventually deal with it. You may not enjoy the method.

9. Keep profiles comparable across releases

One-off profiling is useful. Comparable profiling is better.

For important flows, keep a small performance notebook in the repo or release checklist:

  • device and OS version
  • app build configuration
  • data set size
  • network condition
  • flow steps
  • baseline result
  • new result
  • profile file location if the team stores traces
  • commit or release version

This does not need to become a ceremony. It needs to be enough that a future engineer can reproduce the measurement instead of guessing what “felt faster” meant three releases ago.

Good candidate flows:

  1. cold launch to first interactive screen
  2. opening the busiest tab
  3. scrolling the densest list
  4. searching a realistic data set
  5. saving a complex form
  6. importing or syncing a large batch
  7. restoring purchases or entitlements
  8. uploading media

If a flow is important enough for users to complain about, it is important enough to profile in a repeatable way before shipping.

10. The practical baseline I would ship

For most production iOS apps, I would use this baseline:

  1. Time Profiler pass for launch, major navigation transitions, heavy lists, and known expensive workflows.
  2. Hangs pass for input, scrolling, search, navigation, and forms.
  3. Energy audit for background sync, location, media, uploads, downloads, and sensor-heavy features.
  4. Signposts around important app phases and expensive operations.
  5. Release comparison on at least one current device and one older supported device.
  6. A written performance budget for flows where latency affects trust.
  7. Regression checks after dependency upgrades, persistence changes, image pipeline changes, and major SwiftUI rewrites.

That is enough to catch most expensive mistakes before users do the unpaid QA work.

Instruments is not a last-resort debugging appliance. It is how an iOS team replaces suspicion with evidence.

Open it before the app feels slow, not after the release has already made your support inbox look like a performance review with attachments.

Command menu

Navigate the site or run an action