On this page
Most iOS debugging is still slower than it needs to be.
The tools are already there. Xcode, LLDB, breakpoints, the view debugger, Instruments, and OSLog can answer most questions without rebuilding the app five times and scattering temporary print() statements through production code paths.
print() is not evil. It is just a blunt instrument. It changes code, creates noise, gets committed by accident, and only tells you what you remembered to ask before the app reached the broken state.
A better debugging loop is simple:
- stop the app at the right moment
- inspect real state
- change the experiment without rebuilding
- confirm the cause
- remove the smallest bug, not the largest pile of symptoms
LLDB is the tool that makes that loop cheap.
1. Start with a hypothesis
A breakpoint without a question is just a pause button.
Before opening the debugger, write down the thing you are trying to prove:
- Is this code path running?
- Is the value wrong before or after the network response?
- Is this view being rebuilt too often?
- Is this state mutation happening on the wrong actor?
- Is navigation failing because the route is wrong or because presentation state rejects it?
- Is the object still alive when it should have been released?
The hypothesis decides where to stop and what to inspect.
If a checkout confirmation screen shows the wrong entitlement state, do not start by stepping through the entire app launch. Stop where the entitlement is read, where it is written, and where the UI decides what to show. Debug the boundary where truth changes shape.
That discipline matters because debuggers make it easy to wander. Wandering feels productive because many panels are open. The bug remains exactly where it was.
2. Use symbolic breakpoints for framework and lifecycle questions
A symbolic breakpoint stops when a function or method is called, even if you do not own the source file.
That makes it useful for UIKit, SwiftUI hosting edges, Objective-C selectors, and lifecycle events that are hard to catch with line breakpoints.
Common examples:
UIViewAlertForUnsatisfiableConstraints
-[UIViewController viewDidAppear:]
-[UIViewController viewWillDisappear:]
UIApplicationMain
swift_willThrow
objc_exception_throwUIViewAlertForUnsatisfiableConstraints is especially useful. Auto Layout warnings in the console are easy to ignore until they become a production layout bug with a screenshot attached and no reproduction steps. A symbolic breakpoint turns the warning into a stoppable moment.
Once paused, inspect the view hierarchy, constraints, and call stack. The call stack usually tells you which screen or component created the conflict. That is better than searching the codebase for constraint constants like a person bargaining with wallpaper.
For thrown errors, swift_willThrow can help when an error is being swallowed or converted too early. It is noisy, so use it with a condition when possible. But when an app silently falls back to an empty state, stopping at the throw site can be faster than auditing every catch block.
3. Add conditions instead of stopping every time
A breakpoint that fires 400 times is not a debugging tool. It is a tiny denial-of-service attack you launched against yourself.
Use breakpoint conditions when only one case matters.
Examples:
user.id == "42"
items.count > 1000
error != nil
indexPath.row == 0
route == .settingsIn Xcode, edit the breakpoint and add a condition. In LLDB, conditions can also be attached directly:
breakpoint set --file CheckoutModel.swift --line 84 --condition 'cart.items.count > 20'Conditional breakpoints are useful for:
- one specific account or fixture
- one problematic row in a table or list
- a retry count crossing a threshold
- a nil value that should never be nil
- a route or state machine case that only appears after several steps
The rule is simple: stop when the signal is interesting, not when the line happens to execute.
If evaluating the condition is expensive, move the breakpoint closer to the suspicious branch or use logging instead. The debugger should narrow the problem, not become the performance regression.
4. Use logpoints when you need a timeline
A logpoint is a breakpoint that prints information and continues running.
That gives you the visibility of logging without editing code, rebuilding, or accidentally shipping diagnostics. It is useful when the app needs to continue naturally: animation timing, navigation sequencing, retries, delegate callbacks, and SwiftUI update flow.
Good logpoints are structured and boring:
Checkout state: {state}, retry: {retryCount}, entitlement: {entitlementStatus}
Route opened: {route}, selected tab: {selectedTab}
Cell configured: {indexPath}, id: {item.id}Use logpoints to build a timeline:
- deep link received
- route resolved
- selected tab changed
- navigation path appended
- destination appeared
- sheet presented
If step 4 happens and step 5 does not, the problem is no longer “deep links are broken.” The problem is the path-to-destination boundary. That is a smaller room with fewer suspects.
Logpoints also avoid the classic mistake where pausing in the debugger changes timing and makes the bug disappear. Some bugs are shy. The debugger should observe them quietly before putting them under a lamp.
5. Inspect values with po, p, and v
The LLDB console is not just for backtraces.
The commands you will use constantly:
po object
p value
v variableName
frame variablepo prints an object's debug description. It is useful for Swift and Objective-C objects where CustomDebugStringConvertible, description, or framework descriptions expose readable state.
po view
po navigationController?.viewControllers
po error
po model.visibleItemsp evaluates an expression. For simple Swift values, it is often enough:
p isLoading
p items.count
p currentUserIDv and frame variable inspect variables in the current frame without invoking arbitrary code. That can be safer when evaluating expressions would trigger computed properties, lazy loading, or side effects.
v response
frame variable self
frame variable -L coordinatorThat distinction matters. po someComputedProperty may execute code. If the computed property fetches data, mutates cache, or touches UI state, your inspection just joined the incident.
Use po when you want a readable object description. Use frame variable when you want the current stored state with fewer surprises.
6. Evaluate expressions carefully
LLDB can run expressions in the paused process:
expr item.isFavorite = true
expr await model.reload()
expr try await client.fetchUser(id: userID)This is powerful. It is also how you can accidentally mutate the app into a state that no real user can reach.
Expression evaluation is useful for:
- testing a branch without repeating five UI steps
- changing a flag to inspect an alternate state
- calling a small method to verify behavior
- forcing a reload after changing local data
- checking whether a formatting function returns the expected output
It is dangerous when:
- the expression has side effects you will forget about
- it runs async work while the app is paused in a delicate state
- it mutates persistence or user defaults
- it touches the main actor from an unexpected context
- it changes timing in a race condition
Use expressions as experiments, not as fixes. If an expression makes the app work, you learned something. Now change the code properly and reproduce the fix from a clean launch.
A debugger mutation is evidence. It is not a patch.
7. Navigate the stack instead of guessing the caller
When stopped, the call stack tells you how the app got there.
Use:
bt
thread backtrace
frame select 3
up
downbt gives the current thread's backtrace. frame select moves to a specific stack frame so you can inspect variables from that caller. up and down move through frames quickly.
This is useful when the value is already wrong at the current line. Move up the stack until you find where it was passed in, transformed, or selected.
For example, if PaywallView receives the wrong source:
- stop in the view initializer or factory
- inspect
source - move up to the router frame
- inspect the route
- move up to the deep-link resolver
- inspect the original URL or notification payload
Now you know whether the bug is in view construction, routing, or input parsing.
Without the stack, teams often fix the nearest visible symptom. That is how a routing bug gets “fixed” with a special case inside a SwiftUI view. The compiler will accept it. The architecture will send the invoice later.
8. Debug threads and actors by looking at where work is running
Concurrency bugs often look like random UI bugs until you inspect execution context.
Useful commands:
thread list
thread backtrace all
thread select 1
btthread list shows active threads. thread backtrace all dumps every thread's stack. That is useful when the app appears hung, deadlocked, or stuck waiting for work that never completes.
For main-thread stalls, look at the main thread first. If it is blocked on disk, decoding, persistence, a lock, or a synchronous wait, you have a responsiveness bug, not a mysterious UI mood swing.
For Swift concurrency, the debugger has improved over time, but the practical questions are still the same:
- Is UI state being mutated on the main actor?
- Is long-running work accidentally happening on the main actor?
- Is a task waiting on another task that is waiting back on it?
- Did cancellation happen but the code kept working anyway?
- Is shared mutable state protected by a real boundary?
When the app hangs, pause it and inspect all thread backtraces before adding more logs. A stuck process is evidence frozen in place. Disturb it gently.
9. Use watchpoints for unexpected mutations
A watchpoint stops when memory changes.
That is useful when a value is being modified and you do not know who is doing it.
watchpoint set variable isLoggedIn
watchpoint set expression -- object.someField
watchpoint list
watchpoint delete 1Watchpoints are not the first tool to reach for in Swift app code because properties, wrappers, copy-on-write values, and optimized builds can make memory-level watching less obvious than it sounds.
But they are useful for:
- C and Objective-C interop
- mutable state stored in a class
- flags that change unexpectedly
- low-level buffers
- debugging lifetime or ownership issues around bridged APIs
If a view model's selectedID changes unexpectedly, a normal breakpoint on the setter or mutation method is often cleaner. If the mutation path is hidden behind many callers or legacy Objective-C code, a watchpoint can save time.
Use it narrowly. Do not try to watch half the app at once.
10. Break on exceptions and runtime warnings early
Some breakpoints should exist in almost every iOS project:
objc_exception_throw
UIViewAlertForUnsatisfiableConstraints
swift_willThrowAdd them to your user breakpoints or project debugging setup.
Break on Objective-C exceptions even in Swift apps. UIKit, Foundation, Core Data, and many third-party SDKs can still throw Objective-C exceptions for programmer errors. If you only see the crash after it unwinds, you lose the best context.
Break on Auto Layout conflicts before they become screen-specific folklore.
Use swift_willThrow selectively. It can be noisy in codebases that use throwing APIs heavily, but it is excellent when an error path is disappearing behind abstraction.
The point is not to stop on every runtime complaint forever. The point is to make serious complaints impossible to miss during development.
A console warning that scrolls by for six months is not a warning. It is a future bug report rehearsing quietly.
11. Combine LLDB with Xcode's visual debuggers
LLDB answers state questions. Visual debuggers answer structure questions.
Use the view debugger when:
- a view exists but is not visible
- layout is wrong but constraints look plausible
- a hit target does not receive touches
- a SwiftUI hierarchy is deeper than expected
- the wrong view is on top
- safe-area or z-index behavior looks suspicious
Use memory graph debugging when:
- a view controller or model never deallocates
- a closure captures
selfunexpectedly - a coordinator stays alive after dismissal
- a singleton owns more of the app than anyone wants to admit
- Combine or async tasks retain objects through subscriptions or closures
Then go back to LLDB for the exact state.
A good debugging flow often looks like this:
- visual debugger shows the hidden view still exists
- LLDB shows the presentation state still holds it
- stack inspection shows which route failed to clear it
- a conditional breakpoint confirms the cleanup path never runs
- the code fix moves cleanup to the owner that actually controls presentation
Each tool removes one layer of ambiguity. The problem gets smaller with every pass.
12. Keep reusable debugging setup in the project
Teams waste time when every engineer has to rediscover the same breakpoints and commands.
Keep a small debugging playbook in the repository:
Debugging
├── breakpoints
│ ├── Auto Layout: UIViewAlertForUnsatisfiableConstraints
│ ├── Obj-C exceptions: objc_exception_throw
│ └── Swift errors: swift_willThrow when needed
├── LLDB commands
│ ├── po router
│ ├── po navigation path
│ └── thread backtrace all for hangs
├── flows
│ ├── deep link diagnosis
│ ├── purchase restoration diagnosis
│ └── launch performance diagnosis
└── logging categories
├── auth
├── routing
├── sync
└── entitlementsThis does not need ceremony. A short DEBUGGING.md with common breakpoints, log categories, and known diagnostic flows can save hours during incidents.
For larger teams, shared Xcode schemes and breakpoint sets help, but do not turn the setup into a museum. The best debugging documentation is small enough that engineers actually read it while the app is misbehaving.
13. Use release builds when the bug cares about optimization
Some bugs only appear outside Debug builds.
Common causes:
- compiler optimization changes timing
- assertions are disabled
- logging changes performance
- Swift concurrency scheduling shifts
- data races become easier to trigger
- code guarded by build configuration behaves differently
- TestFlight uses different entitlements, provisioning, or server configuration
If a bug only appears in TestFlight, reproduce it with a Release or profiling configuration as close to production as possible. Debugging optimized Swift can be less pleasant, but it is better than debugging a version of the app that does not have the bug.
For production-only issues, rely on:
- symbolicated crash reports
- OSLog with privacy-safe structured fields
- correlation IDs for important flows
- feature flags to narrow blast radius
- targeted diagnostics builds when needed
LLDB is excellent when you can reproduce locally. When you cannot, the same discipline still applies: define the question, collect the state needed to answer it, and avoid changing five things at once.
14. Make the loop smaller
The debugger is not there to make you feel technical. It is there to shorten the distance between suspicion and proof.
A practical loop:
- form one hypothesis
- set the narrowest breakpoint or logpoint
- reproduce once
- inspect the real state
- adjust the hypothesis
- confirm with a second run
- write the smallest code change
- remove temporary diagnostics
- add a test or durable logging if the bug was expensive
That last step matters. If the bug cost real time, leave the system better defended than you found it.
Sometimes that means a unit test. Sometimes it means a UI test around a fragile route. Sometimes it means an OSLog event around a production flow. Sometimes it means documenting the breakpoint that caught the issue because the same class of bug will return in another part of the app.
Good debugging is not hero work. It is operational discipline.
The goal is not to know more LLDB commands than everyone else. The goal is to stop guessing sooner, prove causes faster, and keep production code free of diagnostic litter. There are enough messes in iOS development already. No need to add more with abandoned print() statements.