Skip to main content
  1. Posts/

How I Debug iOS Apps in Xcode and the Simulator

· loading · loading ·
Jared Lynskey
Author
Jared Lynskey
Emerging leader and software engineer based in Seoul, South Korea
Table of Contents

I spend way too much time debugging. Everyone does. These days most of mine happens in Xcode and the simulator, chasing bugs in my own app, and the thing that keeps striking me is how much debugging machinery Xcode ships with that hardly anyone touches. This is the workflow I’ve settled into over the years, the parts that actually save time when something breaks.

Breakpoints Are More Powerful Than You Think
#

Most people click the gutter to set a breakpoint and leave it at that. Right-click one, though, and there’s a whole menu of options that changes how you debug.

Conditional Breakpoints
#

Say you’ve got a loop processing 1000 items and only item 347 misbehaves. Hitting the breakpoint 346 times first is a waste of an afternoon.

for i in 0..<users.count {
    processUser(users[i])  // Set breakpoint here with condition: i == 347
}

Right-click the breakpoint, add the condition i == 347, and Xcode only stops when it’s true. Works just as well with things like userId == "abc123" or error != nil.

Symbolic Breakpoints
#

These are for when you don’t know where a method is being called from. Open the breakpoint navigator (Cmd+8), click the + button, and choose “Symbolic Breakpoint”.

Want to see every time any view controller loads? Set the symbol to viewDidLoad and Xcode pauses on every single one. Too noisy? Narrow it down: MyViewController.viewDidLoad.

The ones I keep around permanently:

  • UIViewAlertForUnsatisfiableConstraints catches Auto Layout conflicts the moment they happen
  • objc_exception_throw breaks on any Objective-C exception before the crash
  • malloc_error_break finds memory allocation problems

Exception Breakpoints
#

This should be the first thing you set up in any new project. Add one (breakpoint navigator, + → Exception Breakpoint) and Xcode pauses the moment something throws, instead of after the app has already died.

It’s caught more nil-unwrapping bugs for me than I’d like to admit, the kind that otherwise just show up as mysterious crashes in the console.

Breakpoint Actions
#

Breakpoints don’t have to stop execution at all. Edit one, click “Add Action”, and it can:

  • Print variables without stopping: "User count: @(users.count)@"
  • Run LLDB commands automatically
  • Execute shell scripts
  • Play a sound (I use this for rare code paths I want to know about)

Tick “Automatically continue after evaluating actions” and the breakpoint becomes a logger that never interrupts you.

LLDB: The Console Commands You’ll Actually Use
#

When you hit a breakpoint, the console at the bottom isn’t just for reading crash logs. It’s LLDB, and it’s genuinely useful.

The Basics
#

(lldb) po user
▿ User
  - id: "123"
  - name: "John Doe"
  - email: "john@example.com"

po prints objects in a readable format. That’s 90% of my LLDB usage right there.

Need more detail? Try p instead:

(lldb) p user.name
(String) $R0 = "John Doe"

And to see every local variable at once:

(lldb) frame variable

Changing Things at Runtime
#

This is where LLDB earns its keep: you can modify variables without recompiling.

(lldb) expr user.name = "Jane Doe"
(lldb) expr index = 0

Found a bug and want to try a fix without rebuilding? Change the variable and continue. I use this constantly when narrowing down edge cases.

You can call methods too:

(lldb) po self.refreshUI()
(lldb) expr navigationController?.popViewController(animated: true)

Navigation#

(lldb) bt              # Show the call stack
(lldb) frame select 3  # Jump to a different stack frame
(lldb) continue        # Keep running (or just 'c')
(lldb) n               # Step over (next line)
(lldb) s               # Step into function

Watchpoints
#

Want to know when a variable changes? Set a watchpoint:

(lldb) watchpoint set variable user.isLoggedIn

Execution now pauses whenever that value changes, no matter where in your code it happens. Gold for tracking down unexpected state mutations.

