Concurrency in Swift has come a long way. If you’ve been working in iOS for more than a couple of years, you’ve probably felt the pain of juggling DispatchQueue, OperationQueue, semaphores, or worse—nested completion handlers tangled into callback hell.

Structured concurrency, introduced in Swift 5.5, changed the game. With async/await, Task, and actors, our code became more readable and maintainable. But there are more advanced tools that deserve attention—particularly when you’re dealing with event streams, background queues, or building asynchronous APIs from scratch.

In this post, I’ll unpack three such tools: AsyncQueue, AsyncStream, and AsyncSequence. These are not just syntactic sugar—they allow us to build clean, robust, and testable async flows in Swift.


🔁 AsyncQueue: Ordered Execution in an Async World

What is AsyncQueue?

AsyncQueue is a concurrency pattern that ensures async tasks are executed serially—in order—even though they’re defined using Swift’s structured concurrency tools.

This pattern is not part of the standard library but can be implemented easily using Task and actor. Think of it as an OperationQueue where each operation is an async closure, and operations are executed one after another.

Why Use AsyncQueue?

Let’s say you need to:

  • Process user events in order (e.g., keyboard input, game moves)
  • Save user data one chunk at a time
  • Sequentially animate UI elements
  • Avoid race conditions without locking

A typical GCD serial queue could work, but using Task and actor makes the solution more idiomatic and testable in modern Swift.

Implementation

actor AsyncQueue {
    private var lastTask: Task<Void, Never>?

    func enqueue(_ operation: @escaping () async -> Void) {
        let previousTask = lastTask
        lastTask = Task {
            await previousTask?.value
            await operation()
        }
    }

    func cancelAll() {
        lastTask?.cancel()
        lastTask = nil
    }
}

Example: Saving Files in Order

let fileQueue = AsyncQueue()

func saveFile(named name: String, data: Data) {
    fileQueue.enqueue {
        let url = FileManager.default.temporaryDirectory.appendingPathComponent(name)
        try? data.write(to: url)
        print("Saved file: \(name)")
    }
}

saveFile(named: "a.txt", data: Data("Hello".utf8))
saveFile(named: "b.txt", data: Data("World".utf8))

Each saveFile call is async, but they are guaranteed to run in the order they were enqueued.


🌊 AsyncStream: Observing Asynchronous Events

What is AsyncStream?

AsyncStream is a bridge between the old world (delegates, notifications, and callbacks) and the new world of async/await. It lets you create an asynchronous sequence of values that can be iterated over using for await.

It’s particularly useful when:

  • Wrapping callback-based APIs
  • Observing system notifications or events
  • Handling user input streams
  • Consuming WebSocket messages

Basic Example: Wrapping a Timer

func makeTimerStream(interval: TimeInterval) -> AsyncStream<Date> {
    AsyncStream { continuation in
        let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
            continuation.yield(Date())
        }

        continuation.onTermination = { @Sendable _ in
            timer.invalidate()
        }
    }
}

Using the Stream

for await tick in makeTimerStream(interval: 1.0) {
    print("Tick: \(tick)")
}

This is more readable and maintainable than any delegate-based approach or closure chain.

Example: Bridging NotificationCenter

func observeKeyboardNotifications() -> AsyncStream<Notification> {
    AsyncStream { continuation in
        let observer = NotificationCenter.default.addObserver(
            forName: UIResponder.keyboardWillShowNotification,
            object: nil,
            queue: .main
        ) { notification in
            continuation.yield(notification)
        }

        continuation.onTermination = { _ in
            NotificationCenter.default.removeObserver(observer)
        }
    }
}

Task {
    for await notification in observeKeyboardNotifications() {
        print("Keyboard will show: \(notification)")
    }
}

No more dealing with observer tokens or forgetting to unregister.


📜 AsyncSequence: Build Your Own Streams

What is AsyncSequence?

AsyncSequence is the asynchronous equivalent of Sequence. It’s a protocol you conform to when you want to produce values over time, possibly involving suspensions.

It’s a low-level building block for things like:

  • Custom data pipelines
  • Throttled or debounced inputs
  • Reading from a socket or file line-by-line
  • Controlling backpressure and retries

Anatomy of an AsyncSequence

To conform to AsyncSequence, you provide a type that returns an AsyncIterator. This iterator conforms to AsyncIteratorProtocol, which defines a next() async method.

Custom Example: Debounced Input

struct DebouncedInput: AsyncSequence {
    typealias Element = String

    let inputs: [String]
    let interval: TimeInterval

    struct Iterator: AsyncIteratorProtocol {
        var inputs: [String]
        let interval: TimeInterval

        mutating func next() async -> String? {
            guard !inputs.isEmpty else { return nil }
            try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
            return inputs.removeFirst()
        }
    }

    func makeAsyncIterator() -> Iterator {
        Iterator(inputs: inputs, interval: interval)
    }
}

Usage

let searchTerms = DebouncedInput(inputs: ["S", "Sw", "Swi", "Swif", "Swift"], interval: 0.5)

for await term in searchTerms {
    print("Searching for: \(term)")
}

Perfect for building custom debounced or throttled inputs, such as for a search bar.


🧠 When to Use What?

Tool Best For
AsyncQueue Ensuring async tasks run in order, one at a time
AsyncStream Bridging old-world callbacks or notifications into structured async flows
AsyncSequence Building reusable async iterators with custom logic and flow control

🧪 Testing These Components

You can easily test these tools using XCTestExpectation or dependency injection.

For example, you can mock an AsyncSequence to simulate input for a ViewModel:

struct MockInputSequence: AsyncSequence {
    typealias Element = String

    let values: [String]

    struct Iterator: AsyncIteratorProtocol {
        var index = 0
        let values: [String]

        mutating func next() async -> String? {
            guard index < values.count else { return nil }
            defer { index += 1 }
            return values[index]
        }
    }

    func makeAsyncIterator() -> Iterator {
        Iterator(values: values)
    }
}

Use this in unit tests to simulate user input or background events.


🧭 Final Thoughts

Swift Concurrency is not just about replacing GCD. It’s about creating a fundamentally better model for expressing time-based, order-sensitive, and event-driven code.

  • Use AsyncQueue when you need guaranteed serial execution.
  • Reach for AsyncStream when bridging old APIs or dealing with events over time.
  • Build AsyncSequence types when you need full control over async iteration.

When you embrace these tools, you’ll write code that’s not just cleaner—it’ll match the actual behavior and expectations of your apps. Fewer race conditions. No callbacks. Just clarity.

Swift’s concurrency model still has its quirks, but tools like these make asynchronous programming feel like a natural part of the language.

Stay curious, and keep leveling up.