Advanced NSOperation / OperationQueue
Go beyond the fundamentals of NSOperation and NSOperationQueue covered in the earlier NSOperation module: build real dependency graphs, implement cooperative cancellation correctly, understand exactly when and where completionBlock runs, write a fully KVO-compliant asynchronous NSOperation subclass, tune quality of service and priority, and make an informed decision between NSOperationQueue and raw GCD.
The earlier NSOperation module introduced NSOperation and NSOperationQueue as an object-oriented alternative to raw GCD blocks — units of work with identity, readiness, and cancellability. That was the foundation. This addendum goes further into the parts of NSOperation that only really matter once you're building something non-trivial: multi-operation dependency graphs that model real task relationships, the sharp edges of cancellation and completion blocks that catch people off guard in production code, the exact ceremony required to write a correct fully-asynchronous operation subclass, and a clear-eyed framework for deciding when NSOperationQueue is worth its overhead over plain Grand Central Dispatch.
Recap: where the earlier module left off
The earlier NSOperation module covered the essentials: creating an NSOperation via NSBlockOperation or a synchronous subclass overriding -main, adding operations to an NSOperationQueue, setting a single dependency with -addDependency:, checking -isCancelled cooperatively, and a basic introduction to completionBlock and the KVO-observable state properties. If any of that is unfamiliar, that module is the right place to start — this lesson assumes it and pushes into the territory beyond it.
Everything below still rests on the same underlying model: an NSOperation moves through a lifecycle of not-ready, ready, executing, and finished, and an NSOperationQueue's entire job is to watch those states across every operation it holds and decide, from moment to moment, which ready operations to start next given its maxConcurrentOperationCount.
Dependency graphs: chains and fan-out/fan-in shapes
A single -addDependency: call links two operations: B is not ready until A finishes. That's useful on its own, but the real power of NSOperation's dependency system is that it composes — you can build arbitrarily shaped graphs, not just chains, by adding multiple dependencies to a single operation or making a single operation the dependency of several others.
A "diamond" shape is a common and instructive pattern: one operation fans out into two independent operations that can run concurrently, and a final operation fans back in, depending on both of them, so it only starts once both branches are done. Think of it as: fetch a document (A), then in parallel validate its schema (B) and generate a thumbnail preview of it (C), then once both B and C finish, save the final result (D) — D depends on both B and C, and B and C each depend on A.
The queue itself never needs to be told about the graph explicitly — there's no separate "graph" object. The graph exists purely as the set of -addDependency: relationships between the operations, and NSOperationQueue's readiness machinery figures out the correct execution order (and correct concurrency between independent branches) entirely from those relationships.
Cancellation is cooperative, not preemptive — in depth
Calling -cancel on an NSOperation does exactly one guaranteed thing: it sets isCancelled to YES. It does not stop a -main method that is already running, does not unwind a call stack, does not throw an exception, and does not forcibly terminate a thread. If your operation's work is a single long computation or a tight loop, and you never check isCancelled anywhere inside it, cancelling that operation changes nothing about how long it runs or what it does — it simply runs to completion exactly as if -cancel had never been called, and only afterward will callers observing isFinished/isCancelled realize it was "cancelled" in name only.
The fix is to make cancellation cooperative: periodically check self.isCancelled at natural checkpoints inside your operation's work — between iterations of a loop, before starting an expensive sub-step, right after a network call returns — and if it's true, clean up whatever partial state needs cleaning up and return early from -main. This is a design discipline you have to build into every operation subclass; NSOperation gives you the flag, but checking it is entirely your responsibility.
A queue's -cancelAllOperations sends -cancel to every operation it holds — again, cooperatively. Operations that haven't started yet and are still merely "ready" will typically skip their work almost immediately once started, since most well-written -main implementations check isCancelled as their very first line. Operations already mid-flight still need their own internal checkpoints to actually stop early.
completionBlock: timing and threading pitfalls
Every NSOperation has a completionBlock property: a block you assign that runs once the operation transitions to finished (isFinished becomes YES), regardless of whether it finished normally or was cancelled along the way. It is convenient for "and then do this next" logic that's tightly bound to a specific operation instance, as opposed to a dependency, which is about ordering other operations relative to this one.
The critical detail — and the source of a very common bug — is that completionBlock does NOT run on any particular guaranteed queue or thread, and it is certainly not guaranteed to run on the main thread. It runs on whatever thread/queue happens to be executing when the operation finishes, which for a queue-managed operation is typically one of the queue's own background worker threads. Code inside a completionBlock that touches UIKit directly — setting a label's text, reloading a table view — is running UIKit off the main thread, which is undefined behavior and a classic source of intermittent, hard-to-reproduce crashes and visual glitches.
The fix is always the same: if a completionBlock needs to update UI, explicitly dispatch that work back to the main queue with dispatch_async(dispatch_get_main_queue(), ^{ ... }) rather than assuming you're already there. This is covered as a dedicated mistake example below because it is genuinely one of the most common real-world bugs in NSOperation-based code.
Building a correct, fully asynchronous NSOperation subclass
The default NSOperation contract is synchronous: -main runs, and the moment it returns, the operation is finished. That model breaks down the instant your operation's real work outlives the -main call itself — a network request with a completion handler being the classic example. If -main simply kicks off the request and returns immediately, NSOperation will think the operation is finished before the network call has even completed, which is wrong.
For this, NSOperation supports a fully asynchronous mode, but using it correctly requires manual, careful work: you must override -isAsynchronous to return YES, override -isExecuting and -isFinished to be backed by your own ivars/properties instead of NSOperation's default synchronous bookkeeping, and — this is the part people get wrong — manually fire KVO notifications around every state change to those two properties, because NSOperationQueue observes isExecuting and isFinished via Key-Value Observing to know when to start the next operation and when this one is done. If you change the underlying value without wrapping it in -willChangeValueForKey: and -didChangeValueForKey:, the queue's observers are never notified, and the queue will believe the operation is still executing forever — silently stalling anything that depends on it.
The reason this KVO ceremony is unavoidable for async operations specifically (and not for synchronous ones) is timing: a synchronous operation's -main runs and returns on the same thread the queue started it on, so the queue can just check the return of -main. An asynchronous operation, by definition, completes later — often on a completely different thread (a delegate callback thread, a dispatch queue used by the network stack) than the one that called -start. The queue has no way to know that later moment happened except by observing the property changes, which is precisely why they must be fired manually and correctly.
Quality of service and queue priority
Every NSOperation has a qualityOfService property (an NSQualityOfService value: userInteractive, userInitiated, default, utility, or background) that tells the system how urgently this work matters relative to everything else competing for CPU, I/O, and energy budget across the whole device — not just within your app. userInteractive is reserved for work blocking something the user is actively touching right now (animating a gesture); background is for work the user isn't waiting on at all (pre-fetching content for later, cleaning up a cache). Setting this accurately lets the system schedule your app's threads appropriately against every other process, and matters more on battery-constrained devices than people expect.
Separately, queuePriority (an NSOperationQueuePriority value: veryLow, low, normal, high, veryHigh) only affects the relative execution order of ready operations within the same queue — it says nothing about system-wide scheduling and has no effect across different queues. Priority also cannot override dependencies: a veryHigh-priority operation that still has an unfinished dependency is simply not ready yet, and will not run before that dependency finishes no matter how its priority is set.
In practice, qualityOfService is the more consequential of the two for real app responsiveness, and queuePriority is a finer-grained tool for ordering among operations that are already at the same QoS tier and already ready to run.
Choosing NSOperationQueue vs raw GCD
Both NSOperationQueue and raw Grand Central Dispatch schedule work onto background threads, and NSOperationQueue is in fact built on top of GCD internally — so this is not a question of one being fundamentally more capable at running code concurrently. It's a question of which vocabulary fits the problem you actually have.
Reach for NSOperationQueue when you need dependency graphs between discrete units of work (GCD has no built-in equivalent to -addDependency: — modeling the same relationship in raw GCD means manually nesting dispatch_async calls or wiring up dispatch groups by hand), when you need real cancellation of work that may not have started yet (GCD blocks, once submitted, cannot be individually cancelled), when you want operations as reusable, observable, inspectable objects (KVO on isExecuting/isFinished, a name, a queuePriority) rather than anonymous closures, or when the coordination logic is complex enough that the extra structure pays for itself.
Reach for plain GCD (dispatch_async, dispatch_queue_create, dispatch_group_t, dispatch_barrier_async, and friends) when you just need to run a block off the main thread with minimal ceremony, when there's no real dependency graph to model, and when the overhead of allocating and managing NSOperation objects is not buying you anything — GCD is lighter weight and simpler for exactly this common case, which is most concurrent code in most apps.
A three-operation dependency chain
fetchDocument has no dependencies, so it becomes ready and runs first. validateSchema and generateThumbnail both depend only on fetchDocument, so once it finishes, both become ready and can run concurrently (maxConcurrentOperationCount permitting). saveResult depends on both validateSchema and generateThumbnail, so it only becomes ready once both have finished — this is the 'fan-in' half of the diamond.
NSOperationQueue *queue = [[NSOperationQueue alloc] init];
queue.maxConcurrentOperationCount = 2; // allow B and C to run in parallel
NSBlockOperation *fetchDocument = [NSBlockOperation blockOperationWithBlock:^{
NSLog(@"A: fetching document");
}];
NSBlockOperation *validateSchema = [NSBlockOperation blockOperationWithBlock:^{
NSLog(@"B: validating schema");
}];
NSBlockOperation *generateThumbnail = [NSBlockOperation blockOperationWithBlock:^{
NSLog(@"C: generating thumbnail");
}];
NSBlockOperation *saveResult = [NSBlockOperation blockOperationWithBlock:^{
NSLog(@"D: saving final result");
}];
// Diamond: A -> (B, C) -> D
[validateSchema addDependency:fetchDocument];
[generateThumbnail addDependency:fetchDocument];
[saveResult addDependency:validateSchema];
[saveResult addDependency:generateThumbnail];
[queue addOperations:@[fetchDocument, validateSchema, generateThumbnail, saveResult]
waitUntilFinished:NO];Code Examples
A simple chain: parseOp cannot become ready until downloadOp finishes, and renderOp cannot become ready until parseOp finishes. Even though all three operations are added to the queue at once, the dependency chain guarantees they execute in the order 1, 2, 3 — the queue will never start parseOp early just because it happens to have a free worker thread. Expected result: 1: downloading raw data 2: parsing downloaded data 3: rendering parsed result [parseOp addDependency:downloadOp];
Checking self.isCancelled at the top of every loop iteration — not just once at the start of -main — is what makes this operation actually responsive to cancellation. Because -main is a synchronous override here, simply returning early is enough; NSOperation still marks isFinished automatically once -main returns, cancelled or not.
if (self.isCancelled) {... return; } Common mistake: Checking isCancelled only once before the loop starts. If -cancel is called for the operation after the loop is already running, that single early check will never catch it, and the entire batch runs to completion regardless of the cancellation.
This looks reasonable but is a real bug: completionBlock runs on whatever thread the operation happened to finish on, which for a queue-managed NSBlockOperation is a background worker thread, not the main thread. Directly setting nameLabel.text here is a UIKit call made off the main thread, which is unsupported and can produce intermittent crashes or a UI that silently fails to update. The corrected version dispatches the UI work back to the main queue explicitly — see the mistakes section for the fix.
self.nameLabel.text = self.loadedProfile.displayName;
Common mistake: Assuming completionBlock runs on the main thread because "it feels like the natural next step after the operation." It never carries that guarantee.
Every piece here exists for a reason. -isAsynchronous returning YES tells NSOperationQueue not to assume -start returning means the work is done. -isExecuting/-isFinished are backed by ivars this class fully controls, instead of NSOperation's default synchronous bookkeeping. Most importantly, every single change to those two ivars is wrapped in matching -willChangeValueForKey:/-didChangeValueForKey: calls — this is what makes the queue's internal KVO observers actually fire, which is how the queue learns the operation finished and it's safe to start the next ready operation. Without that manual KVO ceremony, the queue would simply never notice this operation is done, since the completion happens inside a URLSession callback on a different thread than the one that called -start.
[self willChangeValueForKey:@"isFinished"]; ... [self didChangeValueForKey:@"isFinished"];
Common mistake: Setting _isFinished = YES directly without the surrounding willChangeValueForKey:/didChangeValueForKey: calls. The operation object's own state is technically correct, but the queue's KVO observers are never triggered, so the queue believes this operation is still running indefinitely — silently stalling any dependent operations and potentially the entire queue if maxConcurrentOperationCount is limited.
qualityOfService communicates system-wide urgency (userInitiated competes for resources far more aggressively than background, across the whole device, not just this queue), while queuePriority only reorders these two operations relative to each other within this one queue once both are ready. Even though prefetchOp was added first, userFacingOp's higher queuePriority and more urgent qualityOfService together make it much more likely to actually start first and get scheduled sooner. Neither property can jump an operation ahead of an unfinished dependency — priority and QoS only matter among operations that are already ready to run.
userFacingOp.qualityOfService = NSQualityOfServiceUserInitiated;
This reproduces the exact same A -> (B, C) -> D diamond as the NSOperationQueue example above, but every relationship has to be hand-wired with dispatch_group_enter/leave/notify calls instead of simple -addDependency: calls. It works, and for a one-off shape like this it's not unreasonable — but there's no NSOperation-style object to hold onto afterward, no built-in cancellation of B or C once they've started, and no reusable, inspectable unit of work; it is pure, ephemeral closures wired together by hand. This is exactly the tradeoff the 'NSOperationQueue vs GCD' section describes. As the graph grows more complex — more branches, conditional dependencies, operations that need to be cancelled individually after being queued — the amount of manual bookkeeping this style requires grows much faster than the equivalent NSOperationQueue code does.
dispatch_group_notify(fanInGroup, concurrentQueue, ^{ ... });
NSOperationQueue *queue = [[NSOperationQueue alloc] init];
NSBlockOperation *downloadOp = [NSBlockOperation blockOperationWithBlock:^{
NSLog(@"1: downloading raw data");
}];
NSBlockOperation *parseOp = [NSBlockOperation blockOperationWithBlock:^{
NSLog(@"2: parsing downloaded data");
}];
NSBlockOperation *renderOp = [NSBlockOperation blockOperationWithBlock:^{
NSLog(@"3: rendering parsed result");
}];
[parseOp addDependency:downloadOp];
[renderOp addDependency:parseOp];
[queue addOperations:@[downloadOp, parseOp, renderOp] waitUntilFinished:NO];@interface BatchProcessingOperation : NSOperation
@property (nonatomic, strong) NSArray<Record *> *records;
@end
@implementation BatchProcessingOperation
- (void)main {
for (Record *record in self.records) {
if (self.isCancelled) {
NSLog(@"Cancelled mid-batch — stopping early, partial results discarded");
return; // bail out of -main; NSOperation marks isFinished automatically
}
[self processRecord:record]; // potentially slow, per-record work
}
NSLog(@"Batch processing completed in full");
}
@endNSBlockOperation *loadProfileOp = [NSBlockOperation blockOperationWithBlock:^{
self.loadedProfile = [self synchronouslyLoadProfileFromDisk];
}];
// INCORRECT: completionBlock does not run on the main thread
loadProfileOp.completionBlock = ^{
self.nameLabel.text = self.loadedProfile.displayName; // UIKit off main thread — unsafe
};
[self.queue addOperation:loadProfileOp];@interface AsyncNetworkOperation : NSOperation
@property (nonatomic, copy) NSURL *url;
@property (nonatomic, strong) NSData *resultData;
@end
@implementation AsyncNetworkOperation {
BOOL _isExecuting;
BOOL _isFinished;
}
- (BOOL)isAsynchronous { return YES; }
- (BOOL)isExecuting { return _isExecuting; }
- (BOOL)isFinished { return _isFinished; }
- (void)start {
if (self.isCancelled) {
[self finish];
return;
}
[self willChangeValueForKey:@"isExecuting"];
_isExecuting = YES;
[self didChangeValueForKey:@"isExecuting"];
NSURLSessionDataTask *task =
[[NSURLSession sharedSession] dataTaskWithURL:self.url
completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
// This callback fires on a URLSession-managed background thread —
// NOT necessarily the thread that called -start.
if (!self.isCancelled) {
self.resultData = data;
}
[self finish];
}];
[task resume];
}
- (void)finish {
[self willChangeValueForKey:@"isExecuting"];
[self willChangeValueForKey:@"isFinished"];
_isExecuting = NO;
_isFinished = YES;
[self didChangeValueForKey:@"isExecuting"];
[self didCh
…NSOperationQueue *queue = [[NSOperationQueue alloc] init];
queue.maxConcurrentOperationCount = 4;
NSBlockOperation *userFacingOp = [NSBlockOperation blockOperationWithBlock:^{
NSLog(@"Decoding image the user is currently waiting to see");
}];
userFacingOp.qualityOfService = NSQualityOfServiceUserInitiated;
userFacingOp.queuePriority = NSOperationQueuePriorityHigh;
NSBlockOperation *prefetchOp = [NSBlockOperation blockOperationWithBlock:^{
NSLog(@"Pre-fetching content the user might scroll to later");
}];
prefetchOp.qualityOfService = NSQualityOfServiceBackground;
prefetchOp.queuePriority = NSOperationQueuePriorityLow;
[queue addOperation:prefetchOp];
[queue addOperation:userFacingOp];dispatch_queue_t concurrentQueue =
dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0);
dispatch_group_t fanInGroup = dispatch_group_create();
dispatch_async(concurrentQueue, ^{
NSLog(@"A: fetching document");
// B and C run after A, and in parallel with each other
dispatch_group_enter(fanInGroup);
dispatch_async(concurrentQueue, ^{
NSLog(@"B: validating schema");
dispatch_group_leave(fanInGroup);
});
dispatch_group_enter(fanInGroup);
dispatch_async(concurrentQueue, ^{
NSLog(@"C: generating thumbnail");
dispatch_group_leave(fanInGroup);
});
// D only runs once both B and C have called dispatch_group_leave
dispatch_group_notify(fanInGroup, concurrentQueue, ^{
NSLog(@"D: saving final result");
});
});A dependency diamond across an NSOperationQueue
A becomes ready first and runs alone. Once A finishes, both B and C become ready simultaneously and may run concurrently. D depends on both B and C, so it only becomes ready once both have finished — the 'fan-in' point of the diamond.
[A: fetchDocument]
/ \
[B: validate] [C: thumbnail]
\ /
[D: saveResult]Important Rules
Calling -cancel only sets isCancelled to YES — it never stops a running -main by itself; cooperative isCancelled checks inside the operation's own code are what make cancellation actually take effect.
completionBlock runs after the operation finishes, but on an arbitrary thread/queue — never assume it runs on the main thread, and dispatch UI updates back to the main queue explicitly.
A fully asynchronous NSOperation subclass must override isAsynchronous, back isExecuting/isFinished with its own state, and wrap every change to them in matching willChangeValueForKey:/didChangeValueForKey: calls so the owning queue's KVO observers fire correctly.
queuePriority only orders already-ready operations within a single queue; it has no effect across queues and cannot override an unfinished dependency.
qualityOfService communicates system-wide scheduling urgency across the whole device, not just within your app's queue.
Choose NSOperationQueue when you need dependency graphs, cancellable/observable units of work, or reusable operation objects; choose raw GCD for simpler, lower-overhead concurrent work with no real dependency structure.
Where this shows up
Dependency graphs built from NSOperation show up anywhere a pipeline has real structure: import-and-process flows (fetch, validate, transform, save), multi-step media pipelines (download, decode, resize, upload), or any feature where some steps can run in parallel but a later step genuinely needs several earlier ones to finish first — the fan-out/fan-in diamond pattern. Fully asynchronous custom NSOperation subclasses are the correct way to wrap any callback-based API (networking, some hardware/sensor APIs) as a first-class, cancellable, dependency-aware unit of work rather than leaving it as a bare, unmanaged callback.
loadProfileOp.completionBlock = ^{ self.nameLabel.text = self.loadedProfile.displayName; // running on a background thread! };
loadProfileOp.completionBlock = ^{ dispatch_async(dispatch_get_main_queue(), ^{ self.nameLabel.text = self.loadedProfile.displayName; }); };
completionBlock is guaranteed to run after the operation finishes, but on no particular thread — for a queue-managed operation, that is very often a background worker thread. Any UIKit call made from inside a completionBlock must be explicitly dispatched to the main queue first, exactly as it would need to be from any other background context.
• (void)main { if (self.isCancelled) { return; } // checked only once, before the loop for (Record *record in self.records) { [self processRecord:record]; // long-running work, never re-checks isCancelled } } - (void)main { for (Record *record in self.records) { if (self.isCancelled) { return; } // re-checked on every iteration [self processRecord:record]; } }
Cancellation is cooperative — the operation must check isCancelled at points throughout its work, not just once at the start. A single check before a long loop will never notice a cancellation that arrives after the loop has already begun.
_isFinished = YES; // direct ivar mutation with no KVO notification
[self willChangeValueForKey:@"isFinished"]; _isFinished = YES; [self didChangeValueForKey:@"isFinished"];
NSOperationQueue relies on KVO on isExecuting and isFinished to know when an operation is done and it can start the next ready one. Mutating the backing ivar directly, without the matching willChangeValueForKey:/didChangeValueForKey: pair, never fires those KVO notifications — the queue is left believing the operation is still running, which can silently stall dependent operations indefinitely.
[Multiple Choice] Calling -cancel on a currently-running NSOperation whose -main never checks isCancelled will: A. Immediately stop -main mid-execution B. Throw an exception on the operation's thread C. Have no effect on the running work — the operation runs to completion as if -cancel had never been called D. Cause the operation to skip its completionBlock entirely
C. Have no effect on the running work — the operation runs to completion as if -cancel had never been called — Cancellation is purely cooperative — -cancel only sets isCancelled to YES. If the operation's own code never checks that flag, nothing about its execution changes; it runs to completion regardless.
[True / False] An operation's completionBlock is guaranteed to run on the main thread. A. True B. False
B. False — False. completionBlock runs after the operation finishes, but on whatever thread/queue happens to be running at that point — often a background worker thread for a queue-managed operation. UI updates inside it must be explicitly dispatched to the main queue.
[Multiple Choice] Why must a fully asynchronous custom NSOperation subclass manually call willChangeValueForKey:/didChangeValueForKey: around changes to isExecuting and isFinished? A. It is required by the Objective-C runtime for all @property declarations B. NSOperationQueue observes those two keys via KVO to know when to start the next operation, and manual state changes don't trigger KVO automatically without it C. It prevents the operation from being deallocated too early D. It is only needed for synchronous operations, not asynchronous ones
B. NSOperationQueue observes those two keys via KVO to know when to start the next operation, and manual state changes don't trigger KVO automatically without it — NSOperationQueue relies on KVO notifications for isExecuting and isFinished to track operation state. A direct ivar mutation without the matching willChangeValueForKey:/didChangeValueForKey: calls never fires those notifications, leaving the queue unaware the operation actually finished.
[Multiple Choice] In a dependency graph where D depends on both B and C, and B has qualityOfService set to userInteractive while C is still mid-execution, what happens? A. D starts as soon as B finishes, ignoring C B. D's high-priority dependency B lets it skip waiting on C C. D still cannot become ready until C also finishes — priority and QoS never override an unfinished dependency D. The queue automatically cancels C to let D proceed sooner
C. D still cannot become ready until C also finishes — priority and QoS never override an unfinished dependency — Dependencies are a hard readiness gate. No combination of qualityOfService or queuePriority on any operation can make a dependent operation ready before all of its dependencies have actually finished.
[Multiple Choice] Which scenario most strongly favors NSOperationQueue over raw GCD? A. Running a single block off the main thread with no relationship to any other work B. A multi-step pipeline with a fan-out/fan-in dependency structure, where individual steps may need to be cancelled before they start C. A tight, performance-critical loop with no concurrency at all D. Code that must run synchronously on the calling thread
B. A multi-step pipeline with a fan-out/fan-in dependency structure, where individual steps may need to be cancelled before they start — NSOperationQueue's dependency graph, per-operation cancellation, and reusable/observable operation objects are exactly the features that make it worth its extra overhead for structured, multi-step pipelines — the scenario GCD alone does not model directly.
[True / False] queuePriority affects how an operation is scheduled relative to operations in other NSOperationQueue instances. A. True B. False
B. False — False. queuePriority only affects the relative order of ready operations within the same queue. It has no effect on scheduling relative to operations in a different queue — qualityOfService is the property that communicates system-wide scheduling urgency.
Model a small "photo import" pipeline with four NSOperation instances on one NSOperationQueue: loadRawData, generateThumbnail (depends on loadRawData), extractMetadata (depends on loadRawData), and finalizeImport (depends on both generateThumbnail and extractMetadata). Set qualityOfService to userInitiated on all four. Add a completionBlock to finalizeImport that logs "Import complete" — dispatched correctly back to the main queue, since this would normally update UI.
NSOperationQueue *queue = [[NSOperationQueue alloc] init]; NSBlockOperation *loadRawData = [NSBlockOperation blockOperationWithBlock:^{ NSLog(@"Loading raw photo data"); }]; NSBlockOperation *generateThumbnail = [NSBlockOperation blockOperationWithBlock:^{ NSLog(@"Generating thumbnail"); }]; NSBlockOperation *extractMetadata = [NSBlockOperation blockOperationWithBlock:^{ NSLog(@"Extracting EXIF metadata"); }]; NSBlockOperation *finalizeImport = [NSBlockOperation blockOperationWithBlock:^{ NSLog(@"Finalizing import"); }]; for (NSOperation *op in @[loadRawData, generateThumbnail, extractMetadata, finalizeImport]) { op.qualityOfService = NSQualityOfServiceUserInitiated; } [generateThumbnail addDependency:loadRawData]; [extractMetadata addDependency:loadRawData]; [finalizeImport addDependency:generateThumbnail]; [finalizeImport addDependency:extractMetadata]; finalizeImport.completionBlock = ^{ dispatch_async(dispatch_get_main_queue(), ^{ NSLog(@"Import complete"); }); }; [queue addOperations:@[loadRawData, generateThumbnail, extractMetadata, finalizeImport] waitUntilFinished:NO];
This is the same diamond shape as the syntax example: one operation feeding two parallel branches, with a final operation depending on both branches finishing.