| name | swift-concurrency-patterns |
| description | Guides expert-level swift concurrency patterns implementation: swift and mobile decision frameworks, production-ready patterns, and concrete templates for swift concurrency patterns workflows.
Use when the user asks about swift concurrency patterns, swift concurrency patterns configuration, or swift best practices for swift projects.
Do NOT use when the user needs a different languages runtimes capability -- check sibling skills in the languages runtimes subcategory.
|
| license | Apache-2.0 |
| metadata | {"author":"foundry-skills","version":"1.0.0","tags":"swift mobile optimization","category":"software-engineering","subcategory":"languages-runtimes","depends":"","disclaimer":"none","difficulty":"intermediate"} |
Swift Concurrency Patterns
When to Use
Use this skill when:
- The user asks how to structure async/await code in Swift 5.5+ to avoid data races, deadlocks, or excessive thread creation
- The user wants to migrate from GCD (Grand Central Dispatch) or DispatchQueue-based code to the Swift concurrency model (actors, async/await, structured concurrency)
- The user needs to decide between
async let, TaskGroup, Actor, @MainActor, or Sendable for a specific iOS/macOS problem
- The user is debugging concurrency bugs such as purple runtime warnings about main thread violations,
Task cancellation not propagating, or unexpected re-entrancy in actor-isolated code
- The user wants to interop between legacy Objective-C callbacks or Combine publishers and the Swift concurrency system
- The user needs to implement a producer/consumer pipeline, a cache with concurrent reads, or a background data-sync task in a Swift application
- The user asks about
AsyncSequence, AsyncStream, or continuation-based bridging with UIKit/AppKit delegate patterns
Do NOT use this skill when:
- The user is asking about Combine-specific operators (
.map, .flatMap, .merge) without any Swift concurrency involvement -- use a Combine-specific skill instead
- The user needs RxSwift, ReactiveSwift, or other reactive library patterns -- those have different execution models
- The user is working in Swift for server-side (SwiftNIO, Vapor event-loop threading model) where actor isolation rules interact differently with NIO's EventLoop -- those require a server-side Swift skill
- The user needs general iOS architecture patterns (MVVM, TCA, VIPER) without a concurrency component -- use an iOS architecture skill
- The user is asking about Objective-C threading (
NSThread, NSOperationQueue) in a purely Objective-C codebase
- The user needs help with GPU/Metal compute shaders or parallel numerical processing -- that is a separate rendering/compute domain
- The user is asking about Python, Kotlin, or Rust async runtimes -- those use fundamentally different concurrency models
Process
1. Diagnose the Concurrency Problem Category
Before recommending any pattern, classify the problem. Swift concurrency has distinct tools for distinct problems, and choosing the wrong primitive causes subtle bugs.
- Fire-and-forget background work (e.g., prefetching, analytics) -- use an unstructured
Task attached to a view or controller lifetime
- Parallel decomposition of known work (e.g., downloading 20 images in parallel, then aggregating) -- use
async let for 2--5 items or withTaskGroup / withThrowingTaskGroup for dynamic counts
- Shared mutable state (e.g., a cache, a counter, a rate limiter) -- use an
actor; decide whether @MainActor isolation or a custom actor is appropriate
- Streaming/event sequences (e.g., CoreLocation updates, URLSession byte streams, timer ticks) -- use
AsyncStream or AsyncThrowingStream
- Bridging callbacks (e.g.,
URLSession completion handlers, CLLocationManager delegate) -- use withCheckedContinuation or withCheckedThrowingContinuation
- Child task coordination with cancellation -- use structured concurrency (
TaskGroup) so cancellation propagates automatically
Identify which category applies before proceeding. A single request may span multiple categories.
2. Audit the Execution Context
Swift concurrency's correctness depends heavily on knowing which actor context code runs in. Mistakes here cause purple runtime warnings and data races.
- Identify all types that must run on the main thread (UIKit views, UIViewController, SwiftUI view models using
@Published) -- annotate them @MainActor
- For
@MainActor-isolated types, recognize that await-ing non-isolated async functions will switch off the main thread; switching back requires either another await or calling via MainActor.run {}
- Check whether a class conforms to
Sendable. If a type crosses actor boundaries, it must be Sendable -- value types with only Sendable stored properties are implicitly Sendable; reference types must mark conformance explicitly and carefully
- Enable
-strict-concurrency=complete in the Swift compiler flags (OTHER_SWIFT_FLAGS in Xcode build settings) to surface all Sendable and isolation violations as errors -- do this in a dedicated CI build target even if the main target uses minimal or targeted
- Map the critical path: if a user-facing action triggers async work, every
await suspension point is a place where the UI can change state; plan for this explicitly
3. Select the Right Concurrency Primitive
Apply this decision framework in order:
For parallelism (doing multiple things at once):
- 2--4 statically known parallel tasks: use
async let -- compiles to a child task per binding, automatic cancellation on scope exit
- 5+ tasks or dynamically sized collections: use
withTaskGroup(of:) for non-throwing work, withThrowingTaskGroup(of:) for throwing work; always iterate results inside the group body using for await result in group to avoid task accumulation
- Unordered result collection where ordering does not matter:
addTaskUnlessCancelled inside the group loop lets you bail cleanly when the parent task is cancelled
For shared state:
- Mutable state accessed from multiple tasks:
actor -- guarantates serial access without explicit locking
- High-read, low-write state that does not need mutation (configuration, lookup tables): use a
struct passed as a Sendable value; avoid actor overhead on read-heavy paths
- Main-thread-bound state (view models, UI controllers):
@MainActor class -- the compiler enforces isolation; nonisolated marks functions that can run off the main thread
- Global singletons: use a
global actor (@globalActor) only when you need a single shared executor across multiple types; avoid overusing this pattern as it creates a hidden serialization point
For streaming:
- Push-based event source (delegate, callback): wrap in
AsyncStream { continuation in ... } -- store the continuation; call continuation.yield(value) from the callback; call continuation.finish() on teardown
- Backpressure-sensitive streaming: use
AsyncStream with a bufferingPolicy of .bufferingNewest(n) or .bufferingOldest(n) to avoid unbounded memory growth; .unbounded is only safe when the producer is slower than the consumer
- Transforming an existing
AsyncSequence: use .map, .filter, .compactMap from the standard library; for custom transformations, implement the AsyncSequence protocol with an AsyncIteratorProtocol
For task lifecycle:
- Work tied to a SwiftUI view: use
.task {} modifier -- automatically cancelled when the view disappears
- Work tied to a UIViewController: create a
Task in viewDidAppear and cancel it in viewDidDisappear or deinit; store the task in a var task: Task<Void, Never>? property
- Background work that must complete even if the UI disappears (e.g., uploading a photo): use
URLSession background sessions, not a detached Task -- the OS can terminate the app
4. Implement Actor Isolation Correctly
Actor isolation is the most commonly misunderstood part of Swift concurrency. Get this right before writing any actor code.
- Actors serialize access: only one piece of code runs at a time inside an actor's executor; there is no need for
DispatchQueue serial queues or NSLock inside an actor
- Actor
nonisolated methods run on the caller's executor -- use nonisolated for pure functions (computations, formatting, encoding) that do not touch actor state; this avoids unnecessary hops to the actor's executor
- Actor re-entrancy: when an actor calls
await on an external function, another caller can enter the actor; always re-validate state after any await inside an actor -- do not assume state is unchanged across a suspension point
- To batch multiple state reads/writes atomically, perform them all synchronously within a single actor-isolated method; do not interleave
await calls between dependent reads
actor vs. class with a lock: actors provide compile-time isolation guarantees; a class with NSLock or os_unfair_lock provides runtime protection only; prefer actors for new code; when interoping with legacy lock-based code, use actors as a wrapper
Example of re-entrancy bug to avoid:
actor BankAccount {
var balance: Double = 0
func transfer(amount: Double, to other: BankAccount) async {
let current = balance
await other.deposit(amount)
balance = current - amount
}
func transfer(amount: Double, to other: BankAccount) async throws {
guard balance >= amount else { throw TransferError.insufficientFunds }
balance -= amount
await other.deposit(amount)
}
}
5. Handle Task Cancellation Explicitly
Swift's cooperative cancellation model requires code to check for cancellation -- it does not stop a task forcibly.
- Call
try Task.checkCancellation() at natural suspension points in long loops or after network calls; this throws CancellationError if the task is cancelled
- Use
Task.isCancelled (a Boolean) for non-throwing contexts where you want to exit gracefully rather than throw
- When implementing
AsyncSequence, yield nil (finishing the sequence) when the iteration task is cancelled; do not continue producing values into a cancelled context
withTaskCancellationHandler(operation:onCancel:) lets you register a synchronous cleanup callback that runs immediately on cancellation -- use this to cancel underlying URLSession data tasks, close file handles, or stop CLLocationManager updates
TaskGroup cancels all child tasks automatically when any child throws -- this is the correct behavior for "fail fast" semantics; if you want "collect all results including errors", use withThrowingTaskGroup and catch errors inside the child task body before they propagate
6. Bridge Legacy APIs Safely
Most real Swift projects have existing GCD, delegation, and callback-based code that must interoperate with async/await.
- Completion handlers to async: use
withCheckedContinuation (non-throwing) or withCheckedThrowingContinuation (throwing); "checked" variants assert that the continuation is resumed exactly once -- a double-resume or missing resume will produce a runtime warning or crash in debug builds
- Unsafe continuations:
withUnsafeContinuation skips the double-resume check; only use this in performance-critical paths where profiling shows the overhead of the check is measurable (it is almost never necessary)
- Delegate-based APIs (CLLocationManager, AVAudioSession): wrap with
AsyncStream; the delegate stores the continuation and yields values; ensure the AsyncStream is properly terminated when the delegate receives a terminal event
- NotificationCenter: use
NotificationCenter.default.notifications(named:) which returns an AsyncSequence since iOS 15 -- do not wrap this in a custom AsyncStream
- Combine to AsyncSequence: use
.values on any Publisher (available since iOS 15) -- somePublisher.values is an AsyncSequence you can iterate with for await
- Mixing DispatchQueue with async: calling
DispatchQueue.main.async {} inside a @MainActor async function is redundant and potentially harmful; use await MainActor.run {} instead when you must explicitly switch to the main actor from a different context
7. Test and Validate Concurrency
Concurrency bugs are non-deterministic. Standard XCTest techniques must be supplemented.
- Enable the Swift Concurrency runtime checks in the scheme: "Thread Performance Checker" (
SWIFT_ASYNC_DEBUG=1) and "Main Thread Checker" in the Diagnostics tab -- these surface actor isolation violations at runtime
- Enable Thread Sanitizer (TSan) on test targets to detect data races in code not yet migrated to actors -- TSan cannot catch actor-isolation violations but catches raw memory races in
nonisolated code
- Use
XCTestExpectation with expectation.fulfill() inside async tasks; call await fulfillment(of: [expectation], timeout: 5.0) instead of waitForExpectations for native async test support (Xcode 14.3+)
- Write deterministic tests by injecting a
Clock conformance -- use ContinuousClock for real time and TestClock (from the swift-clocks library) for controlled time in unit tests; Task.sleep(for:clock:) accepts a Clock parameter
- Test cancellation explicitly: create a
Task, cancel it immediately, then await the task's value; assert that the expected CancellationError was thrown or that the task exited at the correct checkpoint
8. Optimize for Performance
Swift concurrency is not automatically faster than GCD. Apply these benchmarks and decisions deliberately.
- The Swift concurrency runtime uses a cooperative thread pool sized to the number of CPU cores; creating thousands of tasks is safe (unlike threads), but each task has overhead (~heap allocation for the task object); batch small units of work into fewer, larger tasks
actor method dispatch has measurable overhead (~50--100ns per hop in benchmarks) compared to a direct function call; for hot paths that call actor methods in tight loops, expose a nonisolated function that takes a snapshot of actor state, does the computation, and writes back once
- Avoid
MainActor as a catchall for synchronization -- it is a single serial executor; pushing work there unnecessarily starves the UI rendering loop; keep @MainActor to view-state updates only
- Profile with Instruments' "Swift Concurrency" template (Xcode 14+) -- it shows task creation, suspension counts, actor contention, and thread pool utilization; look for actors with high "enqueued" counts as indicators of bottlenecks
- Prefer value types (
struct, enum) for data passed across task boundaries -- they require no reference counting, are implicitly Sendable when their stored properties are Sendable, and have zero synchronization overhead
Output Format
When responding to a user question about Swift concurrency patterns, structure output as follows:
## Swift Concurrency Analysis
### Problem Classification
- Category: [parallelism | shared state | streaming | bridging | lifecycle]
- Execution context: [main actor | custom actor | unstructured | structured]
- Swift/iOS minimum version required: [e.g., iOS 15 / Swift 5.7]
### Concurrency Primitive Decision
| Requirement | Primitive | Rationale |
|---|---|---|
| [requirement 1] | [async let / TaskGroup / actor / AsyncStream / continuation] | [specific reason] |
| [requirement 2] | [primitive] | [reason] |
### Recommended Pattern
[Name of pattern and one-line summary]
### Implementation
```swift
// Full, compilable Swift code with:
// - Correct actor isolation annotations
// - Explicit Sendable conformances where needed
// - Cancellation checkpoints
// - Error handling (throws / CancellationError)
// - No TODO placeholders -- every function is implemented
Cancellation Strategy
[How and where cancellation is checked or propagated]
Testing Approach
[Specific XCTest or Swift Testing framework techniques for validating this pattern]
Performance Considerations
[Specific latency, throughput, or memory trade-offs for this pattern in this context]
Migration Notes (if applicable)
[How to incrementally replace existing GCD/Combine/callback code with this pattern]
---
## Rules
1. **NEVER call `resume` on a continuation more than once** -- double-resuming a `CheckedContinuation` crashes the app in debug builds and causes undefined behavior in release builds; wrap the callback so only the first invocation calls resume, using a flag or by nulling out the continuation reference after use.
2. **NEVER block the cooperative thread pool** -- calling synchronous, blocking APIs (like `semaphore.wait()`, `Thread.sleep()`, or synchronous file I/O) inside an async function starves the thread pool because Swift concurrency cannot reclaim the thread; use `await Task.sleep(for:)` for delays and async file APIs for I/O.
3. **NEVER use `Task.detached` as the default way to create tasks** -- detached tasks inherit no actor context, no task-local values, and no priority from their creating context; use an unstructured `Task {}` (which inherits context) unless you explicitly need isolation from the parent.
4. **ALWAYS re-validate actor state after any `await` suspension point** -- actor re-entrancy means another caller may have mutated state between when you suspended and when you resumed; do not assume that a value read before `await` is still valid after `await`.
5. **NEVER annotate an entire class `@MainActor` to "fix" concurrency warnings** without understanding which methods actually require main-thread execution -- this serializes all work through the main actor, which degrades performance and blocks the UI thread on computation-heavy tasks.
6. **ALWAYS specify a `bufferingPolicy` when creating `AsyncStream`** -- the default `.unbounded` policy can cause unbounded memory growth if the producer is faster than the consumer; use `.bufferingNewest(1)` for live sensor data where only the latest value matters, or `.bufferingOldest(n)` for ordered queues.
7. **NEVER use `@unchecked Sendable` as a quick fix** -- this suppresses compiler checks without providing actual thread safety; use it only when you have manually verified thread safety through an external mechanism (e.g., a lock-protected class that has been audited), and always add a comment documenting the manual guarantee.
8. **ALWAYS cancel tasks stored as properties when their owner is deallocated** -- call `task?.cancel()` in `deinit` or `viewDidDisappear`; failing to do this causes tasks to outlive their owning objects, leading to crashes when the task accesses deallocated memory via a weak reference that has become nil.
9. **NEVER perform UI updates from outside `@MainActor`** -- even in Swift concurrency code, UIKit and SwiftUI mutations must happen on the main thread; always wrap view updates in `await MainActor.run {}` or mark the calling function `@MainActor`.
10. **ALWAYS prefer structured concurrency over unstructured tasks** when the tasks are logically children of the current operation -- structured concurrency (`async let`, `TaskGroup`) guarantees that child tasks are cancelled and awaited before the scope exits, preventing resource leaks; use `Task {}` only when the work is genuinely independent of the current task's lifecycle.
---
## Edge Cases
### Actors and Inheritance
Swift actors cannot be subclassed -- they are implicitly `final`. If you have existing class hierarchies that need actor-like isolation, you cannot simply change the base class to `actor`. Instead, extract the mutable state into a dedicated actor, inject it into each class via dependency injection, and have the classes delegate state mutations to the actor. Alternatively, use a protocol with `@MainActor` or a custom global actor annotation applied at the protocol level so all conforming types share the same isolation.
### Mixing Swift Concurrency with Objective-C Delegates
Many UIKit APIs use delegate callbacks (e.g., `UIImagePickerControllerDelegate`, `MKLocalSearchCompleter`). These callbacks often arrive on the main thread but are not annotated with `@MainActor` in the SDK because they predate Swift concurrency. Wrapping them in `AsyncStream` is correct, but you must ensure:
- The continuation's `yield` is called from within the correct actor context; add `MainActor.assumeIsolated {}` in iOS 17+ to satisfy the compiler without a full `await` hop
- The `AsyncStream` is finished on `imagePickerControllerDidCancel` or equivalent terminal delegate method; failure to finish the stream leaks the iterator
- The lifetime of the delegate object is tied to the stream's `onTermination` handler -- cancel the operation and set the delegate to nil when the stream terminates
### Priority Inversion
Swift concurrency has automatic priority escalation to address priority inversion, but it does not eliminate it entirely. If a low-priority background task holds a lock (e.g., inside a `nonisolated` method using `NSLock`) and a high-priority task is waiting on the same lock, the system cannot escalate priority for lock-based synchronization -- only actor contention benefits from priority escalation. In performance-sensitive code:
- Replace lock-based synchronization inside hot paths with actor-isolated state
- Use `Task(priority: .userInitiated)` explicitly for work that must complete before a visible UI update
- Profile with Instruments' "CPU Profiler" template looking for unexpectedly long "waiting for actor" intervals
### Recursive Actor Methods
Calling an actor-isolated method from within the same actor is allowed synchronously (no `await` needed from within the actor itself). However, calling `await self.someActorMethod()` from inside the same actor is legal but creates a suspension point, allowing re-entrancy. This pattern is subtle and dangerous:
```swift
actor Coordinator {
var isProcessing = false
func process() async {
isProcessing = true
await self.finalize() // suspension point -- isProcessing can be read as true by another caller
isProcessing = false
}
}
Avoid awaiting self-referential actor calls. Extract shared logic into nonisolated helpers that take actor state as parameters, or restructure the code to avoid the suspension between dependent state reads and writes.
Sendable Closures Capturing Non-Sendable Types
When passing a closure to Task {} or TaskGroup.addTask {}, the closure must be @Sendable. This means any captured values must be Sendable. Common non-Sendable types that get captured: UIImage, UIColor, NSAttributedString, AVAudioPlayer, and most @objc delegate objects. Solutions:
- Convert
UIImage to Data (a Sendable value type) before crossing the task boundary; reconstruct the image on the other side
- Mark classes as
@unchecked Sendable only after auditing thread safety
- Use
@MainActor-isolated types to remove the need to send them across boundaries -- the compiler knows they stay on the main actor
- In Xcode 16+ with Swift 6 language mode, these errors are errors, not warnings; plan migration ahead of adopting Swift 6
TaskGroup Child Task Failure Semantics
withThrowingTaskGroup has a subtle behavior: when any child task throws, the group immediately cancels all remaining child tasks and rethrows the error when the group body returns. If you are implementing a "download all, ignore individual failures" pattern:
try await withThrowingTaskGroup(of: Data.self) { group in
for url in urls { group.addTask { try await fetch(url) } }
for try await data in group { results.append(data) }
}
await withTaskGroup(of: Result<Data, Error>.self) { group in
for url in urls {
group.addTask { do { return .success(try await fetch(url)) } catch { return .failure(error) } }
}
for await result in group { }
}
Always choose the group type and error-catching location deliberately based on whether partial failure should abort the entire operation.
SwiftUI .task Modifier and State Updates
The .task {} modifier creates a task scoped to the view's lifetime, but the task body runs on the main actor when the view is @MainActor-isolated (which all SwiftUI views are). Updating @State or @StateObject from inside .task {} is safe because the task is already on the main actor. However:
- Avoid long synchronous work inside
.task {} -- use Task.yield() to voluntarily suspend in CPU-heavy loops, allowing the main actor to process UI events
- When the view reappears (e.g., after a sheet dismiss), SwiftUI creates a new task and cancels the previous one; design the task body to be re-entrant and handle
CancellationError by exiting cleanly without logging an error
- For paginated data loading triggered by scroll position, combine
.task(id:) with the current page index as the id -- the task is automatically cancelled and restarted whenever the page changes
Example
Input: "I'm building an iOS photo browsing app. When a user opens an album, I need to fetch metadata for up to 200 photos in parallel from a REST API, limit concurrency to avoid overwhelming the server, update a progress indicator, and support cancellation if the user navigates away. We're targeting iOS 16, Swift 5.9."
Output:
Swift Concurrency Analysis
Problem Classification
- Category: parallelism with rate limiting, UI state updates, lifecycle-bound cancellation
- Execution context: custom actor for download state,
@MainActor for progress updates
- Swift/iOS minimum version required: iOS 16 / Swift 5.9
Concurrency Primitive Decision
| Requirement | Primitive | Rationale |
|---|
| Fetch 200 photos in parallel | withTaskGroup | Dynamic count known at call time; enables streaming result processing |
| Limit concurrency to N parallel requests | Manual token semaphore via actor | TaskGroup has no built-in rate limiting; an actor-based token pool controls in-flight count |
| Update progress indicator | @MainActor isolated property + AsyncStream | Progress changes must reach the UI thread; stream decouples producer from UI |
| Cancel on view disappear | .task {} modifier on SwiftUI view, or task.cancel() in viewDidDisappear | Structured task cancellation propagates to all child tasks in the group |
Recommended Pattern
Rate-Limited Parallel Task Group with Actor Token Pool
Fetch photos in parallel using withThrowingTaskGroup, control concurrency via an actor-based token pool that limits simultaneous in-flight requests, and stream progress back to the UI via @MainActor state updates.
Implementation
import Foundation
actor RateLimiter {
private let maxConcurrent: Int
private var available: Int
init(maxConcurrent: Int) {
self.maxConcurrent = maxConcurrent
self.available = maxConcurrent
}
func acquire() async {
while available == 0 {
await Task.yield()
}
available -= 1
}
func release() {
available = min(available + 1, maxConcurrent)
}
}
struct PhotoMetadata: Sendable {
let id: String
let title: String
let thumbnailURL: URL
let dateTaken:
}
: {
photos: [] []
progress:
error: ?
isLoading
fetchTask: <, >?
(: []) {
fetchTask.cancel()
fetchTask {
isLoading
progress
photos []
error
{
fetched fetchAllPhotos(photoIDs: photoIDs)
photos fetched
} {
} {
.error error
}
isLoading
}
}
() {
fetchTask.cancel()
fetchTask
}
(: []) -> [] {
rateLimiter (maxConcurrent: )
total (photoIDs.count)
completed
results: [] []
results.reserveCapacity(photoIDs.count)
withThrowingTaskGroup(of: .) { group
id photoIDs {
.checkCancellation()
group.addTask {
rateLimiter.acquire()
{ { rateLimiter.release() } }
.checkCancellation()
.fetchMetadata(id: id)
}
}
metadata group {
results.append(metadata)
completed
progress (completed) total
}
}
results.sorted { .dateTaken .dateTaken }
}
}
{
(: ) -> {
url (string: )
request (url: url)
request.timeoutInterval
(data, response) .shared.data(for: request)
httpResponse response ,
().contains(httpResponse.statusCode) {
.invalidResponse
}
().decode(., from: data)
}
}
: {
invalidResponse
}
SwiftUI
: {
photoIDs: []
viewModel ()
body: {
{
photoGrid
viewModel.isLoading {
progressOverlay
}
}
.task {
viewModel.loadAlbum(photoIDs: photoIDs)
}
.onDisappear {
viewModel.cancelLoad()
}
}
photoGrid: {
{
(columns: [(.adaptive(minimum: ))]) {
(viewModel.photos, id: \.id) { photo
(url: photo.thumbnailURL)
.frame(width: , height: )
}
}
}
}
progressOverlay: {
{
(value: viewModel.progress)
.padding()
()
.monospacedDigit()
}
.background(.regularMaterial)
.cornerRadius()
}
}
Cancellation Strategy
Cancellation propagates through the structured task hierarchy:
- The SwiftUI
.task {} modifier cancels the loadAlbum task when the view disappears
AlbumViewModel.cancelLoad() explicitly cancels fetchTask on onDisappear as a belt-and-suspenders guard
- Inside
fetchAllPhotos, try Task.checkCancellation() before each addTask prevents queuing new work after cancellation is requested
- Each child task in the
TaskGroup independently calls checkCancellation before the network request
withThrowingTaskGroup automatically cancels all running child tasks when any task throws -- including CancellationError
CancellationError is caught at the loadAlbum level and swallowed silently; the UI resets to empty state without showing an error message
Testing Approach
@MainActor
final class AlbumViewModelTests: XCTestCase {
func testLoadAlbumUpdatesProgress() async throws {
let mockIDs = (0..<20).map { "photo-\($0)" }
let viewModel = AlbumViewModel()
await viewModel.loadAlbum(photoIDs: mockIDs)
XCTAssertEqual(viewModel.photos.count, 20)
XCTAssertEqual(viewModel.progress, 1.0, accuracy: 0.001)
XCTAssertFalse(viewModel.isLoading)
}
func testCancellationProducesNoError() async {
let viewModel = AlbumViewModel()
let bigList = (0..<200).map { "photo-\($0)" }
let task = Task { await viewModel.loadAlbum(photoIDs: bigList) }
task.cancel()
await task.value
XCTAssertNil(viewModel.error, )
}
() {
limiter (maxConcurrent: )
peak
current
lock ()
withTaskGroup(of: .) { group
{
group.addTask {
limiter.acquire()
lock.lock()
current
peak (peak, current)
lock.unlock()
.sleep(for: .milliseconds())
lock.lock()
current
lock.unlock()
limiter.release()
}
}
}
(peak, )
}
}
Performance Considerations
- 10 concurrent requests is appropriate for most REST APIs on a mobile network; reduce to 5 for APIs with strict rate limits (check
Retry-After headers); increase to 20 only for local network or CDN endpoints with sub-5ms response times
- The
RateLimiter actor uses Task.yield() polling which has lower overhead than a semaphore but slightly higher than a condition variable; for high-frequency operations, consider an actor-based queue with AsyncStream as a token channel for O(1) wakeup
results.reserveCapacity(photoIDs.count) prevents repeated array reallocations during the result collection loop -- at 200 items this saves approximately 7 reallocation cycles
- Sorting 200 items by date after fetch is O(n log n) and takes under 1ms; this is acceptable as a final step; avoid sorting inside the
for await loop which would be O(n² log n) total
- The
@MainActor progress update inside fetchAllPhotos executes approximately 200 main-actor hops; at ~50ns per hop this is ~10µs total overhead -- negligible, but if you needed to update every byte received, batch updates using a counter and yield to main only every 10 completions
Migration Notes
If the existing codebase uses DispatchGroup + DispatchSemaphore for this pattern:
let group = DispatchGroup()
let semaphore = DispatchSemaphore(value: 10)
let queue = DispatchQueue(label: "photo.fetch", attributes: .concurrent)
var results: [PhotoMetadata] = []
let lock = NSLock()
for id in photoIDs {
group.enter()
queue.async {
semaphore.wait()
PhotoAPI.legacyFetch(id: id) { metadata in
lock.lock()
results.append(metadata)
lock.unlock()
semaphore.signal()
group.leave()
}
}
}
group.notify(queue: .main) { completion(results) }
Migrate incrementally:
- First, wrap
PhotoAPI.legacyFetch in withCheckedThrowingContinuation to get an async version
- Replace
DispatchGroup + result aggregation with withThrowingTaskGroup -- this is the highest-value change
- Replace
DispatchSemaphore with the RateLimiter actor
- Replace
NSLock-protected results array with direct result collection in the for await loop inside the group body
- Finally, remove the
DispatchQueue -- Swift concurrency manages the thread pool automatically
Each step is independently compilable and testable. Steps 1 and 2 provide the largest safety improvement; complete them first before addressing the remaining GCD infrastructure.