I spend way too much time debugging. Everyone does. But Xcode has some surprisingly powerful tools that most developers barely touch. This is the debugging workflow I’ve built up over the years—the stuff that actually saves time when things break.
Breakpoints Are More Powerful Than You Think#
Most people click the gutter to set a breakpoint and that’s it. But right-click one and you’ll find a bunch of options that’ll change how you debug.
Conditional Breakpoints#
Let’s say you’ve got a loop processing 1000 items, but only item 347 is causing issues. You don’t want to hit that breakpoint 346 times.
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 will only stop when that’s true. Same works for things like userId == "abc123" or error != nil.
Symbolic Breakpoints#
These are great when you don’t know where a method is being called. Go to the breakpoint navigator (Cmd+8), click the + button, and choose “Symbolic Breakpoint.”
Want to see every time any view controller loads? Set symbol to viewDidLoad. Xcode will pause on every single one. Too noisy? Narrow it down: MyViewController.viewDidLoad.
The most useful ones I keep around:
UIViewAlertForUnsatisfiableConstraints- catches Auto Layout conflicts immediatelyobjc_exception_throw- breaks on any Objective-C exception before the crashmalloc_error_break- finds memory allocation issues
Exception Breakpoints#
This should probably be the first thing you do in any new project. Add an exception breakpoint (in the breakpoint navigator, click + → Exception Breakpoint) and Xcode will pause right when something throws, not after the app has already crashed.
I’ve had this catch so many nil unwrapping issues that would otherwise just show up as mysterious crashes in the console.
Breakpoint Actions#
Here’s where it gets interesting. Breakpoints don’t have to stop execution—they can just log things or play sounds.
Edit a breakpoint, click “Add Action”, and you 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)
Check “Automatically continue after evaluating actions” and your breakpoint becomes a logger that doesn’t interrupt flow.
LLDB: The Console Commands You’ll Actually Use#
When you hit a breakpoint, that console at the bottom isn’t just for looking at crash logs. It’s LLDB, and it’s incredibly 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 what I use.
Want more detail? Try p instead:
(lldb) p user.name
(String) $R0 = "John Doe"See all local variables at once:
(lldb) frame variableChanging Things at Runtime#
This is the real power move. You can modify variables without recompiling:
(lldb) expr user.name = "Jane Doe"
(lldb) expr index = 0Found a bug and want to test a fix without rebuilding? Just change the variable and continue. I’ve used this to test different values when tracking down edge cases.
You can even call methods:
(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 functionWatchpoints#
Want to know when a variable changes? Set a watchpoint:
(lldb) watchpoint set variable user.isLoggedInNow execution will pause whenever that value changes, from anywhere in your code. This is gold for tracking down unexpected state mutations.
Console.app: The Hidden Debugging Tool#
Xcode’s console is fine, but when you need to see everything—system logs, crash reports, the works—open Console.app.
If you’re using OSLog (and 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:
- Select your simulator or connected device
- Filter by your subsystem:
subsystem:com.example.app - Filter by level:
subsystem:com.example.app AND level:error
You can save filter predicates for common debugging sessions. I’ve got one for network requests, one for database operations, and one that just shows errors and warnings.
Why OSLog Over print()?#
OSLog is way better than scattering print() statements everywhere:
- It’s structured—you can filter by level and category
- It’s fast—logging is optimized and won’t slow your app
- It automatically redacts sensitive data in production
- The logs persist even after your app crashes
Use print() for quick debugging. Use OSLog for anything you might want to look at later.
Advanced Console.app Filtering#
Console.app supports powerful predicate queries. Here are some 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 share with your team or attach to bug reports. Way better than trying to copy-paste from Xcode’s console.
View Debugging#
When your UI is broken and you can’t figure out why, the view debugger is a lifesaver.
Run your 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 every view in your hierarchy.
Rotate it around and you’ll see:
- Views that are rendering but hidden behind other views
- Views that are way off-screen
- Views that have zero size
- The entire constraint chain for any selected view
I’ve used this to find invisible buttons that were blocking tap events, labels that were rendering at 0x0, and images that were accidentally 10,000 pixels wide.
Auto Layout Debugging#
Purple warning icons in the view hierarchy mean constraint conflicts. Click them and Xcode shows exactly which constraints are fighting each other.
Pro tip: Give your constraints identifiers:
heightConstraint.identifier = "ProfileImageHeight"When constraints break, you’ll see "ProfileImageHeight" in the error instead of a memory address. Makes debugging way easier.
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 slows everything to 1/10 speed. Perfect for seeing what’s happening during transitions or understanding why an animation looks weird.
Simulate Memory Warnings#
Debug → Simulate Memory Warning. Tests how your app handles low memory. I’ve found so many image caching bugs this way—stuff that works fine until you trigger a memory warning and suddenly all your images disappear.
Network Link Conditioner#
Xcode → Open Developer Tools → Network Link Conditioner
Simulate 3G, LTE, high packet loss, or complete offline mode. If you’ve ever had an API request work fine on WiFi but time out on cellular, this is why.
I keep a “Bad Network” profile with 500ms latency and 10% packet loss. It catches timeout bugs and loading state issues that you’d never see on your fast office WiFi.
Status Bar Overrides#
Right-click the status bar in the simulator and you can override time, battery level, signal strength, and carrier. Useful for consistent screenshots or testing how your UI looks at different battery levels.
Location Simulation#
Debug → Location lets you simulate different locations without leaving your desk. Custom locations, city walks, freeway drives—all available. Super helpful when testing location-based features or debugging issues that only happen in certain regions.
Memory Debugging#
Debug Memory Graph#
Click the Debug Memory Graph button (Cmd+Shift+M) and Xcode shows you every object in memory, their relationships, and—crucially—which ones are leaking.
Purple exclamation marks mean leaks. Click one and you’ll 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 you:
- Every object allocation
- Memory growth over time
- Which classes are using the most memory
- Stack traces showing where allocations happened
I usually look for:
- Memory that grows linearly over time (probably a leak)
- Unexpected large allocations (did I accidentally load all 1000 images at once?)
- Objects that should be deallocated but aren’t
Mark generations (the little flag button) before and after actions to see what’s persisting when it shouldn’t.
Performance Profiling#
Time Profiler#
Product → Profile → Time Profiler. Record while doing something slow in your app, then look at the call tree.
Sort by “Self Weight” to find the bottlenecks. If a function shows 40% self weight, that’s where you should optimize.
I once found a JSON parsing function that was getting called 1000 times per 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 bunch of checkboxes here that catch bugs you’d never find manually.
Address Sanitizer#
Finds memory corruption:
- Use-after-free errors
- Buffer overflows
- Memory leaks
It makes your app 2-3x slower, but catches bugs that would otherwise be impossible to track down. I turn it on when chasing crashes that only happen sometimes.
Thread Sanitizer#
Catches data races and threading issues. Run your app with this enabled and interact with it normally. If you’ve got race conditions, Thread Sanitizer will find them.
You can’t run both Address Sanitizer and Thread Sanitizer at the same time, so I alternate between them when debugging weird crashes.
Zombie Objects#
Catches messages sent to deallocated objects. Enable it and Xcode will tell you exactly when you’re accessing freed memory:
*** -[MyViewController viewDidLoad]: message sent to deallocated instance 0x600001234000Don’t leave this on—it prevents objects from being deallocated, so memory usage will grow. But it’s great for finding specific use-after-free bugs.
Common Debugging Scenarios#
App Crashes on Launch#
Add an exception breakpoint first. That’ll usually catch it.
If not, check:
- Missing keys in Info.plist
- Force-unwrapped optionals in initialization code
- Dependency injection failing
UI Not Updating#
Every. Single. Time. It’s either:
- The update isn’t on the main thread
- The outlet isn’t connected
- The view isn’t actually visible (check with
po view.windowin LLDB—if it returns nil, your view isn’t in the hierarchy)
For table/collection views, you forgot to call reloadData().
Memory Warnings Crashing the App#
Use Instruments Allocations to see what’s using memory. Usually it’s:
- Images not being released from cache
- View controllers not deallocating (retain cycle)
- Some array or dictionary that’s growing forever
Simulate memory warnings in the simulator to test your cleanup code.
Mysterious Crashes#
Enable all the sanitizers. If it’s intermittent, it’s probably threading—run with Thread Sanitizer. If it’s in system frameworks, it’s probably memory corruption—run with Address Sanitizer.
Debugging OTA Updates in Simulator#
The simulator is perfect for debugging over-the-air update flows. Here’s what I do:
Watch the network traffic. Use Console.app filtered to your app’s subsystem to see manifest requests and responses in real-time. You’ll see exactly what headers are being sent and what the server returns.
Simulate bad networks. Use Network Link Conditioner to test how your update flow handles slow connections or packet loss. Does your app hang? Show a loading indicator? Fall back gracefully?
Check the bundle loading. Add logs around your update check logic. I usually log when checking for updates, when an update is available, when download starts, 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)")Test the manifest endpoint. You can manually curl your manifest endpoint 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/This helps verify your server is returning the right data before debugging the client side.
Third-Party Tools#
Charles Proxy / Proxyman#
See all network traffic. Modify requests and responses. Test error conditions. Essential for API debugging.
Proxyman has a better UI than Charles and feels more native on macOS. I switched to it last year and haven’t looked back.
Reveal#
Like Xcode’s view debugger but more powerful. Works on physical devices without needing to be attached to Xcode. Great for debugging layout issues on tester devices.
Debugging on Real Devices#
Wireless Debugging#
Connect your device via USB once, go to Window → Devices and Simulators, check “Connect via network”, then unplug. Your device will stay in Xcode as long as you’re on the same WiFi.
Device Console#
Window → Devices and Simulators → Open Console. This shows you everything: system logs, app logs, crash reports. Way more detailed than what you see in Xcode’s console.
When a tester says “the app crashed,” I always ask them to plug in their device so I can grab the console logs. Often there’s a system-level error that explains everything.
What I Actually Do When Debugging#
- Add an exception breakpoint if I don’t have one
- Reproduce the bug
- Set a breakpoint near where I think it’s breaking
- Use
poin LLDB to check values - Modify variables to test fixes without recompiling
- Check the view hierarchy if it’s UI-related
- Profile with Instruments if it’s performance-related
- Turn on sanitizers if it’s memory or threading-related
The key is working backward 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 become bugs:
assert(users.count > 0, "Users array should never be empty here")Commit before debugging. If you start changing things to test theories, you’ll want to revert.
Delete your print statements. Or at least wrap them in #if DEBUG. Old debug logs make debugging new issues harder.
Learn LLDB. It’s faster than rebuilding your app 20 times.
The simulator is not real. Bugs that only appear on devices are always threading or memory issues. Or they’re performance-related because the simulator uses your Mac’s CPU, not 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
Debugging isn’t about knowing every tool—it’s about knowing which tool to grab when you see a specific symptom. The view debugger won’t help with a memory leak, and Instruments won’t help with Auto Layout conflicts.
The more you debug, the faster you’ll recognize patterns. “Oh, this is definitely a retain cycle.” “This looks like a main thread violation.” “This is probably a constraint priority issue.”
Build that intuition and debugging gets way less frustrating.

