---
name: debugging-instruments
description: "Xcode debugging and profiling: LLDB, Memory Graph Debugger, Instruments, crashes, leaks and retain cycles, main-thread hangs, slow rendering, build failures, CPU, memory, energy and network profiling. Use when debugging or profiling an iOS app."
metadata:
  source: multi-agent-pipeline
---

# Debugging and Instruments

Baseline: Xcode 26 with iOS 26 devices and simulators. The LLDB commands and
most Instruments templates are much older and work the same on earlier
releases.

Covers: LLDB breakpoints, expressions and watchpoints, Malloc Stack Logging,
Instruments templates, `xctrace`, signposts, and build failures from the
compiler, SwiftPM or the linker.

Hand these to their own skills and keep the answer here to a short pointer:

| Topic | Skill |
|-------|-------|
| Rewriting SwiftUI views once a trace shows the cost | `swiftui-performance` |
| `MXMetricManager` subscribers, `MXDiagnosticPayload`, `MXHangDiagnostic` | `metrickit-diagnostics` |
| Organising benchmarks and perf tests in a test suite | `swift-testing` |

Deeper material: [full LLDB notes](references/lldb-patterns.md) and a
[template-by-template Instruments guide](references/instruments-guide.md).

## LLDB

### Everyday commands

| Command | Effect |
|---------|--------|
| `v total` | Reads a frame variable straight from memory. Fast, runs no code. |
| `p total` | Evaluates an expression and prints it with type info through the LLDB formatter. |
| `po order` | Evaluates an expression and prints the object description (`debugDescription`). |
| `bt` / `bt all` | Backtrace of the current thread / of every thread. |
| `frame select 3` | Moves to frame 3 of the backtrace. |
| `thread list` / `thread select 2` | Lists threads with their state / switches to thread 2. |

Reach for `v` first when all you need is a local value. It executes nothing in
the target process, so it cannot trigger getters, locks or other side effects.

### Breakpoints

```text
(lldb) br set -f CartView.swift -l 88          # file and line
(lldb) br set -n applicationDidBecomeActive    # function name
(lldb) br set -S setObject:forKey:             # Objective-C selector
(lldb) br modify 1 -c "lineItems.count > 20"   # add a condition
(lldb) br modify -G true 1                     # print, then carry on
(lldb) br command add 1
> po lineItems.count
> continue
> DONE
(lldb) br disable 1                            # keep it, but inactive
(lldb) br delete 1                             # remove it
```

A breakpoint with commands plus `--auto-continue true` behaves as a logpoint.

### Evaluating and changing state

```text
(lldb) expr lineItems.count
(lldb) expression -l swift -- import UIKit
(lldb) expression -l swift -- self.checkoutButton.isEnabled = false
(lldb) expression -l objc -- (void)[CATransaction flush]
```

The process is paused, so UIKit will not redraw on its own. After changing a
view property from the debugger, flush the pending `CATransaction` to see the
change on screen without resuming.

### Watchpoints

```text
(lldb) w set v self.balance                    # stop on write
(lldb) w set v self.balance -w read            # stop on read
(lldb) watchpoint modify -c "self.balance < 0" 1   # only when it goes negative
(lldb) w list
(lldb) w delete 1
```

Watchpoints use hardware debug registers, so only about four can be active at
once on ARM. They answer "who changed this value?": the debugger stops on the
exact line that performed the write.

### Symbolic breakpoints

A symbolic breakpoint names a symbol rather than a file, which is how you stop
inside framework or system code whose source you do not have.

```text
(lldb) br set -n "UIViewController.viewWillAppear"
(lldb) br set -r ".*paymentFailed.*"           # regex over symbol names
(lldb) br set -n malloc_error_break            # heap corruption
(lldb) breakpoint set --name UIViewAlertForUnsatisfiableConstraints
```

In Xcode, open the Breakpoint Navigator, press +, choose Symbolic Breakpoint
and enter a symbol such as `swift_willThrow` or `-[UIView layoutSubviews]`.

## Memory

### Memory Graph Debugger

1. Run the Debug configuration.
2. Reproduce the suspected leak, for example open a screen and close it again.
3. Click the Memory Graph button in the debug bar.
4. Objects flagged with a purple warning icon are leaks.
5. Select one to see what references it and, with stack logging on, where it
   was allocated.

Turn on Malloc Stack Logging (Scheme > Run > Diagnostics) before launching;
without it the graph shows ownership but no allocation backtrace.

### Retain cycles that come up again and again

```swift
@MainActor
final class UploadMonitor {
    var onProgress: ((Double) -> Void)?
    private var ticker: Timer?
    private(set) var fraction = 0.0

    func attach() {
        onProgress = { [weak self] value in
            self?.fraction = value
        }
    }

    func start() {
        ticker = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
            MainActor.assumeIsolated { self?.poll() }
        }
    }

    private func poll() {}
}

protocol UploadQueueDelegate: AnyObject {
    func queueDidDrain(_ queue: UploadQueue)
}

final class UploadQueue {
    weak var delegate: UploadQueueDelegate?
}
```