Console.app: The Hidden Debugging Tool
#

Xcode’s console is fine until you need to see everything: system logs, crash reports, the lot. That’s when I open Console.app.

If you’re using OSLog (you should be), Console.app makes those logs searchable and filterable:

import os.log

let logger = Logger(subsystem: "com.example.app", category: "networking")

logger.info("Starting API request")
logger.debug("Request URL: \(url.absoluteString)")
logger.error("Failed to decode: \(error.localizedDescription)")

Then in Console.app:

  1. Select your simulator or connected device
  2. Filter by your subsystem: subsystem:com.example.app
  3. Filter by level: subsystem:com.example.app AND level:error

You can save filter predicates for the sessions you run often. I’ve got one for network requests, one for database operations, and one that shows nothing but errors and warnings.

Why OSLog Over print()?
#

OSLog beats scattering print() statements everywhere. It’s structured, so you can filter by level and category. It’s fast enough that logging won’t slow the app down. It redacts sensitive data in production automatically, and the logs survive your app crashing.

print() is still fine for quick throwaway checks. Anything you might want to look at later belongs in OSLog.

Advanced Console.app Filtering
#

Console.app supports proper predicate queries. The ones I use regularly:

# All errors and faults in your app
subsystem:com.example.app AND (level:error OR level:fault)

# Network requests that failed
subsystem:com.example.app AND category:networking AND eventMessage CONTAINS "failed"

# Everything from the last 5 minutes
subsystem:com.example.app AND timestamp >= now(-5m)

You can also export logs to attach to bug reports, which beats copy-pasting out of Xcode’s console.

View Debugging
#

When the UI is broken and you can’t see why, the view debugger is the fastest way out.

Run the app, navigate to the broken screen, and click the “Debug View Hierarchy” button in Xcode’s debug bar (or press Cmd+Shift+D). You get a 3D exploded view of the whole hierarchy.

Rotate it around and things jump out:

  • Views rendering behind other views
  • Views sitting way off-screen
  • Views with zero size
  • The full constraint chain for whatever you select

I’ve used it to find invisible buttons swallowing tap events, labels rendering at 0x0, and an image that was accidentally 10,000 pixels wide.

Auto Layout Debugging
#

Purple warning icons in the view hierarchy mean constraint conflicts. Click one and Xcode shows exactly which constraints are fighting.

Give your constraints identifiers:

heightConstraint.identifier = "ProfileImageHeight"

When a constraint breaks, you’ll see "ProfileImageHeight" in the error instead of a memory address, which makes the log actually readable.

From LLDB you can also print the constraint tree:

(lldb) po view.hasAmbiguousLayout
(lldb) po view._autolayoutTrace()

Simulator Features That Save Time
#

Slow Animations
#

Debug → Slow Animations runs everything at 1/10 speed. Perfect for seeing what’s actually happening during a transition, or why an animation looks off.

Simulate Memory Warnings
#

Debug → Simulate Memory Warning tests how your app copes with low memory. I’ve found plenty of image caching bugs this way: everything works fine until the warning fires and suddenly all your images vanish.

Network Link Conditioner#

Xcode → Open Developer Tools → Network Link Conditioner

Simulate 3G, LTE, high packet loss, or complete offline mode. If an API request has ever worked fine on WiFi and timed out on cellular, this is how you reproduce it.

I keep a “Bad Network” profile with 500ms latency and 10% packet loss. It surfaces timeout bugs and loading-state problems you’d never see on fast office WiFi.

Status Bar Overrides
#

Right-click the status bar in the simulator and you can override the time, battery level, signal strength, and carrier. Handy for consistent screenshots, or for checking how the UI looks at different battery levels.

Location Simulation
#

Debug → Location simulates different locations without leaving your desk: custom coordinates, city walks, freeway drives. Very useful for location-based features, or bugs that only show up in certain regions.

Memory Debugging
#

Debug Memory Graph
#

