Key-Value Observing: Automatic Change Notifications and Their Crash Risks
Understand Key-Value Observing (KVO) as the mechanism built on top of KVC that automatically notifies observers when a property changes, including how to register and remove observers correctly, how automatic and manual change notification work, why context pointers matter, and the serious crash risks that come from mismanaging observer lifetime.
Key-Value Observing extends Key-Value Coding with a notification layer: once a property is KVC-compliant, any object can register itself as an observer of that property on a given instance and be called back automatically whenever the property's value changes. It is the mechanism underneath Cocoa Bindings, underneath a great deal of Core Data's automatic UI updating, and underneath countless places in both AppKit and UIKit-adjacent code where one object needs to react to another object's state changing without polling it. KVO is also, notoriously, one of the more crash-prone corners of classic Objective-C, because its registration and removal are manual, unbalanced calls with no automatic cleanup — get them wrong, in either direction, and the result is frequently a hard crash with a famously cryptic error message. This lesson works through observeValueForKeyPath:ofObject:change:context:, the old/new value dictionary, the distinction between automatic and manual change notification, why context pointers exist, and treats observer lifetime and removal as the dedicated, serious topic the spec calls it out to be.
Registering an observer
You register interest in a property with addObserver:forKeyPath:options:context:, sent to the object whose property you want to watch. The observer parameter is the object that wants to be told about changes, the key path names the property (or nested property, via a dotted key path exactly as in ordinary KVC), options is a bitmask describing what information you want delivered with each notification, and context is an opaque pointer you supply and get back unchanged in the callback, used to disambiguate which registration a given notification corresponds to.
Once registered, the observing object is called back through a single, fixed method — observeValueForKeyPath:ofObject:change:context: — every time the observed property changes, for as long as the registration remains active. Unlike delegation, where each callback is a distinct method the delegate implements, KVO funnels every observed change for every registered key path through this one method, and it is your responsibility inside it to inspect the key path, object, and context to figure out which specific change just happened.
This single-callback design is efficient and uniform, but it means a class observing several different key paths on several different objects needs its own internal dispatch logic — typically a chain of if or switch statements comparing the incoming keyPath and context against what was registered — to route each notification to the right handling code.
The observeValueForKeyPath: callback and change dictionary
observeValueForKeyPath:ofObject:change:context: receives four parameters: the key path that changed, the object it changed on, a change dictionary describing the change, and the same context pointer passed at registration time. The change dictionary is where the actual before/after information lives, keyed by constants like NSKeyValueChangeKindKey (describing whether this was a setting, insertion, removal, or replacement), NSKeyValueChangeOldKey, and NSKeyValueChangeNewKey.
Whether NSKeyValueChangeOldKey and NSKeyValueChangeNewKey are actually present in the dictionary depends entirely on which options you requested at registration time — passing NSKeyValueObservingOptionOld includes the old value, NSKeyValueObservingOptionNew includes the new value, and passing neither (or omitting options entirely) delivers a change dictionary with just the change kind and no old or new value at all. Requesting both is common when you need to compare before and after, but it is not free — Cocoa has to snapshot the old value before the change happens, which is extra work you should skip if you only ever need the new value.
Scalar and struct values inside the change dictionary go through the same automatic boxing KVC uses elsewhere: an int property's old and new values arrive as NSNumber, a CGRect's as NSValue, and your callback needs to unbox them the same way any other KVC read would.
Automatic vs. manual change notification
For the overwhelming majority of properties, KVO's notifications fire entirely automatically, with no extra code from you at all. This is called automatic key-value observing compliance, and it works because the compiler-synthesized setter for an ordinary @property already goes through a pattern KVO can hook into transparently — any change made through the standard setter path triggers the corresponding willChange/didChange machinery behind the scenes, invisibly to your code.
Some properties don't go through that standard path, though — most commonly a custom setter that mutates an underlying instance variable or a collection directly rather than calling through the usual synthesized accessor pattern, or a computed property whose value depends on some other piece of state changing rather than being set directly itself. For those cases, KVO offers manual notification: you disable automatic notification for that key by overriding automaticallyNotifiesObserversForKey: to return NO for it, and then you personally bracket the mutation with willChangeValueForKey: before it happens and didChangeValueForKey: right after, which is what actually triggers observers to be called.
Manual notification is also how you correctly notify observers of a computed property that changes only because some other property it depends on changed — for instance, a fullName property computed from firstName and lastName needs its own willChangeValueForKey:/didChangeValueForKey: calls (or, more commonly, a declared keyPathsForValuesAffectingFullName class method telling KVO about the dependency) so that observers of fullName are told when firstName changes, even though nothing ever calls a setter named setFullName: directly.
Why context pointers matter
The context parameter looks optional and is easy to pass as NULL, but doing so is a real mistake in any nontrivial class hierarchy. Its purpose is to let observeValueForKeyPath:ofObject:change:context: definitively identify which specific addObserver:... call a given callback invocation is answering, independent of the key path and object — which matters enormously when a superclass and a subclass both happen to register themselves as observers of the same key path on the same object, or when your class observes the same key path on two different objects and needs to tell them apart cheaply.
The conventional pattern is a private static void * (or a static context variable whose address you take), unique per observation you register, declared once at file scope so its address never changes: static void * const MyObservationContext = &MyObservationContext; is a common idiom, since taking a variable's own address as its value guarantees a genuinely unique pointer that can never collide with another class's context by accident.
Inside the callback, you check the incoming context against your own class's context pointer first — if (context == MyObservationContext) — before falling through to a superclass implementation for any context you don't recognize, via [super observeValueForKeyPath:keyPath ofObject:object change:change context:context]. This pattern lets each class in a hierarchy handle only the observations it registered, correctly delegating everything else upward instead of accidentally swallowing or double-handling a superclass's notifications.
Removing observers correctly
Every call to addObserver:forKeyPath:options:context: must be matched, at some point before both the observer and the observed object are deallocated, by a corresponding call to removeObserver:forKeyPath: (or the context-qualified removeObserver:forKeyPath:context: variant, preferred when you registered with a specific context, since it lets you remove exactly the registration that context identifies rather than all registrations for that key path).
The timing matters as much as the call itself. The removal needs to happen while both objects are still alive — typically in dealloc for a long-lived observer relationship, or earlier, at whatever point the observation relationship naturally ends, such as when a view controller is about to be discarded or a child object is about to be replaced. Removing too early leaves changes unobserved when you still wanted them; removing too late, or not at all, is where KVO's most notorious crashes come from.
Because registration and removal are unbalanced manual calls with no automatic cleanup on either side — nothing about ARC's reference counting knows anything about KVO registrations — it is entirely possible to register an observer and simply forget to remove it, or to remove an observer registration that was never actually made, and both mistakes fail in different but equally unpleasant ways.
Crash risks from incorrect observer management
The single most infamous KVO crash happens when an observed object is deallocated while an observer is still registered on it — or, more precisely, when the observing object itself is deallocated without having removed its registration first, leaving the observed object holding a KVO registration that points at a now-dangling observer. The resulting crash is distinctive enough to be widely recognized on sight: a message along the lines of -[NSKeyValueObservationInfo ...] An instance ... was deallocated while key value observers were still registered with it, typically firing not at the moment of the actual mistake but later, whenever the observed property next changes and KVO tries to notify an observer that no longer exists — which makes the crash's stack trace often misleadingly far removed from the code that actually caused it.
The mirror-image mistake — calling removeObserver:forKeyPath: for a registration that was never made, or removing the same registration twice — is equally fatal, raising an NSRangeException-flavored crash complaining that the object is not registered as an observer for that key path. This one is common after a refactor that changes conditional registration logic: if the code path that adds an observer becomes conditional but the code path that removes it stays unconditional, the removal eventually runs with nothing to remove.
Both failure modes are, in practice, why many teams reach for a small owning wrapper around each observation — an object that performs the add in its initializer and the remove in its own dealloc, so the observation's lifetime is tied to a single object's lifetime instead of being hand-balanced across two separate call sites in two separate places in the code — or, in modern Objective-C, why NSKeyValueObservation-based block APIs and Swift-side alternatives exist at all: they exist specifically to make this balance automatic rather than manual.
Modern observation alternatives
Classic, selector-based KVO — addObserver:forKeyPath:options:context: paired with a single observeValueForKeyPath:ofObject:change:context: override — remains the foundational mechanism in Objective-C, and it is still exactly what you'll find underneath most existing Objective-C codebases and a great deal of Cocoa framework internals. But it is not the only option available even from Objective-C: NSObject's block-based observation API, -observeValueForKeyPath:... aside, also has a block-taking registration variant on newer SDKs that returns an NSKeyValueObservation token whose own deallocation automatically removes the observation, sidestepping the manual-removal crash risk entirely by tying removal to ordinary ARC lifetime instead of a hand-written dealloc call.
In Swift-first codebases, KVO in this classic Objective-C form is increasingly superseded by Combine publishers, closures, or other reactive patterns that express the same "react when this value changes" idea with compiler-checked property references instead of string key paths, and with automatic, ARC-integrated cleanup instead of manual add/remove pairs. But none of that removes classic KVO's relevance for an Objective-C course: it is still the mechanism you will actually encounter reading and maintaining existing Objective-C code, it still underlies a large amount of framework behavior you'll interact with indirectly even from Swift, and understanding its manual, string-keyed, unbalanced-by-default design is exactly what makes clear why the newer alternatives were built the way they were.
KVO registration, callback, and removal
Always define a unique static context pointer per observation rather than passing NULL. Every addObserver: call must be matched by a removeObserver: call before either object is deallocated. Fall through to [super observeValueForKeyPath:...] for any context you don't recognize.
// Registration
[object addObserver:self
forKeyPath:@"someProperty"
options:(NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew)
context:MyObservationContext];
// Callback
- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary<NSKeyValueChangeKey, id> *)change
context:(void *)context {
if (context == MyObservationContext) {
id oldValue = change[NSKeyValueChangeOldKey];
id newValue = change[NSKeyValueChangeNewKey];
// handle the change
return;
}
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
// Removal (matched to the registration above)
[object removeObserver:self forKeyPath:@"someProperty" context:MyObservationContext];Code Examples
The observation is registered in the initializer and removed in dealloc, tying the observation's lifetime to BalanceLabel's own lifetime so it can never be forgotten. The unique static context AccountBalanceContext lets the callback confidently identify this specific registration before touching the change dictionary, and falls through to super for anything else, which matters if BalanceLabel is ever subclassed by something that adds its own observations.
[_account removeObserver:self forKeyPath:@"balance" context:AccountBalanceContext];
Common mistake: Forgetting the dealloc removal here is exactly what produces the classic 'was deallocated while key value observers were still registered' crash.
itemCount is a computed property (presumably backed by _items.count) rather than one with its own stored setter, so automatic notification never fires for it. Overriding automaticallyNotifiesObserversForKey: to opt itemCount out of automatic notification, then manually bracketing the mutation that actually changes the count with willChangeValueForKey:/didChangeValueForKey:, is what makes observers of itemCount actually get notified when addItem: is called.
[self willChangeValueForKey:@"itemCount"];
When a LeakyObserver instance is deallocated, nothing removes its registration from account. The next time account's balance changes, KVO tries to deliver a notification to an observer that no longer exists, producing the well-known -[NSKeyValueObservationInfo ...] crash reporting that an instance was deallocated while key value observers were still registered with it -- with a stack trace pointing at the unrelated code that changed balance, not at LeakyObserver's missing dealloc.
// No dealloc override -- the observation is never removed.
Common mistake: This class has no dealloc override at all, so its KVO registration outlives the object that made it.
static void * const AccountBalanceContext = &AccountBalanceContext;
@implementation BalanceLabel
- (instancetype)initWithAccount:(Account *)account {
self = [super init];
if (self) {
_account = account;
[_account addObserver:self
forKeyPath:@"balance"
options:(NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew)
context:AccountBalanceContext];
}
return self;
}
- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary *)change
context:(void *)context {
if (context == AccountBalanceContext) {
NSNumber *oldBalance = change[NSKeyValueChangeOldKey];
NSNumber *newBalance = change[NSKeyValueChangeNewKey];
NSLog(@"Balance changed from %@ to %@", oldBalance, newBalance);
return;
}
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
- (void)dealloc {
[_account removeObserver:self forKeyPath:@"balance" context:AccountBalanceContext];
}
@end+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
if ([key isEqualToString:@"itemCount"]) {
return NO;
}
return [super automaticallyNotifiesObserversForKey:key];
}
- (void)addItem:(id)item {
[self willChangeValueForKey:@"itemCount"];
[_items addObject:item];
[self didChangeValueForKey:@"itemCount"];
}// WARNING: illustrates the crash, not the fix
@implementation LeakyObserver
- (instancetype)initWithAccount:(Account *)account {
self = [super init];
if (self) {
[account addObserver:self forKeyPath:@"balance" options:0 context:nil];
}
return self;
}
// No dealloc override -- the observation is never removed.
@endWhere KVO shows up whether you call it directly or not
Cocoa Bindings on macOS is built almost entirely on top of KVO — a bound UI control observes a controller's key path and updates itself automatically whenever that value changes, with essentially no glue code from the application developer. NSOperation's isFinished and isExecuting properties are historically KVO-compliant specifically so that an operation queue can observe them and know when to schedule the next operation, without polling. AVFoundation's playback APIs use KVO extensively — observing an AVPlayerItem's status property is the standard way to find out when a video has finished loading and is ready to play. Any time you see code overriding observeValueForKeyPath:ofObject:change:context:, or calling addObserver:forKeyPath:options:context:, in an existing Objective-C codebase, it's worth checking immediately whether the matching removeObserver: call exists somewhere reachable — a missing one is one of the single most common causes of hard-to-reproduce crashes in older Objective-C apps.
[account addObserver:self forKeyPath:@"balance" options:0 context:NULL]; // no dealloc removal
// register in init, and always pair it with a removal in dealloc - (void)dealloc { [account removeObserver:self forKeyPath:@"balance" context:AccountBalanceContext]; }
Registering an observer without ever removing it leaves a dangling registration once the observer is deallocated, producing the classic 'was deallocated while key value observers were still registered' crash the next time the observed property changes.
[account removeObserver:self forKeyPath:@"balance"]; // called from a path where it was never added
// only remove an observation that this exact code path actually registered, and track that state if needed
Calling removeObserver:forKeyPath: for a registration that doesn't exist raises an exception just as reliably as failing to remove one that does. Conditional registration logic must be matched by equally conditional removal logic.
[account addObserver:self forKeyPath:@"balance" options:0 context:NULL];
[account addObserver:self forKeyPath:@"balance" options:0 context:AccountBalanceContext];
Passing NULL as the context works only as long as no other class in the hierarchy also observes the same object and key path. A unique static context pointer is what lets observeValueForKeyPath:... reliably tell registrations apart, especially across superclass/subclass observation.
NSInteger count = [self.items count]; // mutated directly, no willChange/didChange, automatic notification disabled
[self willChangeValueForKey:@"itemCount"]; [_items addObject:item]; [self didChangeValueForKey:@"itemCount"];
If automaticallyNotifiesObserversForKey: has been overridden to return NO for a computed key, observers get no notification at all unless the code that actually changes the underlying value explicitly brackets that change with willChangeValueForKey: and didChangeValueForKey:.
What is the single method through which KVO delivers every change notification, regardless of how many key paths an object observes? A. A different, uniquely named method generated per observed key path B. observeValueForKeyPath:ofObject:change:context: C. valueForKey: D. dealloc
B. observeValueForKeyPath:ofObject:change:context: — Unlike delegation's per-event methods, KVO funnels every observed change on every registered key path through the single observeValueForKeyPath:ofObject:change:context: method, requiring the observer to inspect the key path and context to route the notification.
Why is passing a unique static context pointer to addObserver:forKeyPath:options:context: better than passing NULL? A. It makes the observed property change faster B. It lets observeValueForKeyPath:... reliably identify which specific registration a callback answers, which matters when a class hierarchy has multiple observers of the same key path and object C. It is required by the compiler and code will not build without it D. It automatically removes the observer when the context pointer is deallocated
B. It lets observeValueForKeyPath:... reliably identify which specific registration a callback answers, which matters when a class hierarchy has multiple observers of the same key path and object — A unique context pointer disambiguates which addObserver: call a given callback invocation corresponds to, which becomes essential once a superclass and subclass -- or multiple unrelated observations -- might otherwise collide on the same key path and object.
What crash results when an observing object is deallocated without first removing its KVO registration? A. No crash occurs; KVO cleans up automatically B. An NSUndefinedKeyException, the same as an undefined KVC key C. A crash reporting that an instance was deallocated while key value observers were still registered with it, typically triggered later when the observed property next changes D. A compile-time error preventing the app from building
C. A crash reporting that an instance was deallocated while key value observers were still registered with it, typically triggered later when the observed property next changes — KVO registration and removal are manual and unbalanced by default -- nothing about ARC cleans up a forgotten registration -- so a deallocated observer left registered produces the well-known 'was deallocated while key value observers were still registered' crash, usually on the next change to the observed property.
When must you override automaticallyNotifiesObserversForKey: and call willChangeValueForKey:/didChangeValueForKey: manually? A. For every property in every class, without exception B. Only for properties declared 'readonly' C. When a property's value changes without going through the standard synthesized setter path -- for example a computed property or a mutation made directly to an underlying ivar or collection D. Never -- KVO always fires automatically for any property
C. When a property's value changes without going through the standard synthesized setter path -- for example a computed property or a mutation made directly to an underlying ivar or collection — Most properties get automatic KVO notification for free because their synthesized setters already go through the hooked path. Manual notification is only needed when a change bypasses that standard path, such as a computed property or a direct ivar/collection mutation.
What is the practical benefit of a block-based NSKeyValueObservation-returning registration API over classic addObserver:forKeyPath:options:context:? A. It observes properties that are not KVC-compliant B. It removes the observation automatically when the returned token is deallocated, tying removal to ordinary ARC lifetime instead of a hand-written, manually balanced remove call C. It eliminates the need for a change dictionary entirely D. It works only in Swift and has no Objective-C equivalent
B. It removes the observation automatically when the returned token is deallocated, tying removal to ordinary ARC lifetime instead of a hand-written, manually balanced remove call — The block-based, token-returning KVO API ties the observation's removal to the deallocation of the returned NSKeyValueObservation object, sidestepping the manual add/remove balancing act that causes classic KVO's most common crashes.
In the change dictionary delivered to observeValueForKeyPath:ofObject:change:context:, when is NSKeyValueChangeOldKey present? A. Always, regardless of registration options B. Only when NSKeyValueObservingOptionOld was included in the options passed at registration time C. Only for scalar properties, never for object properties D. Only if the property has never changed before
B. Only when NSKeyValueObservingOptionOld was included in the options passed at registration time — The change dictionary only includes the old value when NSKeyValueObservingOptionOld was requested at registration; omitting that option (or passing 0) skips snapshotting the old value, saving work when only the new value is needed.
Write a class Thermostat with a float property temperature, and a class DisplayPanel that observes a Thermostat instance's temperature key path, logging old and new values whenever it changes. Register the observation in DisplayPanel's initializer using a unique static context, implement observeValueForKeyPath:ofObject:change:context: to handle it, and remove the observation correctly in dealloc.
static void * const TemperatureContext = &TemperatureContext; @implementation DisplayPanel - (instancetype)initWithThermostat:(Thermostat *)thermostat { self = [super init]; if (self) { _thermostat = thermostat; [_thermostat addObserver:self forKeyPath:@"temperature" options:(NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew) context:TemperatureContext]; } return self; } - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { if (context == TemperatureContext) { NSLog(@"Temp: %@ -> %@", change[NSKeyValueChangeOldKey], change[NSKeyValueChangeNewKey]); return; } [super observeValueForKeyPath:keyPath ofObject:object change:change context:context]; } - (void)dealloc { [_thermostat removeObserver:self forKeyPath:@"temperature" context:TemperatureContext]; } @end
Since temperature is a plain float property with a standard synthesized setter, automatic notification is all you need on the Thermostat side — no willChangeValueForKey:/didChangeValueForKey: overrides are required there. Focus the exercise on getting registration, the context check, and removal right on the DisplayPanel side.