- A stored closure that captures `self` strongly keeps its owner alive. Capture
  `[weak self]` and call through `self?`.
- A strong delegate reference closes a loop. Constrain the protocol to
  `AnyObject` and store the property as `weak var`.
- `Timer.scheduledTimer(timeInterval:target:selector:userInfo:repeats:)`
  retains its target. Use the block API, `scheduledTimer(withTimeInterval:repeats:)`,
  with `[weak self]`, and invalidate it when done. The block is `@Sendable`, so
  on a main-actor class it reaches `self` through `MainActor.assumeIsolated`; a
  timer scheduled from the main thread fires on the main run loop.

### Allocations and Leaks

- **Allocations** shows memory over time and object lifetimes. Use Mark
  Generation before and after an action (open and close a screen) to isolate
  what that action left behind.
- **Leaks** finds allocations nothing points to any more, including cycles that
  have become unreachable. Record it alongside Allocations for the full picture.
- Filter the detail view by your module name to hide system allocations.

Triage recipe for a leak or steady growth: mark a generation before and after
the repro to prove memory is retained, open the Memory Graph Debugger to see
who owns it, and keep Malloc Stack Logging on for the allocation call stacks.

### Malloc Stack Logging

In the scheme editor, open Run, then the Diagnostics tab, and tick Malloc
Stack Logging. The recorded
allocation backtraces feed the Memory Graph Debugger, the Allocations
instrument and exported `.memgraph` files. Export a graph from Xcode (File >
Export Memory Graph) or Instruments and inspect it from Terminal:

```bash
leaks Checkout.memgraph
```

## Hangs

### Thresholds and detection

For a discrete interaction such as a tap, a delay below roughly 100 ms goes
unnoticed, while a few hundred milliseconds already makes the app feel stuck.
Apple's tools usually start reporting once the main run loop stays busy for
more than 250 ms. Keep those two numbers apart: the tool threshold is not the
point where users start to notice.

Tools, from development to production:

- Thread Checker (Scheme > Diagnostics) flags UI calls made off the main thread.
- Thread Performance Checker flags priority inversions during a debug session.
- Hang Detection under Developer settings on the device reports hangs as you
  use the app.
- Time Profiler, CPU Profiler and Hitches profile a hang you can reproduce.
- `os_signpost` and `OSSignposter` put your own intervals on the Instruments
  timeline.
- MetricKit's `MXHangDiagnostic` catches hangs in the field; see
  `metrickit-diagnostics`.

```swift
import os

let tripSignposts = OSSignposter(subsystem: "app.atlas.trips", category: "Sync")

func refreshTrips(from client: TripClient, into store: TripStore) async throws {
    let state = tripSignposts.beginInterval("fetchTrips")
    let trips = try await client.trips()
    tripSignposts.endInterval("fetchTrips", state)
    store.merge(trips)
}
```

### Time Profiler in five steps

1. Product > Profile (Cmd+I).
2. Pick Time Profiler, start recording, then perform the sluggish action.
3. Look at the main thread and sort by Weight to find the hot path.
4. Turn on Hide System Libraries to keep only your code.
5. Double-click an expensive frame to open the matching source line.

### Usual causes

| Cause | Symptom | Fix |
|-------|---------|-----|
| Synchronous file or network I/O on main | UI freezes while data loads | Do the work in a `Task` or a background actor |
| Lock contention | Main waits on a lock a background job holds | Use an actor or shrink the critical section |
| Layout thrash | `layoutSubviews` runs over and over | Batch layout changes, avoid forcing layout |
| Large JSON decode on main | Screen stalls during load | Decode off the main thread |
| Synchronous image decoding | Jank while scrolling an image list | `AsyncImage`, or decode and downsample off main |

## Build Failures

### Compiler diagnostics

Fix the first error first; many later errors cascade from it. Search the log
for the message text (for example `error: cannot convert`). The Report
Navigator (Cmd+9) keeps the full, timestamped build log.

### SwiftPM resolution

When versions clash, resolution fails and the message names the requirement
that cannot be met, typically a range such as `1.0.0..<2.0.0` on one package.
Read `Package.resolved`, then loosen or align the version requirements. When
the cache itself is suspect:

```bash
rm -rf .build
rm -rf "$HOME/Library/Caches/org.swift.swiftpm"
swift package resolve
```

### Missing modules and linker errors

| Message | Look at |
|---------|---------|
| `No such module 'Foo'` | Is the file in the right target? Then the import and framework search paths |
| `Undefined symbol` | A framework missing from the link phase, or the wrong architecture |
| `duplicate symbol` | Two targets defining one symbol; Objective-C name clashes |

Check these build settings first: `FRAMEWORK_SEARCH_PATHS`, `OTHER_LDFLAGS`,
`SWIFT_INCLUDE_PATHS`, and `BUILD_LIBRARY_FOR_DISTRIBUTION` for XCFrameworks.

## Instruments

### Picking a template