Click the Debug Memory Graph button (Cmd+Shift+M) and Xcode shows every object in memory, their relationships, and the part you actually care about: which ones are leaking.

Purple exclamation marks mean leaks. Click one and you can see the retain cycle.

The most common leak I see:

class ViewController: UIViewController {
    var onComplete: (() -> Void)?

    func setupHandler() {
        onComplete = {
            self.dismiss(animated: true)  // ❌ Captures self strongly
        }
    }
}

Fix it with weak self:

onComplete = { [weak self] in
    self?.dismiss(animated: true)  // ✓ No retain cycle
}

Delegates need to be weak too:

weak var delegate: ManagerDelegate?  // Not just 'var'

Instruments
#

For serious memory investigation, use Instruments (Product → Profile, or Cmd+I).

The Allocations instrument shows every object allocation, memory growth over time, which classes are using the most memory, and stack traces for where each allocation happened.

What I’m usually looking for: memory that grows linearly over time (probably a leak), unexpectedly large allocations (did I just load all 1000 images at once?), and objects that should have been deallocated but weren’t.

Mark generations (the little flag button) before and after an action to see what’s persisting when it shouldn’t.

Performance Profiling
#

Time Profiler
#

Product → Profile → Time Profiler. Record while doing the slow thing in your app, then read the call tree.

Sort by “Self Weight” to find the bottlenecks. If a function shows 40% self weight, that’s where your time is going.

I once found a JSON parsing function being called 1000 times a second. Moved it to a background queue and the UI stopped stuttering.

Main Thread Checker
#

Xcode automatically catches UI updates on background threads. If you see this:

Main Thread Checker: UI API called on a background thread: -[UILabel setText:]

you did something like this:

URLSession.shared.dataTask(with: url) { data, response, error in
    self.label.text = "Loaded"  // ❌ Crash!
}

Fix it:

URLSession.shared.dataTask(with: url) { data, response, error in
    DispatchQueue.main.async {
        self.label.text = "Loaded"  // ✓
    }
}

Runtime Diagnostics
#

Edit Scheme → Run → Diagnostics. There’s a row of checkboxes in here that catch bugs you’d never find by hand.

Address Sanitizer
#

Finds memory corruption: use-after-free errors, buffer overflows, memory leaks. It makes the app 2-3x slower, but it catches bugs that are near impossible to track down any other way. I turn it on when chasing crashes that only happen sometimes.

Thread Sanitizer
#

Catches data races and threading bugs. Enable it, run the app, and use it normally; if there’s a race condition in there, Thread Sanitizer will find it.

You can’t run Address Sanitizer and Thread Sanitizer at the same time, so I alternate between them when a crash is being weird.

Zombie Objects
#

Catches messages sent to deallocated objects. With it enabled, Xcode tells you exactly when you’re touching freed memory:

*** -[MyViewController viewDidLoad]: message sent to deallocated instance 0x600001234000

Don’t leave it on permanently. It stops objects from being deallocated, so memory climbs forever. But for pinning down a specific use-after-free bug, it’s great.

Common Debugging Scenarios
#

App Crashes on Launch
#

Add an exception breakpoint first; that usually catches it.

If not, the usual suspects are missing keys in Info.plist, force-unwrapped optionals in initialisation code, or dependency injection failing.

UI Not Updating
#

Every. Single. Time. It’s one of:

  1. The update isn’t on the main thread
  2. The outlet isn’t connected
  3. The view isn’t actually visible (check with po view.window in LLDB; if it returns nil, the view isn’t in the hierarchy)

And for table or collection views: you forgot to call reloadData().

Memory Warnings Crashing the App
#

Use Instruments Allocations to see what’s holding memory. It’s usually images never released from a cache, view controllers that won’t deallocate (retain cycle), or some array or dictionary growing forever.

Simulate memory warnings in the simulator to test your cleanup code.

Mysterious Crashes
#

