Skip to content
macOSDeep Dive Published Updated 8 min readViews unavailable

NotificationCenter on macOS: Observer Ownership, Delivery, and Reentrancy

Design reliable NotificationCenter observers with explicit token ownership, queue semantics, validated payloads, and cancellation-safe teardown.

Foundation’s NotificationCenter is an in-process broadcast mechanism. It lets one object announce that something happened without knowing each observer, but it does not provide durable delivery, a transaction log, or a guarantee that observers finish in a particular order. A notification posted while the app is not running is not replayed later. Treat a notification as a prompt to inspect current model state, not as the authoritative record of that state.

This distinction separates NotificationCenter from UNUserNotificationCenter. The former coordinates code inside a running process; the latter manages user-visible notifications through the operating system. A document save, account switch, or background job should have a durable source of truth even if a notification tells interested views to refresh it.

Define a notification contract

Use a stable, namespaced name and document what changed, which object is the sender, and what observers should do. Avoid encoding an entire mutable model into userInfo when an observer can use a stable identifier to query the model. Payload dictionaries are untyped and can be missing keys, contain unexpected values, or outlive the state they describe.

import Foundation

extension Notification.Name {
    static let libraryDidChange = Notification.Name("com.example.reader.libraryDidChange")
}

@MainActor
final class LibraryObserver {
    private let center: NotificationCenter
    private var token: NSObjectProtocol?

    init(center: NotificationCenter = .default) {
        self.center = center
    }

    func start() {
        guard token == nil else { return }
        token = center.addObserver(
            forName: .libraryDidChange,
            object: nil,
            queue: .main
        ) { [weak self] notification in
            guard let identifier = notification.userInfo?["libraryID"] as? UUID else { return }
            Task { @MainActor [weak self] in
                self?.reloadLibrary(identifier)
            }
        }
    }

    func stop() {
        guard let token else { return }
        center.removeObserver(token)
        self.token = nil
    }

    private func reloadLibrary(_ identifier: UUID) {
        // Query the authoritative model and update this observer's owned view state.
        _ = identifier
    }

    deinit {
        if let token { center.removeObserver(token) }
    }
}

The sender and notification name should be as specific as the feature needs. A custom center is useful when the event bus should be isolated from unrelated app-wide events; NotificationCenter.default is appropriate for framework conventions and deliberately shared app events. Do not create a new center for every observer, because posters and observers must use the same instance to communicate.

The sample uses an explicit main queue and a weak capture. That avoids a common ownership cycle: the center retains the observer closure, while the object that stores the token can otherwise be retained by that closure. The extra actor hop makes the UI ownership rule visible. Keep any expensive query or I/O off the main actor, then publish the result back through the model’s normal isolation boundary.

Queue and execution semantics

With the block-based API, queue: nil runs the block synchronously on the thread that posts the notification. That can make a seemingly harmless post call block on slow observers or trigger nested state changes before the poster returns. Supplying an OperationQueue schedules the block there, but does not make a model actor-safe automatically. An operation queue and a Swift actor are different isolation mechanisms.

If multiple observers handle one notification, their blocks can execute concurrently on their selected queues or on posting threads. Do not depend on registration order as a sequencing contract. If observer B must see the committed result of observer A, make the publisher perform that ordered work directly or put the transition behind a single model/service owner. Notifications are a poor substitute for a command bus with ordering semantics.

Avoid posting while holding locks or during a partially applied model mutation. An observer can call back into the publisher synchronously, observe inconsistent state, or start a second transition. Commit the state first, release locks, then post a concise event. If notification delivery itself can initiate another write, make that write idempotent or guard it with a generation identifier.

Filter deliberately and validate payloads

The optional name and object filters reduce unrelated callbacks. Use a stable sender object when an event belongs to a specific subsystem; avoid passing a transient value that will not match by identity. A broad observer with both filters omitted is harder to reason about and makes accidental name collisions more consequential.

