How do you consume NotificationCenter, delegates and other repeated callbacks as async sequences, and what are the pitfalls?

10.12.3 · L3 · Interview

Predict first

Two notifications are posted after the loop starts. What does it print?

import Foundation

let name = Notification.Name("ping")
let t = Task {
    var n = 0
    for await note in NotificationCenter.default.notifications(named: name) {
        n += 1
        print("received", note.userInfo?["id"] ?? "?")
        if n == 2 { break }
    }
    return n
}
try await Task.sleep(nanoseconds: 100_000_000)
NotificationCenter.default.post(name: name, object: nil, userInfo: ["id": 1])
NotificationCenter.default.post(name: name, object: nil, userInfo: ["id": 2])
print("count:", await t.value)
  • received 1 / received 2 / count: 2
  • count: 0
  • received 2 / received 1 / count: 2

Mental model. Instead of leaving a doorbell tied to your door forever, you stand at the door for as long as you care, and walk away when you stop listening.

NotificationCenter.default.notifications(named:) returns an AsyncSequence of notifications: a for await loop replaces the observer token and the cleanup, and the observer is removed when the loop ends or the task is cancelled. In the check the loop receives the two posts and breaks after the second; the awaited count is 2. The same shape works for other repeated sources by wrapping them in an AsyncStream: a delegate method yields into a stored continuation, and onTermination detaches the delegate (Q300), so a screen can write for await location in locations instead of juggling callbacks.

Pitfalls. Subscribe before you post: a notification sent before the sequence started iterating is lost (the check waits 100 ms before posting), so make sure the consumer is running first or buffer through your own stream. A notification’s payload (Notification and its userInfo) is not Sendable, so under strict concurrency extract the values you need in the loop rather than passing the notification across isolation. Tie the task’s lifetime to the screen: start it in .task { } (cancelled automatically) or store the Task and cancel it on teardown, otherwise the loop keeps the observer alive. For UI-bound work run the loop on the main actor so the handler touches state safely.

The async sequence delivers the posts in order, and the loop breaks after two.

Common traps

  • Starting the observing task after the event has fired. Past notifications are not replayed.
  • Letting the observing task outlive its screen. It leaks the observer and may touch freed state.

They might follow up with

  • How is this different from Combine’s publisher(for:)? Pull-based with natural back-pressure and cancellation; no subscription objects to store (Q312).
  • What if I need several observers to see every event? Each notifications(named:) call is its own subscription, so every loop sees every post.

Related questions

  • 10.12.1 What does an AsyncStream do when the producer yields faster than the consumer reads, and how do the buffering…
  • 10.12.2 What happens when a continuation is resumed twice or never, and how do you wrap a callback API correctly?
  • 10.6.2 How do you bridge repeated events, GCD and Combine into async code with AsyncStream?
  • 11.6.1 When do you use Combine, AsyncSequence or the Observation framework, and when should you not use Combine?

← What happens when a continuation is resumed twice or never, and how…