Turn on the sanitizers. Intermittent usually means threading, so run with Thread Sanitizer. Crashing inside system frameworks usually means memory corruption, so run with Address Sanitizer.

Debugging OTA Updates in the Simulator
#

My app ships over-the-air updates, and the simulator is the easiest place to debug that whole flow.

I start by watching the network traffic. Console.app filtered to the app’s subsystem shows the manifest requests and responses in real time, so you can see exactly which headers go out and what the server sends back.

Then I make the network bad on purpose. Network Link Conditioner shows you how the update flow copes with slow connections and packet loss. Does the app hang? Show a loading indicator? Fall back gracefully?

Around the update check itself, I log everything: when the check starts, when an update is available, when the download begins, and when the new bundle loads.

import os.log

let logger = Logger(subsystem: "com.example.app", category: "updates")

logger.info("Checking for updates...")
let update = try await Updates.checkForUpdateAsync()
logger.info("Update available: \(update.isAvailable)")

Finally, it’s worth testing the manifest endpoint on its own. You can curl it with the same headers the app sends:

curl -H "expo-protocol-version: 1" \
     -H "expo-platform: ios" \
     -H "expo-runtime-version: 1.0.0" \
     https://your-server.com/api/expo-updates/manifest/

That confirms the server is returning the right data before you go digging into the client.

Third-Party Tools
#

Charles Proxy / Proxyman
#

See all your network traffic, modify requests and responses, test error conditions. Essential for API debugging.

Proxyman has a nicer UI than Charles and feels more at home on macOS. I switched last year and haven’t looked back.

Reveal
#

Like Xcode’s view debugger, but more capable. It works on physical devices without being attached to Xcode, which makes it great for chasing layout issues on testers’ devices.

Debugging on Real Devices
#

Wireless Debugging
#

Plug the device in over USB once, go to Window → Devices and Simulators, tick “Connect via network”, then unplug. The device stays available in Xcode as long as you’re on the same WiFi.

Device Console
#

Window → Devices and Simulators → Open Console. This shows everything: system logs, app logs, crash reports. Far more detail than Xcode’s own console.

When a tester tells me “the app crashed”, I always ask them to plug their device in so I can grab the console logs. There’s often a system-level error sitting right there that explains the whole thing.

What I Actually Do When Debugging
#

  1. Add an exception breakpoint if there isn’t one already
  2. Reproduce the bug
  3. Set a breakpoint near where I think it’s going wrong
  4. Check values with po in LLDB
  5. Modify variables to test fixes without recompiling
  6. Check the view hierarchy if it’s UI-related
  7. Profile with Instruments if it’s performance-related
  8. Turn on the sanitizers if it’s memory or threading

The trick is working backwards from the symptom. Crash? Exception breakpoint. Slow? Time Profiler. Memory? Instruments. Weird UI? View debugger.

Tips I Wish I’d Known Earlier
#

Use assertions. They catch logic errors before they turn into bugs:

assert(users.count > 0, "Users array should never be empty here")

Commit before you start debugging. Once you’re changing things to test theories, you’ll want a clean point to revert to.

Delete your print statements when you’re done, or at least wrap them in #if DEBUG. Old debug logs make the next bug harder to see.

Learn LLDB properly. It’s faster than rebuilding your app twenty times.

And remember the simulator is not a real phone. Bugs that only appear on device are almost always threading or memory. Or performance, because the simulator runs on your Mac’s CPU rather than an actual iPhone chip.

Tools I Keep Open
#

  • Xcode, obviously
  • Console.app filtered to my app’s subsystem
  • Proxyman for network debugging
  • Instruments when things get serious

No single tool covers everything. The view debugger won’t find a memory leak, and Instruments won’t untangle an Auto Layout conflict, so most of the skill is matching the tool to the symptom. That part just comes with repetition: after enough retain cycles you start to smell them from the stack trace alone. Debugging never becomes fun exactly, but it does stop being miserable.