Treat userInfo as input, not as a trusted typed structure. Validate required fields and their types at the boundary, reject unsupported schema versions, and handle a stale identifier by re-reading current state. Do not assume one notification corresponds to one user action: publishers may coalesce updates, several writes may occur before an observer runs, and the same event can be posted more than once.

For model refreshes, carry a stable object ID or revision and fetch the current value. For a small immutable fact, a typed value in the payload can be adequate. Never place credentials, full document contents, or private paths in notification data merely because it is process-local. It may be copied into logs or retained longer than expected.

Observer token lifetime

The block API returns an opaque observer token. Store it for exactly as long as the feature needs observation, remove it when the owning window, document, or service stops, and clear the stored reference after removal. Do not treat deinitialization as the only teardown path when a feature has a clear explicit stop event.

An observer may be removed while a block is already executing or queued. Design the handler to tolerate a late callback: check whether its owner is still active, compare a document or account generation, and discard results after the feature has closed. Removing an observer is not a rollback for work already started by its closure.

For one-shot observation, make the completion path remove the token once and make cancellation race-safe. If cancellation and notification delivery can both finish a continuation, protect the terminal state so the continuation resumes exactly once. Long-running asynchronous handlers should launch owned tasks and cancel them when the observing feature ends rather than hiding unbounded work in the callback.

Swift concurrency and newer typed messages

The traditional Notification payload is a reference-oriented API with untyped values. Under strict concurrency, avoid passing non-Sendable objects across actors without a clear ownership rule. Extract a small Sendable value on the delivery context and send that value to the actor that owns the model. Do not silence compiler diagnostics with unchecked sendability just to preserve an observer closure.

Current Foundation documentation also describes typed NotificationCenter.Message APIs and asynchronous message sequences. These can express a stronger message type and isolation contract for app-owned events. Check the deployment target and SDK availability before adopting them. A typed notification still does not make the event durable or authoritative; it improves the type boundary, not the storage model.

The asynchronous sequence form is useful when a task naturally consumes events in a loop. Tie that loop to a task owner, exit it when the owner cancels, and decide how the sequence’s buffering behavior affects bursts. If missing an intermediate event is unacceptable, use a persistent queue or queryable change log rather than assuming a process-local stream is durable.

Notifications versus commands and state streams

A notification should normally describe a fact that has already happened, such as “the library revision changed.” A command asks a specific owner to do something, such as “save this document.” Commands need a recipient, a result, and an error path; broadcasting them as notifications can make it unclear which listener was responsible for completion. Keep write ownership in a service or model and use notification only to let other components refresh.

For rapidly changing values such as playback position, pointer location, or progress, a notification per sample can overwhelm consumers. Prefer a state publisher or a bounded stream that exposes the latest value, and coalesce refresh hints when intermediate states do not matter. If every transition matters for audit or replay, persist events before publishing them. Do not infer durability from the fact that a callback ran on the main queue.

Framework notifications may have their own payload and delivery contract. Read the corresponding framework documentation rather than generalizing the semantics of your custom notifications to NSWorkspace, file presenters, or other system sources. Keep framework-observer tokens just as explicitly owned and removed, but preserve any framework-specific registration center and sender requirements.

Testing and operational checks

Test observer start twice, stop twice, stop during callback execution, owner deallocation, a malformed payload, duplicate posts, a post during a model transition, a slow observer, concurrent observers, and an observer that posts another event. Verify that UI work reaches the intended actor, no callback keeps a closed document alive, and current model state is correct after bursts.

Instrument event name, source subsystem, model revision, observer duration, and outcome category. Avoid high-volume logging of every event in a hot path. A useful diagnostic should tell whether the publisher committed its state and whether the consumer refreshed, without recording the private payload itself.

Use NotificationCenter for transient coordination among components in a running process. Keep ownership explicit, choose a queue intentionally, validate every payload, and re-read durable state when correctness matters. The result is a decoupled design without pretending that notification delivery is a transaction or a background job system.

Related:

Sources:

Comments