Swift 6 Concurrency: Advanced Patterns (2/3)

image
image

Damjan Dabo

13 minute read in Development

Published on March 11, 2026

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() }  // Always signal completion

            for i in 1...100 {
                // Let CancellationError propagate to exit the loop
                try await Task.sleep(for: .seconds(0.5))

                // Generate sensor reading
                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)  // Emit value
            }
        }
    }
}

// Consume the stream
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:

// Old Combine way
sensorPublisher
    .receive(on: DispatchQueue.main)
    .sink { reading in
        updateUI(with: reading)
    }
    .store(in: &cancellables)

// New async/await way
for await reading in sensorStream {
    updateUI(with: reading)  // Already on main thread if in @MainActor
}

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:

// UI-critical work (highest priority)
Task(priority: .userInitiated) {
    await updateVisibleCells()
}

// Default priority
Task(priority: .medium) {
    await performNormalWork()
}

// Deferrable work (can wait)
// Note: .low and .utility are the same priority (0x11)
Task(priority: .utility) {
    await processLargeDataset()
}

// Background work (lowest priority, user-unaware)
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)

        // Give other tasks a chance to run
        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 {
    // All methods run on DatabaseActor
    private var cache: [String: Data] = [:]

    func save(_ data: Data, forKey key: String) async throws {
        // Database operations serialized on DatabaseActor
        cache[key] = data
    }

    func load(forKey key: String) async -> Data? {
        return cache[key]
    }
}

// Usage
let manager = DatabaseManager()
await manager.save(data, forKey: "user")  // Runs on DatabaseActor

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)
                    // ❌ WRONG - Data race!
                    // .visualEffect runs on background thread (@Sendable closure)
                    // .visualEffect { content, proxy in
                    //     let offset = proxy.frame(in: .scrollView).minY
                    //     // Accessing MainActor property from background thread!
                    //     return content.offset(y: offset * viewModel.parallaxIntensity)
                    // }

                    // ✅ CORRECT - Safe value capture
                    .visualEffect { [intensity = viewModel.parallaxIntensity] content, proxy in
                        let offset = proxy.frame(in: .scrollView).minY
                        // 'intensity' is a captured Double (value type, Sendable)
                        return content.offset(y: offset * intensity)
                    }
            }
        }
    }
}

@MainActor
class ParallaxViewModel: ObservableObject {
    @Published var parallaxIntensity: Double = 0.3
}

Why the capture list works:

  1. [intensity = viewModel.parallaxIntensity] reads the value on the main thread
  2. Creates a local copy (Double is a value type, automatically Sendable)
  3. The closure captures the copy, not the @MainActor viewModel
  4. 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  // Already on main thread!
            } 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:

// Combine
func fetchUser() -> AnyPublisher<User, Error>

// async/await
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) {
    // Legacy completion handler API
    URLSession.shared.dataTask(with: url) { data, response, error in
        // Process and call completion
    }.resume()
}

Wrapped with continuation:

func fetchUser() async throws -> User {
    try await withCheckedThrowingContinuation { continuation in
        fetchUser { result in
            continuation.resume(with: result)
        }
    }
}

// Now you can use it with async/await
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.

// ❌ WRONG - Multiple resumes
try await withCheckedThrowingContinuation { continuation in
    someAPI { result in
        if result.isValid {
            continuation.resume(returning: result)  // Resume #1
        }
        continuation.resume(returning: defaultValue)  // Resume #2 - CRASH!
    }
}

// ✅ CORRECT - Single resume
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)

// ❌ Error: Call to async function not in async context
let user = fetchUser()

// ✅ Correct
let user = await fetchUser()

2. Data races with non-Sendable types

// ❌ Warning in Swift 6 strict mode
class MutableData {
    var value: Int = 0
}

Task.detached {
    let data = mutableData  // Captured mutable class!
    data.value = 42
}

// ✅ Use struct or actor instead
struct ImmutableData: Sendable {
    let value: Int
}

3. Blocking the main thread

// ❌ DON'T do this!
@MainActor
func loadData() {
    // Synchronous heavy work on main thread
    let result = performExpensiveComputation()  // UI freezes!
}

// ✅ Do this instead
@MainActor
func loadData() {
    Task {
        let result = await performExpensiveComputation()  // Calls nonisolated function
        self.data = result
    }
}

nonisolated func performExpensiveComputation() async -> String {
    // Heavy work, not isolated to MainActor
}

4. Not checking Task.isCancelled

// ❌ Task keeps running even after cancellation
task = Task {
    for i in 1...1000 {
        await processItem(i)  // Continues even if cancelled
    }
}

// ✅ Respects cancellation
task = Task {
    for i in 1...1000 {
        if Task.isCancelled { return }
        await processItem(i)
    }
}

5. Over-using actors

// ❌ Overkill - no shared mutable state
actor MathHelper {
    func add(_ a: Int, _ b: Int) -> Int {
        return a + b  // Pure function, doesn't need actor!
    }
}

// ✅ Just use a regular function
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

  1. async/await makes concurrent code readable: Code flows sequentially even though it executes asynchronously. No more callback hell.

  2. Actors prevent data races: Swift 6 enforces safety at compile-time. Data races become compile errors, not runtime crashes.

  3. 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.

  4. 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.

  5. @MainActor simplifies UI code: No more DispatchQueue.main.async everywhere. The compiler ensures you're on the main thread.

  6. 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

Community Resources

Video Tutorials

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

SHARE ARTICLE

More from our blog