This is Part 2 of a 3-part series on Swift 6 Concurrency:
- Part 1: The Fundamentals - async/await, actors, Sendable, MainActor
- Part 2: Advanced Patterns (you are here) - AsyncSequence, task priorities, SwiftUI integration, practical guidance
- Part 3: Migration Guide - Strategic planning for production codebases
Table of Contents
Streaming Data with AsyncSequence
Sometimes you don't just want a single result—you want a stream of results over time. Think sensor readings, WebSocket messages, or live search results.
This example creates a stream that emits sensor readings every 0.5 seconds. Notice how we use continuation.yield() to emit values and continuation.finish() to signal completion:
struct SensorData: Sendable {
let id: Int
let temperature: Double
let humidity: Double
let timestamp: Date
}
func createSensorStream() -> AsyncStream<SensorData> {
AsyncStream { continuation in
Task {
defer { continuation.finish() }
for i in 1...100 {
try await Task.sleep(for: .seconds(0.5))
let data = SensorData(
id: i,
temperature: Double.random(in: 18.0...28.0),
humidity: Double.random(in: 30.0...70.0),
timestamp: Date()
)
continuation.yield(data)
}
}
}
}
func monitorSensors() async {
let stream = createSensorStream()
for await reading in stream {
print("Temp: \(reading.temperature)°C")
updateUI(with: reading)
}
print("Stream ended")
}
This replaces Combine's Publisher pattern with cleaner syntax
Here's a side-by-side comparison showing how AsyncSequence simplifies reactive streams:
sensorPublisher
.receive(on: DispatchQueue.main)
.sink { reading in
updateUI(with: reading)
}
.store(in: &cancellables)
for await reading in sensorStream {
updateUI(with: reading)
}
Use cases:
- Live search results (yield as user types)
- WebSocket messages
- File downloads with progress updates
- Real-time sensor data
- Server-sent events (SSE)
Task Priorities & Scheduling
Not all tasks are equal. Some are critical for UI responsiveness, others can run in the background. Swift's TaskPriority has four distinct priority levels, though it exposes six names (two pairs are aliases).
This example demonstrates the priority levels and when to use each one. Remember: priorities influence but don't guarantee execution order:
Task(priority: .userInitiated) {
await updateVisibleCells()
}
Task(priority: .medium) {
await performNormalWork()
}
Task(priority: .utility) {
await processLargeDataset()
}
Task(priority: .background) {
await syncAnalytics()
}
Important notes:
- There are four distinct priority levels:
.userInitiated/.high, .medium, .utility/.low, and .background. The aliases exist for semantic clarity — use whichever name best describes your intent.
- Priorities influence scheduling but don't guarantee order
- Higher priority tasks get more CPU time when system is busy
- The system considers other factors (battery, thermal state)
Cooperative scheduling with Task.yield()
When a task is doing lots of work in a loop, it can monopolize the CPU. This example shows how to yield control periodically, giving other tasks a chance to run:
Task {
for i in 1...1000 {
performWork(step: i)
await Task.yield()
}
}
Task.yield() is like saying "I'm doing fine, let others run if they need to." This prevents one task from hogging the CPU.
Custom Global Actors
While @MainActor is the most common global actor, you can create custom global actors for domain-specific isolation:
@globalActor
actor DatabaseActor {
static let shared = DatabaseActor()
}
@DatabaseActor
class DatabaseManager {
private var cache: [String: Data] = [:]
func save(_ data: Data, forKey key: String) async throws {
cache[key] = data
}
func load(forKey key: String) async -> Data? {
return cache[key]
}
}
let manager = DatabaseManager()
await manager.save(data, forKey: "user")
When to use: Rarely. Most apps only need @MainActor. Only introduce custom global actors when you have clear, documented isolation requirements:
- Database access that must be serialized
- File system operations
- Hardware resource access (camera, location services)
Similar to singletons: Global actors provide singleton-like behavior with actor isolation guarantees. The key difference is that global actors enforce compile-time isolation, preventing accidental concurrent access.
⚠️ Don't overuse: Creating many global actors can lead to complex isolation hierarchies. Start with actors for shared state, use @MainActor for UI, and only create custom global actors when you have a compelling reason.
SwiftUI Integration
Swift 6's concurrency checking extends to SwiftUI modifiers. SwiftUI increasingly runs performance-sensitive code off the main thread for better responsiveness:
- Animation calculations - Complex animations computed concurrently
- Layout computations - Heavy layout work offloaded from main thread
- Visual effects - Modifiers like
.visualEffect run off main thread
Understanding when SwiftUI uses background execution and how to safely capture values is crucial for avoiding data races.
This example creates a parallax scroll effect. The commented-out code shows the WRONG way (data race), while the active code demonstrates the CORRECT way using a capture list:
struct ParallaxScrollView: View {
@StateObject private var viewModel = ParallaxViewModel()
var body: some View {
ScrollView {
ForEach(0..<15, id: \.self) { index in
ItemView(index: index)
.visualEffect { [intensity = viewModel.parallaxIntensity] content, proxy in
let offset = proxy.frame(in: .scrollView).minY
return content.offset(y: offset * intensity)
}
}
}
}
}
@MainActor
class ParallaxViewModel: ObservableObject {
@Published var parallaxIntensity: Double = 0.3
}
Why the capture list works:
[intensity = viewModel.parallaxIntensity] reads the value on the main thread
- Creates a local copy (Double is a value type, automatically Sendable)
- The closure captures the copy, not the
@MainActor viewModel
- No cross-thread access to the ViewModel
This is better than GeometryReader because:
- Runs off main thread → smoother scrolling
- Doesn't affect layout like GeometryReader can
- Compiler enforces safety at compile-time
When to Use Each Pattern
Here's a decision tree for choosing the right concurrency pattern:
Need to share mutable state across tasks?
→ Use actor (serialized access, thread-safe)
All your methods should run on main thread? (UI classes)
→ Use @MainActor class
Have a method that needs to run off MainActor?
→ Use nonisolated within a @MainActor class. A regular Task { } calling a nonisolated async function will already run it off the main actor. Only use Task.detached if you need to avoid inheriting the parent task's priority or task-local values.
Need to stream multiple values over time?
→ Use AsyncSequence or AsyncStream
User might cancel the operation?
→ Store a Task reference and call .cancel(), check Task.isCancelled periodically
Multiple independent async operations?
→ Use async let (fixed count) or withTaskGroup (dynamic count)
Passing data between concurrency domains?
→ Use immutable structs (Sendable by default), or actors
Need manual control over thread safety?
→ Use @unchecked Sendable with locks (⚠️ last resort - treat like force unwrapping)
Migrating from Combine to async/await
If you're coming from Combine, here's how patterns translate. This section shows how to convert Combine code to async/await at the code level. For comprehensive migration planning including strict concurrency, @preconcurrency, modularization strategies, and team coordination, see Part 3: Migration Guide.
Let's look at a typical ViewModel that fetches data—first with Combine's reactive approach, then with Swift 6's async/await.
Before (Combine)
This approach uses publishers, sinks, and cancellable storage. Notice the complexity of managing subscriptions and thread hopping:
class ViewModel: ObservableObject {
@Published var users: [User] = []
private var cancellables = Set<AnyCancellable>()
func fetchUsers() {
userService.fetchUsers()
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { completion in
if case .failure(let error) = completion {
print("Error: \(error)")
}
},
receiveValue: { [weak self] users in
self?.users = users
}
)
.store(in: &cancellables)
}
}
After (async/await)
The same functionality with async/await is dramatically simpler. No subscription management, no explicit thread hopping:
@MainActor
class ViewModel: ObservableObject {
@Published var users: [User] = []
func fetchUsers() {
Task {
do {
let users = try await userService.fetchUsers()
self.users = users
} catch {
print("Error: \(error)")
}
}
}
}
Key improvements:
- No more
Set<AnyCancellable>() storage
- No more
.receive(on:) for thread hopping
- No more
[weak self] dance in most cases. In a @MainActor class, Task { } captures self strongly, which is fine for ViewModels owned by a view. But for long-lived objects with independent lifecycles, [weak self] is still needed to avoid retain cycles.
- Linear code flow, easier to read
@MainActor guarantees main thread execution
Publishers → async functions:
func fetchUser() -> AnyPublisher<User, Error>
func fetchUser() async throws -> User
Note: @Published still works great with async/await! Keep using it for reactive UI updates.
Converting Callback-Based APIs
For legacy APIs using completion handlers, use continuations to bridge to async/await:
Callback-based API:
func fetchUser(completion: @escaping (Result<User, Error>) -> Void) {
URLSession.shared.dataTask(with: url) { data, response, error in
}.resume()
}
Wrapped with continuation:
func fetchUser() async throws -> User {
try await withCheckedThrowingContinuation { continuation in
fetchUser { result in
continuation.resume(with: result)
}
}
}
let user = try await fetchUser()
Continuation types:
withCheckedContinuation - Non-throwing, validates single resume (recommended for production)
withCheckedThrowingContinuation - Throwing, validates single resume (recommended for production)
withUnsafeContinuation - Non-throwing, no validation (use only if profiling shows performance issues)
withUnsafeThrowingContinuation - Throwing, no validation (use only if profiling shows performance issues)
⚠️ Unsafe continuations are not recommended unless you have profiled your code with Instruments and identified continuation checks as a bottleneck (extremely rare in practice). The Swift team, Donny Wals, and Paul Hudson all recommend checked continuations for production code. Making code less safe based on assumptions about performance is a mistake—measure first.
⚠️ Critical rule: Resume the continuation exactly once. Multiple resumes crash in checked variants, cause undefined behavior in unsafe variants. Missing a resume will leak the continuation and suspend forever.
try await withCheckedThrowingContinuation { continuation in
someAPI { result in
if result.isValid {
continuation.resume(returning: result)
}
continuation.resume(returning: defaultValue)
}
}
try await withCheckedThrowingContinuation { continuation in
someAPI { result in
if result.isValid {
continuation.resume(returning: result)
} else {
continuation.resume(returning: defaultValue)
}
}
}
When to use: Wrapping legacy callback-based APIs (URLSession completion handlers, delegate callbacks, third-party SDKs) when migrating incrementally to async/await.
Common Pitfalls
1. Forgetting await (Compiler catches this)
let user = fetchUser()
let user = await fetchUser()
2. Data races with non-Sendable types
class MutableData {
var value: Int = 0
}
Task.detached {
let data = mutableData
data.value = 42
}
struct ImmutableData: Sendable {
let value: Int
}
3. Blocking the main thread
@MainActor
func loadData() {
let result = performExpensiveComputation()
}
@MainActor
func loadData() {
Task {
let result = await performExpensiveComputation()
self.data = result
}
}
nonisolated func performExpensiveComputation() async -> String {
}
4. Not checking Task.isCancelled
task = Task {
for i in 1...1000 {
await processItem(i)
}
}
task = Task {
for i in 1...1000 {
if Task.isCancelled { return }
await processItem(i)
}
}
5. Over-using actors
actor MathHelper {
func add(_ a: Int, _ b: Int) -> Int {
return a + b
}
}
func add(_ a: Int, _ b: Int) -> Int {
return a + b
}
Rule of thumb: Only use actors when you have shared mutable state. Stateless or immutable types don't need protection.
Conclusion
Key Takeaways
-
async/await makes concurrent code readable: Code flows sequentially even though it executes asynchronously. No more callback hell.
-
Actors prevent data races: Swift 6 enforces safety at compile-time. Data races become compile errors, not runtime crashes.
-
Performance gains are real: Our examples showed 3x improvement with parallel execution (demonstrated in Part 1). That's not theoretical—that's what your users will experience.
-
Progressive adoption is possible: You don't need to rewrite your entire app. Start with one API call, migrate ViewModels gradually, enable strict concurrency warnings in Xcode.
-
@MainActor simplifies UI code: No more DispatchQueue.main.async everywhere. The compiler ensures you're on the main thread.
-
Value types (structs) are your friends: Immutable structs are automatically Sendable. Use them for data transfer between tasks.
Next Steps
Ready to adopt Swift 6 concurrency? Here's a learning path to build your knowledge progressively:
Phase 1: Learn the Fundamentals
- Understand async/await basics and Task creation
- Learn about MainActor and why UI code runs on the main thread
- Explore actors for thread-safe shared state
- Study the Sendable protocol and why it matters
- No pressure on timelines—focus on understanding the concepts
Phase 2: Experiment & Practice
- Build small example projects using the patterns from this guide
- Convert simple Combine code to async/await
- Practice using actors vs classes for different scenarios
- Run the interactive examples in the companion project
- Make mistakes in a safe environment
Phase 3: Apply to Your Projects
- Start with one simple feature or API call
- Apply patterns you've learned to real code
- Use stress tests to verify correctness (run operations 1000 times concurrently)
- Document what works for your codebase
- Build confidence through incremental application
Ready to migrate your production app? This guide focuses on learning patterns. For strategic planning, team coordination, modularization strategies, strict concurrency setup, and step-by-step implementation guidance, see Part 3: Migration Guide.
Testing is crucial: Concurrent bugs are notoriously hard to reproduce. Use stress tests like our examples to verify correctness—run operations hundreds or thousands of times to catch race conditions.
Resources
Official Documentation
WWDC Sessions
- Part 3: Migration Guide - Strategic planning for production migration
- How to Plan a Migration to Swift 6 by Donny Wals - Strategic migration advice
- Swift Forums - Ask questions and discuss Swift concurrency with the community
- Default Actor Isolation in Swift 6.2 - Understanding the new migration-friendly defaults
- @concurrent explained with code examples by Antoine van der Lee - Swift 6.2's @concurrent attribute for controlling isolation inheritance (covered in Part 1)
Video Tutorials
- Isolation, actors, sendable… a concurrency deep dive - Comprehensive technical deep dive into isolation concepts
- Mastering the concurrency concepts for Swift 6.2 - Swift 6.2 specific patterns and features
- Combine vs Async Algorithms: What are the differences? - Migrating from Combine operators to AsyncAlgorithms
Advanced Libraries
- apple/swift-async-algorithms - AsyncSequence operators (debounce, throttle, merge, zip) - the async/await equivalent of Combine operators. Essential for advanced stream processing.
Thanks for reading! Have questions or found this useful? I'd love to hear your concurrency migration stories. The future of Swift is concurrent, and it's safer and more performant than ever.
Series Navigation