| Question | Template |
|----------|----------|
| High CPU, slow UI, where is the time going? | Time Profiler |
| Memory growth, object lifetimes | Allocations |
| Suspected retain cycles or abandoned objects | Leaks |
| Request timing and payloads | Network |
| How often SwiftUI bodies run and why | SwiftUI |
| Dropped frames, blending, commit and render cost | Animation Hitches, Core Animation instruments |
| Battery drain, heat, background energy | Power Profiler |
| Heavy or slow disk access | File Activity |
| Scheduling, system calls, VM faults | System Trace |

### xctrace on the command line and in CI

```bash
xcrun xctrace record --device "QA iPhone" \
  --template "Time Profiler" --instrument "Allocations" \
  --output launch.trace --launch -- /path/to/Atlas.app
xcrun xctrace export --input launch.trace --xpath '/trace-toc/run/data/table'
xcrun xctrace list templates
xcrun xctrace list devices
```

A recording takes exactly one `--template`; add further instruments with
`--instrument`. Everything after `--launch --` is the app and its arguments.
Run the same recording in CI and compare the exported tables between builds to
catch regressions.

## Common Mistakes

### `print()` instead of `Logger`

Output from `print()` carries no subsystem, category or privacy annotation, so
nobody can filter it later. Use unified logging:

```swift
import os

let log = Logger(subsystem: "app.atlas.checkout", category: "Orders")

func submit(order id: String, items: Int) {
    log.debug("Submitting \(id, privacy: .public) with \(items) items")
    log.info("Order submitted")
}
```

Console.app filters by subsystem and category. By default `.debug` messages
stay in the in-memory log store and are not persisted to disk.

### Memory Graph without Malloc Stack Logging

The graph still shows leaked objects but not where they were allocated. Before
launching, choose All Allocations for Malloc Stack Logging in the scheme's
Diagnostics tab; then run, reproduce and open the graph.

### Debugging or profiling the wrong configuration

Optimised Release code inlines, reorders and drops variables, so LLDB prints
`<optimized out>`. Debug builds add runtime checks that skew timings. Debug
with the Debug configuration; profile with Release.

### Stopping on every loop iteration

Add a condition instead:
`br set -f OrderList.swift -l 42 -c "row.id == wantedID"`, or right-click the
breakpoint in Xcode, choose Edit Breakpoint and fill in Condition.

### Dismissing Thread Sanitizer warnings

A TSan report is a real data race that may crash only now and then. Treat it as
a bug until you can show the tool itself is wrong. Protect the shared state,
for example with an actor:

```swift
actor ThumbnailCache {
    private var images: [URL: Data] = [:]

    func image(for url: URL) -> Data? { images[url] }

    func store(_ data: Data, for url: URL) { images[url] = data }
}
```

Turn it on in the scheme's Diagnostics tab. Apps for iOS, iPadOS, tvOS,
visionOS and watchOS get it in Simulator; the only devices Apple lists as
supported are Macs running 64-bit macOS apps.

### Correcting a flawed memory or hang plan

- Leaks catches abandoned, unreachable allocations and isolated unreachable
  cycles. It does not prove that every cycle still reachable from a root is
  gone; use the Memory Graph and Allocations generations for those.
- For growth: generations around the repro, the Memory Graph to see who holds
  the memory, and stack logging to see where it came from.
- For severity: tools report above 250 ms, but users can feel a few hundred
  milliseconds. Do not treat "under the tool threshold" as "fine".

## Review Checklist

- [ ] `os.Logger` instead of `print()`
- [ ] Malloc Stack Logging on before memory sessions
- [ ] Memory Graph checked after dismiss and deallocation flows
- [ ] Delegates declared `weak var`
- [ ] Stored closures capture `[weak self]`
- [ ] Timers use the block API with `[weak self]`
- [ ] Thread Sanitizer on in Simulator test schemes when chasing races
- [ ] Main thread free of blocking I/O and long computations
- [ ] Time Profiler baselines recorded from Release builds
- [ ] Failed builds read from the earliest error down
- [ ] `OSSignposter` intervals around custom performance-critical work
- [ ] Conditional breakpoints for loops and collections

## Apple Documentation

- [Logging](https://developer.apple.com/documentation/os/logging)
- [Logger](https://developer.apple.com/documentation/os/logger)
- [OSSignposter](https://developer.apple.com/documentation/os/ossignposter)
- [Writing log messages](https://developer.apple.com/documentation/os/generating-log-messages-from-your-code)
- [Recording performance data](https://developer.apple.com/documentation/os/recording-performance-data)
- [Catching memory, threading and crash bugs early](https://developer.apple.com/documentation/xcode/diagnosing-memory-thread-and-crash-issues-early)
- [Data races](https://developer.apple.com/documentation/xcode/data-races)
- [Lowering memory use](https://developer.apple.com/documentation/xcode/reducing-your-app-s-memory-use)
- [Improving app responsiveness](https://developer.apple.com/documentation/xcode/improving-app-responsiveness)
- [Battery use](https://developer.apple.com/documentation/xcode/analyzing-your-app-s-battery-use)
- [Performance of a shipped app](https://developer.apple.com/documentation/xcode/analyzing-the-performance-of-your-shipping-app)
- [Instruments tutorials](https://developer.apple.com/tutorials/instruments)
