Swift Concurrency in Practice: Structured Concurrency, Actors, and AsyncSequence
Published:
Swift Concurrency makes async code safer and easier to reason about. This post shows practical patterns for using async/await
, task groups, actors, and AsyncSequence
in production iOS apps.
1. Structured Concurrency
struct FeedService {
func loadHome() async throws -> HomeViewModel {
async let articles = fetchArticles()
async let highlights = fetchHighlights()
async let profile = fetchProfile()
return try await HomeViewModel(
articles: articles,
highlights: highlights,
profile: profile
)
}
}
Benefits: automatic child task cancellation, predictable lifetimes, and better error aggregation.
2. Task Groups for Fanāout/Fanāin
func fetchDetails(ids: [UUID]) async throws -> [Detail] {
try await withThrowingTaskGroup(of: Detail.self) { group in
for id in ids {
group.addTask { try await fetchDetail(id: id) }
}
var results: [Detail] = []
for try await detail in group { results.append(detail) }
return results
}
}
Control concurrency with a Semaphore or AsyncSemaphore
if the backend has QPS limits.
3. Actors for Data Races
actor ImageCache {
private var store: [URL: UIImage] = [:]
func get(_ url: URL) -> UIImage? { store[url] }
func set(_ image: UIImage, for url: URL) { store[url] = image }
}
let cache = ImageCache()
func loadImage(url: URL) async throws -> UIImage {
if let cached = await cache.get(url) { return cached }
let image = try await download(url)
await cache.set(image, for: url)
return image
}
Use @MainActor
for UI types or view models that must run on the main thread.
4. Cancellation and Timeouts
func search(query: String) async throws -> [ResultItem] {
try Task.checkCancellation()
return try await withThrowingTaskGroup(of: [ResultItem].self) { group in
let deadline = ContinuousClock().now.advanced(by: .seconds(2))
group.addTask { try await searchRemote(query) }
group.addTask { try await searchLocal(query) }
return try await withTaskCancellationHandler {
for try await result in group { return result }
return []
} onCancel: {
group.cancelAll()
}
}
}
Propagate cancellation to child tasks and cancel stale requests when the query changes.
5. AsyncSequence for Streams
struct EventStream: AsyncSequence {
typealias Element = Event
struct AsyncIterator: AsyncIteratorProtocol {
var client: EventClient
mutating func next() async -> Event? { await client.nextEvent() }
}
let client: EventClient
func makeAsyncIterator() -> AsyncIterator { AsyncIterator(client: client) }
}
@MainActor
final class EventViewModel: ObservableObject {
@Published var events: [Event] = []
func start(stream: EventStream) {
Task { [weak self] in
guard let self else { return }
for await event in stream { self.events.append(event) }
}
}
}
Map streaming network data (ServerāSent Events, WebSockets) into UI updates with backāpressure handled by the runtime.
6. Testing Concurrency Code
final class FeedServiceTests: XCTestCase {
func testLoadHome() async throws {
let sut = FeedService()
let home = try await sut.loadHome()
XCTAssertFalse(home.articles.isEmpty)
}
}
Use XCTest
ās async support and @TestActor
isolation where appropriate.
These patterns yield predictable, cancelable, and testable async code that scales with product complexity without introducing data races or callback hell.
7. Actor Reentrancy, Isolation, and Nonāisolated APIs
Actors are reentrant: they may suspend between await
points and process other messages. Guard invariants carefully.
actor RateLimiter {
private var lastCall: Date = .distantPast
private let minInterval: TimeInterval = 0.2
func execute<T>(_ work: @Sendable () async throws -> T) async rethrows -> T {
let now = Date()
let delta = now.timeIntervalSince(lastCall)
if delta < minInterval { try await Task.sleep(nanoseconds: UInt64((minInterval - delta) * 1_000_000_000)) }
lastCall = Date()
return try await work()
}
}
Expose computed properties that are cheap as nonisolated
if they donāt access actor state, to avoid hops.
8. Sendable, @MainActor, and Isolation Violations
Mark crossāconcurrency data as Sendable
to prevent threadāunsafety.
struct Profile: Sendable { let id: UUID; let name: String }
@MainActor
final class ProfileViewModel: ObservableObject {
@Published var state: State = .idle
}
Use @unchecked Sendable
only for carefully audited, immutable wrappers.
9. Structured Cancellation Patterns
Tie the lifetime of work to view lifecycles.
@MainActor
final class SearchViewModel: ObservableObject {
@Published var results: [ResultItem] = []
private var searchTask: Task<Void, Never>?
func updateQuery(_ q: String) {
searchTask?.cancel()
searchTask = Task { [weak self] in
guard let self else { return }
do {
let items = try await self.search(query: q)
self.results = items
} catch is CancellationError { /* ignore */ }
}
}
}
10. AsyncSequence Operators and Backāpressure
Compose sequences with transformations and buffering.
extension AsyncSequence {
func throttle(for interval: Duration) -> AsyncThrowingStream<Element, Error> where Self: Sendable {
AsyncThrowingStream { continuation in
Task {
var last = ContinuousClock.now
for try await value in self {
let now = ContinuousClock.now
if now.durationSince(last) >= interval {
continuation.yield(value)
last = now
}
}
continuation.finish()
}
}
}
}
Use buffering (AsyncChannel
, AsyncStream(bufferingPolicy:)
) to decouple producers/consumers.
11. Task Priorities, Detachment, and Executors
Prefer structured tasks; use Task.detached
sparingly for fireāandāforget, and set priorities intentionally.
Task(priority: .userInitiated) { await refreshAboveTheFold() }
Task(priority: .utility) { await prefetchBelowTheFold() }
Avoid doing heavy work on the main actor; annotate computeāheavy APIs as nonāmain.
12. Bridging Combine and Async/Await
extension Publisher where Failure == Never {
func values() async -> AsyncStream<Output> {
AsyncStream { continuation in
let cancellable = sink { continuation.finish() } receiveValue: { continuation.yield($0) }
continuation.onTermination = { _ in cancellable.cancel() }
}
}
}
This lets you iterate publisher values with for await
to simplify view models.
13. Testing Concurrency: Determinism and Time
Use ImmediateClock
/TestClock
or dependencyāinjected clocks to control time in tests.
struct Timeouts { var clock: any Clock = ContinuousClock() }
final class SearchTests: XCTestCase {
func testThrottle() async throws {
let testClock = TestClock()
// Inject testClock into throttled sequence to advance time deterministically
}
}
14. Concurrency Checklist
- No actor isolation violations at build with strict concurrency checks
- Cancellation on navigation and query change
- Bounded parallelism for fanāout requests
@MainActor
only where UIācritical- Backāpressure on streams; buffering where needed
Share on
Twitter Facebook LinkedInā Buy me a coffee! š
If you found this article helpful, consider buying me a coffee to support my work! š