← All posts

AsyncSequence in Swift: Events as Values Over Time

How AsyncSequence works, when to use it, and why notifications, streams and UI events become easier to reason about with for await.

SwiftConcurrencyAsyncSequence

What AsyncSequence solves

Some values do not arrive once. They arrive over time: notifications, network updates, progress events, location changes, app lifecycle events or user-driven signals. AsyncSequence gives Swift a native way to model those values using structured concurrency instead of callbacks.

The core idea

An AsyncSequence is like a regular Sequence, but each next value may require waiting. Instead of a normal for loop, you consume it with for await. The loop suspends while waiting for the next element, then resumes when a new value arrives.

NotificationCenter example

A practical iOS example is observing app lifecycle notifications. NotificationCenter exposes an async notifications sequence, so you can consume app events with a regular-looking loop that suspends between values.

final class AppLifecycleObserver {
    private var task: Task<Void, Never>?

    func start() {
        task = Task {
            let notifications = NotificationCenter.default.notifications(
                named: UIApplication.didBecomeActiveNotification
            )

            for await notification in notifications {
                handleAppDidBecomeActive(notification)
            }
        }
    }

    func stop() {
        task?.cancel()
        task = nil
    }

    private func handleAppDidBecomeActive(_ notification: Notification) {
        // Refresh state, resume timers, or trigger analytics.
    }
}

Cancellation matters

AsyncSequence fits well with task cancellation. If the Task observing notifications is cancelled, the loop stops. In a view model, this means you can keep a Task reference and cancel it in deinit or when the feature is no longer visible.

@MainActor
final class SessionViewModel: ObservableObject {
    private var appActiveTask: Task<Void, Never>?

    func bindAppLifecycle() {
        appActiveTask = Task {
            for await _ in NotificationCenter.default.notifications(
                named: UIApplication.didBecomeActiveNotification
            ) {
                await reloadSessionIfNeeded()
            }
        }
    }

    deinit {
        appActiveTask?.cancel()
    }
}

Creating your own stream

When you need to bridge delegate callbacks or custom events, AsyncStream is often the simplest tool. You create AsyncStream<Event> { continuation in ... } and call continuation.yield(event) whenever a callback arrives. When the source finishes, call continuation.finish().

enum DownloadEvent {
    case progress(Double)
    case finished(URL)
}

func downloadEvents() -> AsyncStream<DownloadEvent> {
    AsyncStream { continuation in
        let downloader = Downloader()

        downloader.onProgress = { value in
            continuation.yield(.progress(value))
        }

        downloader.onFinish = { fileURL in
            continuation.yield(.finished(fileURL))
            continuation.finish()
        }

        continuation.onTermination = { _ in
            downloader.cancel()
        }

        downloader.start()
    }
}

Why senior iOS engineers should care

AsyncSequence makes event-driven code easier to test, cancel and compose. It also reduces callback nesting and helps teams express time-based behavior clearly. The important habit is to define ownership: who creates the stream, who consumes it, and when cancellation happens.