
Debug iOS Objective-C Runtime Crashes: Selectors & KVO
Every iOS developer eventually stares at a console line that ends in "unrecognized selector sent to instance," or hits an EXC_BAD_ACCESS with no obvious cause. These are Objective-C runtime crashes — failures in the dynamic-dispatch machinery that sits underneath every message send, every KVO observation, and every notification in a Cocoa app. Because Objective-C resolves methods and manages object lifetimes at runtime rather than at compile time, these bugs slip past the compiler and only detonate once the app is live in a user's hands. In this guide we break down the four most common Objective-C runtime crash clusters — unrecognized selectors, KVO observer-lifecycle failures, NSNotificationCenter dangling observers, and over-release EXC_BAD_ACCESS — and show you exactly how to reproduce, isolate, and fix each one, then how to catch them automatically before they ever reach production.
Why Objective-C crashes surface at runtime
Objective-C is a dynamically dispatched language. When you write [object doSomething], the compiler emits a call to objc_msgSend(object, @selector(doSomething)), and the real method implementation is located only when that line actually executes (Apple's Objective-C runtime reference). At dispatch time the runtime walks the receiving object's method table; if the selector isn't found, it tries a chain of fallbacks — resolveInstanceMethod:, forwardingTargetForSelector:, and finally a full forwardInvocation:. Only when every fallback is exhausted does the runtime raise NSInvalidArgumentException with the "unrecognized selector sent to instance" message.
That indirection is what powers categories, KVO's isa-swizzling, and dynamic method resolution, but it has a real cost: an entire category of bugs — the wrong object type stored in an id, a stale delegate, a dangling pointer, an observer that was never unregistered — is completely invisible to the compiler. These mistakes don't produce a build error or even a warning; they simply wait until runtime and then crash. That is why an Objective-C runtime crash shows up so often in production builds and so rarely in unit tests.
Crash #1: "unrecognized selector sent to instance"
The exception text usually tells you most of what you need, if you know how to read it:
*** Terminating app due to uncaught exception 'NSInvalidArgumentException',
reason: '-[__NSCFString objectForKey:]: unrecognized selector sent to instance 0x600002a1c4f0'Three pieces of information are embedded in that single line: the class of the receiving object (__NSCFString, the private class behind NSString), the selector that could not be dispatched (objectForKey:), and the object's memory address. The class name is usually the smoking gun. The five most common causes are:
- A selector typo —
@selector(butonTapped:)instead ofbuttonTapped:, or a mismatched trailing colon. - The wrong object type stored in an
idor a loosely typed property. - A delegate or data source pointing at an object that never implemented the protocol method.
- A deallocated object whose memory was reused by a different class, so the message lands on the wrong type entirely.
- A category that was never linked into the target — the classic
-ObjC/-all_loadlinker-flag problem for static libraries (Apple QA1490).
// dataSource is typed id but actually points at an NSArray
id dataSource = @[@"alpha", @"beta"];
NSString *name = [dataSource objectForKey:@"name"]; // unrecognized selector crashTo debug it, start from the class name in the reason. Set a breakpoint on objc_exception_throw, print the object's class with po [obj class], and trace where the value was assigned. If the class looks unrelated to what you expected, suspect a dangling pointer and enable Zombie Objects (covered below). If a category method is mysteriously missing, verify your static-library linker flags before chasing anything else.
The runtime's second chance: message forwarding
It is worth understanding that "unrecognized selector" is genuinely the last stop. Before raising the exception, the runtime gives your class three chances to handle the unknown message: resolveInstanceMethod: (add a method dynamically), forwardingTargetForSelector: (hand the message to another object), and forwardInvocation: (build an NSInvocation and do anything with it). Many real-world crashes come from code that used to rely on forwarding — for example a proxy or mock object — that silently stopped forwarding after a refactor. If you implement forwarding, log every path through it so a message that falls through is visible instead of vanishing into a crash (Apple's exception programming guide).
Crash #2: KVO observer-lifecycle failures
Key-value observing works by having the runtime swizzle your object's isa pointer to a generated subclass that intercepts setter calls (Apple's KVO programming guide). When you register with addObserver:forKeyPath:options:context:, the runtime records that observation — and it is entirely your responsibility to remove it before either the observer or the observed object is deallocated. Miss that cleanup and you'll hit:
*** Terminating app due to uncaught exception 'NSInternalInconsistencyException',
reason: 'An instance 0x600001234000 of class MyModel was deallocated while key value observers were still registered with it.'The patterns that cause this are remarkably consistent across apps:
- Registering an observer in
viewDidLoad:but never removing it indealloc. - Removing an observer you never added — or removing the same one twice — which throws the mirror-image exception.
- Observing a key path that a parent object later re-binds or deallocates.
- Observing
selfinsideinitand never balancing the removal.
- (void)viewDidLoad {
[super viewDidLoad];
[self.model addObserver:self
forKeyPath:@"status"
options:NSKeyValueObservingOptionNew
context:StatusContext];
}
- (void)dealloc {
// BUG: missing [self.model removeObserver:self forKeyPath:@"status"];
}The fix is symmetric registration: pair every addObserver: with exactly one removeObserver:forKeyPath: in the same lifetime, track whether you've added the observer with a boolean flag, and prefer the block-based KVO API introduced in iOS 11. The block API returns a token that you store in a property and invalidate in dealloc, which makes the pairing almost impossible to get wrong because the add and remove live next to each other.
Crash #3: NSNotificationCenter dangling observers
Notifications are the other half of the observer-lifecycle story. Before iOS 9, an observer had to be explicitly unregistered with removeObserver: before deallocation; skip it and the notification center held a dangling pointer, so the next posted notification crashed with EXC_BAD_ACCESS. Since iOS 9 the center unregisters an object automatically when it deallocates (Apple's NSNotificationCenter documentation), but two sharp edges remain:
- Double-removing an observer can still throw on some paths, so a balanced flag matters.
- The block-based API
addObserverForName:object:queue:usingBlock:returns an opaque token, and that token — notself— is the observer you must retain and later remove. Automatic cleanup does not apply the way you might expect.
self.observerToken = [[NSNotificationCenter defaultCenter]
addObserverForName:UIApplicationDidBecomeActiveNotification
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:^(NSNotification *note) {
[self refresh];
}];
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self.observerToken];
}Treat notifications exactly like KVO: symmetric add/remove, a flag to prevent double removal, and a hard rule that anything registered in viewDidLoad is unregistered in dealloc.
Crash #4: EXC_BAD_ACCESS, over-release, and Zombie objects
EXC_BAD_ACCESS means your process touched memory it does not own — almost always a dangling pointer. In manual-reference-counted code, or anywhere you use __unsafe_unretained, the usual cause is an over-release: one release or autorelease too many, the object is deallocated, and a later message or property access dereferences freed memory.
NSObject *obj = [[NSObject alloc] init];
[obj release];
[obj release]; // over-release: obj is already deallocated
NSLog(@"%@", obj); // EXC_BAD_ACCESS (may not crash immediately)The most valuable tool for this class of bug is Zombie objects. When zombies are enabled, the runtime does not actually free deallocated objects; it keeps their memory and swaps the object's isa pointer for a special "zombie" class that logs every message sent to it (Apple Technical Note TN2124). The silent EXC_BAD_ACCESS becomes a precise, nameable log line. In Xcode, turn zombies on under Edit Scheme → Run → Diagnostics → "Zombie Objects", or set the environment variable for a single run:
NSZombieEnabled=YESWith zombies on, the crash message becomes something like *** -[MyModel retain]: message sent to deallocated instance 0x600002a1c4f0, pinning the bug to a specific object and message. Pair it with the Allocations instrument's malloc history to see exactly where the object was freed, and you have turned an opaque memory crash into a nameable culprit.
A systematic debugging workflow
When one of these crashes lands in your queue, don't guess — run the same loop every time:
- Reproduce and capture. Get the crash report and, if possible, a reliable repro. A crash you can trigger on demand is half-solved.
- Symbolicate the stack. Match the memory addresses in the report to your source symbols so the stack trace names real methods rather than hex offsets — see our stack trace symbolication guide.
- Read the exception reason. For NSException-based crashes the reason string names the class and selector; for EXC_BAD_ACCESS, flip on zombies.
- Bisect. If the crash appeared recently, use
git bisector a release diff to find the commit that introduced it, then fix at the source rather than patching around it.
For memory-corruption variants that zombies alone can't pin down, escalate to a sanitizer — AddressSanitizer catches use-after-free and over-release at the exact line. We've covered that workflow in depth in our guide to native memory corruption debugging with ASan.
Catch Objective-C runtime crashes before your users do
These four crash families share one property: they almost never appear on the happy path, which is exactly why they reach production in the first place. The only reliable way to stay ahead of them is to instrument your app and watch for them in the wild. A crash-monitoring tool captures the full stack trace, exception reason, and device context the moment an Objective-C runtime crash fires, so you can see whether a new build introduced "unrecognized selector" regressions or whether an observer was never cleaned up on a specific iOS version.
That's exactly what Bugspulse is built for. Bugspulse gives you real-time crash reporting, symbolized stack traces, and error fingerprinting that deduplicates the same runtime crash across thousands of devices into a single actionable issue — all from bugspulse.com. Whether you're chasing an unrecognized selector in a category or an EXC_BAD_ACCESS that only reproduces on one device model, Bugspulse shows you the exact line and the release that introduced it.
Start monitoring your iOS app today and catch Objective-C runtime crashes before your users do.