Vedran Burojevic
← WritingJuly 15, 2026

LLDB and debugging techniques every iOS engineer should know

Practical LLDB commands, breakpoint techniques, and inspection workflows that make diagnosis faster than print-statement guesswork.

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:

  1. stop the app at the right moment
  2. inspect real state
  3. change the experiment without rebuilding
  4. confirm the cause
  5. 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:

  1. Is this code path running?
  2. Is the value wrong before or after the network response?
  3. Is this view being rebuilt too often?
  4. Is this state mutation happening on the wrong actor?
  5. Is navigation failing because the route is wrong or because presentation state rejects it?
  6. 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_throw

UIViewAlertForUnsatisfiableConstraints 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 == .settings

In 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:

  1. deep link received
  2. route resolved
  3. selected tab changed
  4. navigation path appended
  5. destination appeared
  6. 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 variable

po 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.visibleItems

p evaluates an expression. For simple Swift values, it is often enough:

p isLoading
p items.count
p currentUserID

v 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 coordinator

That 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:

  1. the expression has side effects you will forget about
  2. it runs async work while the app is paused in a delicate state
  3. it mutates persistence or user defaults
  4. it touches the main actor from an unexpected context
  5. 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
down

bt 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:

  1. stop in the view initializer or factory
  2. inspect source
  3. move up to the router frame
  4. inspect the route
  5. move up to the deep-link resolver
  6. 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
bt

thread 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:

  1. Is UI state being mutated on the main actor?
  2. Is long-running work accidentally happening on the main actor?
  3. Is a task waiting on another task that is waiting back on it?
  4. Did cancellation happen but the code kept working anyway?
  5. 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 1

Watchpoints 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_willThrow

Add 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 self unexpectedly
  • 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:

  1. visual debugger shows the hidden view still exists
  2. LLDB shows the presentation state still holds it
  3. stack inspection shows which route failed to clear it
  4. a conditional breakpoint confirms the cleanup path never runs
  5. 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
    └── entitlements

This 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:

  1. symbolicated crash reports
  2. OSLog with privacy-safe structured fields
  3. correlation IDs for important flows
  4. feature flags to narrow blast radius
  5. 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:

  1. form one hypothesis
  2. set the narrowest breakpoint or logpoint
  3. reproduce once
  4. inspect the real state
  5. adjust the hypothesis
  6. confirm with a second run
  7. write the smallest code change
  8. remove temporary diagnostics
  9. 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.

Command menu

Navigate the site or run an action