Introduction
When making multiple network requests in a loop in Objective-C, you need to handle the asynchronous nature of network operations carefully. A naive for loop fires all requests simultaneously, and the loop finishes before any response arrives. To process responses in order or wait for all to complete, use dispatch groups (dispatch_group_t), semaphores, or NSOperationQueue with dependencies. The key challenge is coordinating loop iteration with asynchronous callbacks so that data is collected correctly and the UI updates at the right time.
The Problem: Async Calls in a Loop
1// WRONG — loop finishes before any response arrives
2NSArray *urls = @[@"https://api.example.com/1",
3 @"https://api.example.com/2",
4 @"https://api.example.com/3"];
5NSMutableArray *results = [NSMutableArray array];
6
7for (NSString *urlString in urls) {
8 NSURL *url = [NSURL URLWithString:urlString];
9 NSURLSessionDataTask *task =
10 [[NSURLSession sharedSession] dataTaskWithURL:url
11 completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
12 // This runs LATER, after the loop has finished
13 [results addObject:data];
14 }];
15 [task resume];
16}
17
18// results is EMPTY here — all tasks are still in-flight
19NSLog(@"Results count: %lu", (unsigned long)results.count); // 0
Solution 1: Dispatch Groups
dispatch_group_t tracks a set of async tasks and notifies when all complete:
1NSArray *urls = @[@"https://api.example.com/1",
2 @"https://api.example.com/2",
3 @"https://api.example.com/3"];
4NSMutableArray *results = [NSMutableArray array];
5dispatch_group_t group = dispatch_group_create();
6
7for (NSString *urlString in urls) {
8 dispatch_group_enter(group); // Increment counter
9
10 NSURL *url = [NSURL URLWithString:urlString];
11 NSURLSessionDataTask *task =
12 [[NSURLSession sharedSession] dataTaskWithURL:url
13 completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
14 if (data) {
15 @synchronized (results) {
16 [results addObject:data];
17 }
18 }
19 dispatch_group_leave(group); // Decrement counter
20 }];
21 [task resume];
22}
23
24// Called when ALL tasks complete
25dispatch_group_notify(group, dispatch_get_main_queue(), ^{
26 NSLog(@"All done! Results: %lu", (unsigned long)results.count);
27 // Update UI here
28});
With Timeout
1dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, 30 * NSEC_PER_SEC);
2long result = dispatch_group_wait(group, timeout);
3
4if (result == 0) {
5 NSLog(@"All tasks completed within 30 seconds");
6} else {
7 NSLog(@"Timeout — some tasks did not finish");
8}
Solution 2: Semaphores for Sequential Execution
To execute requests one at a time (waiting for each to finish before starting the next):
1dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
2NSMutableArray *results = [NSMutableArray array];
3NSArray *urls = @[@"https://api.example.com/1",
4 @"https://api.example.com/2",
5 @"https://api.example.com/3"];
6
7dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
8 for (NSString *urlString in urls) {
9 NSURL *url = [NSURL URLWithString:urlString];
10 NSURLSessionDataTask *task =
11 [[NSURLSession sharedSession] dataTaskWithURL:url
12 completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
13 if (data) {
14 [results addObject:data];
15 }
16 dispatch_semaphore_signal(semaphore); // Unblock
17 }];
18 [task resume];
19 dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); // Block until done
20 }
21
22 dispatch_async(dispatch_get_main_queue(), ^{
23 NSLog(@"All sequential requests done: %lu", (unsigned long)results.count);
24 });
25});
Solution 3: NSOperationQueue
NSOperationQueue provides higher-level control with dependencies and concurrency limits:
1NSOperationQueue *queue = [[NSOperationQueue alloc] init];
2queue.maxConcurrentOperationCount = 3; // Limit concurrent requests
3
4NSArray *urls = @[@"https://api.example.com/1",
5 @"https://api.example.com/2",
6 @"https://api.example.com/3"];
7
8NSBlockOperation *completionOp = [NSBlockOperation blockOperationWithBlock:^{
9 [[NSOperationQueue mainQueue] addOperationWithBlock:^{
10 NSLog(@"All operations finished — update UI");
11 }];
12}];
13
14for (NSString *urlString in urls) {
15 NSBlockOperation *op = [NSBlockOperation blockOperationWithBlock:^{
16 dispatch_semaphore_t sem = dispatch_semaphore_create(0);
17 NSURL *url = [NSURL URLWithString:urlString];
18
19 NSURLSessionDataTask *task =
20 [[NSURLSession sharedSession] dataTaskWithURL:url
21 completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
22 // Process data
23 dispatch_semaphore_signal(sem);
24 }];
25 [task resume];
26 dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
27 }];
28
29 [completionOp addDependency:op]; // Completion waits for all
30 [queue addOperation:op];
31}
32
33[queue addOperation:completionOp];
Solution 4: Recursive Chaining
Process one request at a time using recursive method calls:
1- (void)fetchURLs:(NSArray *)urls
2 atIndex:(NSUInteger)index
3 results:(NSMutableArray *)results
4 completion:(void (^)(NSArray *))completion {
5
6 if (index >= urls.count) {
7 completion([results copy]);
8 return;
9 }
10
11 NSURL *url = [NSURL URLWithString:urls[index]];
12 NSURLSessionDataTask *task =
13 [[NSURLSession sharedSession] dataTaskWithURL:url
14 completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
15 if (data) {
16 [results addObject:data];
17 }
18 // Recursively fetch the next URL
19 [self fetchURLs:urls
20 atIndex:index + 1
21 results:results
22 completion:completion];
23 }];
24 [task resume];
25}
26
27// Usage
28NSArray *urls = @[@"https://api.example.com/1",
29 @"https://api.example.com/2",
30 @"https://api.example.com/3"];
31
32[self fetchURLs:urls atIndex:0 results:[NSMutableArray array]
33 completion:^(NSArray *results) {
34 dispatch_async(dispatch_get_main_queue(), ^{
35 NSLog(@"Fetched %lu results in order", (unsigned long)results.count);
36 });
37}];
Approach Comparison
| Approach | Concurrent? | Ordered? | Complexity |
| Dispatch group | Yes | No | Low |
| Semaphore | No (sequential) | Yes | Low |
| NSOperationQueue | Configurable | With dependencies | Medium |
| Recursive chaining | No (sequential) | Yes | Medium |
Common Pitfalls
Forgetting dispatch_group_leave in error paths: Every dispatch_group_enter must be paired with a dispatch_group_leave, even when the request fails. A missing leave call means dispatch_group_notify never fires, and your app silently hangs.
Calling dispatch_semaphore_wait on the main thread: Blocking the main thread with a semaphore freezes the UI and can cause watchdog termination on iOS. Always dispatch semaphore-based loops to a background queue.
Mutating a shared array without synchronization: Completion handlers from multiple concurrent requests run on different threads. Mutating an NSMutableArray without @synchronized or a serial queue causes race conditions and crashes.
Not dispatching UI updates to the main queue: Completion handlers from NSURLSession run on a background queue by default. Calling UIKit methods directly from these handlers causes undefined behavior. Always use dispatch_async(dispatch_get_main_queue(), ...).
Assuming loop order equals response order: With concurrent requests, responses arrive in arbitrary order. If order matters, either use sequential execution (semaphore/recursive) or store results by index instead of appending.
Summary
Async network calls in a loop complete after the loop finishes — you cannot use the results immediately
Use dispatch_group_t with enter/leave for concurrent requests with a completion callback
Use dispatch_semaphore_t on a background queue for sequential one-at-a-time execution
Use NSOperationQueue for configurable concurrency limits and operation dependencies
Always synchronize access to shared mutable collections and dispatch UI updates to the main queue