2.5 — Debugging deep dive
Opening scenario
A user-reported bug: the profile picture sometimes appears as a gray square, only after navigating away and back. The QA team can reproduce it 1-in-5 times. The PR adding profile pictures was merged two weeks ago by someone now on vacation. You have:
- ~30k lines of Swift to dig through
- A
print()statement won’t help — you need to inspect runtime state XCTestcan’t reproduce a navigation timing bug- The bug never appears in your test build
This is a debugging chapter, not a “logging” chapter. Logging is what you do after you understand the bug. Debugging is how you find it. The two senior tools for this:
- LLDB — interactive runtime inspection
- View Debugger + Memory Graph Debugger — visual inspection of the runtime tree
The five Xcode debugging tools
| Tool | Use it when |
|---|---|
| Breakpoints | Stop at a specific line; inspect locals |
LLDB console (po, p, expression) | Run code in paused process; mutate state |
| View Debugger (3D hierarchy) | “Why is this view layout wrong?” |
| Memory Graph Debugger | “Why is this object still alive / why is it nil?” |
| Thread Sanitizer / Address Sanitizer | “Why does this crash sometimes?” |
Concept → Why → How → Code
Breakpoints — beyond the click in the gutter
A click in the gutter sets a line breakpoint. Right-click the breakpoint → Edit Breakpoint… unlocks the actual power:
Condition: viewModel.userID == "abc-123"
Ignore: 0
Action: Log Message → "User loaded, isLoading = @viewModel.isLoading@"
Action: Debugger Command → po viewModel
☐ Automatically continue after evaluating actions
- Condition — only breaks when the expression evaluates true. Stop only when the bug-reproducing user ID is hit.
- Action — runs without you doing anything when the breakpoint hits. Combine “Log Message” + “Automatically continue” and you have a non-stopping breakpoint that essentially adds a
print()without recompiling. - Ignore N — skip the first N hits. Useful inside loops.
The senior move: conditional, auto-continuing breakpoints with debugger commands as “actions” turn the IDE into a tracing tool, no source-code changes needed.
Symbolic breakpoints
Breakpoint navigator (⌘8) → + → Symbolic Breakpoint. Set:
Symbol: -[UIViewController viewDidLoad]
Now you stop in every viewDidLoad of every view controller — useful when you don’t know which class is misbehaving but you know the lifecycle method involved. Also handy:
objc_exception_throw— stop on every Objective-C exception (catchesNSInvalidArgumentExceptionfrom UIKit)swift_willThrow— stop right before any Swiftthrow-[UIView setNeedsLayout]— find unexpected layout invalidations
LLDB commands every iOS engineer should know
LLDB runs in the console pane when paused. Top commands:
| Command | What it does |
|---|---|
po expr | Print object (calls description / debugDescription) |
p expr | Print value (without description) |
expression expr | Evaluate and modify state — expression viewModel.isLoading = false |
v (or frame variable) | Print all local variables of current frame |
bt (or thread backtrace) | Stack trace of the current thread |
thread list | All threads |
thread select N | Switch to thread N |
c (continue) | Resume execution (same as ⌃⌘Y) |
n (next) | Step over |
s (step) | Step into |
finish | Run to end of current function |
The killer combination:
(lldb) po viewModel
<MyApp.ProfileViewModel: 0x600003a4c800, userID: nil, isLoading: true>
(lldb) expression viewModel.userID = "test-user-id"
(lldb) expression viewModel.refresh()
(lldb) c
You just fixed runtime state without restarting the app — invaluable for reproducing edge cases or simulating server responses while paused.
Print to LLDB from SwiftUI
In SwiftUI views, print() in body is awkward. Use a let _ = print():
var body: some View {
let _ = print("Profile body rebuilt, isLoading = \(viewModel.isLoading)")
VStack { … }
}
Or — better — let _ = Self._printChanges() which logs what changed to cause the rebuild. Indispensable for diagnosing unnecessary view updates.
View Debugger (3D hierarchy)
While running, Debug → View Debugging → Capture View Hierarchy (or the icon in the debug bar that looks like three stacked squares). The editor pane explodes into a 3D exploded view of your UI:
- Rotate / pan with click+drag
- Click a view to highlight it in a hierarchy outline on the left
- Right inspector shows
UIViewproperties: frame, alpha, isHidden, view class, AutoLayout constraints - “Show Clipped Content” reveals views that were drawn off-screen
This is the tool for:
- “Why is this view zero-sized?” — captured frame is
(0, 0, 0, 0) - “Why isn’t this label visible?” —
alpha = 0orisHidden = true - “Why does this view cover that one?” — the 3D rotation makes it obvious
- “Why is my AutoLayout broken?” — inspector shows the constraints in plain English
In SwiftUI projects, the View Debugger shows the underlying UIKit hierarchy (SwiftUI compiles down to UIKit at the leaves). It’s less precise than for pure UIKit but still useful for layout questions.
Memory Graph Debugger (object retention)
While running, Debug → Debug Memory Graph (icon in the debug bar that looks like three stacked nodes). Pauses your app, takes a snapshot of every live object, and renders the retain graph:
- Left sidebar: every class with live instances, sorted by count
- Center: the retain graph for the selected object
- Right inspector: backtrace of where this object was allocated
This is the tool for memory leaks:
- A view controller that should be gone after dismissal but isn’t (look for it in the sidebar; if it’s there, click it to see what’s retaining it)
- An object retained by a closure capture (the graph shows the closure as a node, with an edge labeled the captured variable)
- A reference cycle between two objects (the graph draws the cycle as a literal cycle)
Enable in Xcode → Settings → “Show memory at top of debug navigator” so you see live allocation counts at all times.
Address / Thread / Main Thread / Undefined Behavior Sanitizers
Edit the scheme (⌘<) → Run → Diagnostics. Toggle:
- Address Sanitizer — catches buffer overflows, use-after-free. Slows the app ~2×. Catches bugs that would otherwise crash randomly.
- Thread Sanitizer — catches data races. Slows ~5–15×. Essential when adopting Swift Concurrency in a Swift 5 codebase that has nonisolated state.
- Main Thread Checker — flags UIKit calls off the main thread. Cheap. Leave on for all Debug builds.
- Malloc Stack Logging — records allocation stacks for objects shown in the Memory Graph Debugger. Turn on only when memory-debugging; it’s heavyweight.
Run with sanitizers enabled at least monthly. Race bugs they catch save weeks of “intermittent crash, no repro” tickets.
Breakpoint Sets
Power user feature: Breakpoint navigator (⌘8) → bottom-left + button → Add Breakpoint Set. Group related breakpoints (e.g., “Profile screen debugging”) and enable/disable the whole set at once. Saves the “I left 12 breakpoints on, my app is paused every 3 seconds” frustration.
In the wild
- Airbnb engineering blog has documented their use of conditional breakpoints with custom LLDB scripts for diagnosing layout issues in their hot-reload framework.
- The View Debugger was famously how the Slack iOS team chased down their “the new message badge sometimes draws under the navigation bar” bug pre-2020.
- Memory Graph Debugger + Malloc Stack Logging is how Cash App’s engineers profile transient memory spikes during navigation; the allocation backtraces point straight to the leaking closure.
- Apple’s Auto Layout team demos their own debugging in every WWDC layout session — exclusively via the View Debugger.
Common misconceptions
-
“
printis enough.” No.printrequires you to (a) know where to add it, (b) rebuild, (c) reproduce again. Conditional breakpoints with log-message actions are zero-rebuild and zero-source-change. -
“View Debugger is for visual designers.” It’s for every iOS engineer. UI bugs are 30–50% of the bug surface in a typical app and View Debugger is the first place to look.
-
“Memory leaks are rare in Swift because of ARC.” Swift’s ARC handles 95% of memory management, but the remaining 5% (closure captures, delegate cycles, Combine subscriptions held by the publisher) is where every real-world memory leak comes from. Run the Memory Graph Debugger weekly.
-
“Thread Sanitizer is too slow to run.” It’s slow, yes — but run it on your test suite in CI nightly, or in your scheme’s “test” action for the smoke-test plan. The races it catches will ship to production otherwise.
-
“LLDB is for Objective-C; Swift has Xcode’s debugger UI.” LLDB is the underlying debugger for both. The UI is a frontend. Mastering LLDB pays dividends in both languages, and is the only way to do anything non-trivial.
Seasoned engineer’s take
The split between junior and mid iOS engineers is mostly debugging skill. Anyone can write features. Mid-level engineers can isolate a bug to a 50-line block of code in under an hour without reading the codebase top to bottom. They do this by:
- Forming a hypothesis first. (“I think the view model isn’t being deallocated.”)
- Picking the right tool for the hypothesis. (“Memory Graph Debugger will tell me in 10 seconds.”)
- Confirming or rejecting in minutes. (“Confirmed — there’s a Combine subscription with
selfstrong-captured.”)
The split between mid and senior is prevention: senior engineers also:
- Leave Main Thread Checker on always
- Add Active Compilation Conditions to enable sanitizers in
Debugschemes - Write
assert()andprecondition()at API boundaries so bugs surface immediately at the failing call site, not 12 frames deeper - Use
os.Loggerwith categories so production bugs come with breadcrumbs
The toolchain is generous; most engineers use 10% of it. Decide to be the engineer who uses 80%.
TIP: Set a symbolic breakpoint on
UIViewAlertForUnsatisfiableConstraintsto stop the debugger every time AutoLayout has an unsatisfiable constraint. The console message that scrolls past at runtime becomes a pause-and-inspect moment. Single best AutoLayout debugging trick.
WARNING: Don’t ship
print()statements. They run in Release. They take time, allocate strings, and on iOS they’re written to OSLog without categories — they’re slow, noisy, and unstructured. Useos.Loggerfor anything that needs to be readable in production.
Interview corner
Question: “You’ve shipped an app and users report it crashes ‘sometimes’ on the profile screen. There’s no stack trace. How do you debug it?”
Junior answer: “I’d add print statements and ask QA to reproduce.” → Acceptable for a 1-person side project.
Mid-level answer: “First, check the crash logs in App Store Connect → Xcode Organizer → Crashes; symbolicate them. If the crash is reproducible, attach LLDB and step through. If it’s intermittent, enable Thread Sanitizer and Address Sanitizer in the Debug scheme — most ‘sometimes’ crashes are data races or use-after-free issues that sanitizers catch deterministically. If it’s a memory crash, run the Memory Graph Debugger to see what’s still alive.” → Strong.
Senior answer: Plus: “I’d also check whether the crash correlates with low-memory state — iOS sends applicationDidReceiveMemoryWarning and may terminate the app, which doesn’t generate a traditional crash report. I’d add breadcrumb logging via os.Logger with a category per feature so I have context from the minutes leading up to the crash. For ‘sometimes’ bugs that don’t crash but produce wrong behavior, I’d reach for conditional breakpoints with auto-continuing log actions — they essentially add tracing without rebuilds. And I’d structure my types defensively: preconditions at API boundaries push the failure as close to the bug source as possible, instead of crashing 12 stack frames deeper where the symptom appears.” → Senior signal: symptom vs cause, memory warnings as a class of “crash”, breadcrumb design, defensive APIs.
Red-flag answer: “I’d wrap everything in a do/catch so it doesn’t crash anymore.” → Tells the interviewer the candidate will hide bugs rather than fix them.
Lab preview
Lab 2.2 is a hands-on debugging gauntlet: a starter app with three deliberate bugs (one layout, one memory leak, one threading) that you’ll find using only Xcode’s debugging tools — no source-code reading allowed beyond the symptom.
Next: when bug-hunting graduates to perf-hunting — Instruments. → Instruments primer