Bridging Observable State and Actors in Swift 6: @ActorSynced for Safer Concurrency

Introduction

Swift 6 introduces strict concurrency checking to help developers identify and fix data races at compile time. Overlapping access to shared mutable state can lead to crashes, misbehavior, or corrupted user data. Actors provide compile-time and runtime isolation guarantees, ensuring safe access to actor-isolated state.

However, there is currently no mechanism to automatically propagate changes from @Observable state to an actor that depends on it. Developers must explicitly push updates manually, which introduces boilerplate and potential risks for stale state or incorrect concurrent access.

This pre-proposal introduces @ActorSynced, an attribute to automatically synchronize observable state into actor-isolated properties, eliminating boilerplate and strengthening concurrency safety in Swift 6 applications.


Motivation

Consider a common scenario in apps: managing an authentication token across UI and background services.

@Observable
struct AuthState {
    var jwtToken: String? = nil
}

A @MainActor-bound session manager updates this token:

@MainActor
@Observable
final class AuthSessionManager {
    var authState: AuthState

    init(authState: AuthState) {
        self.authState = authState
    }

    func login(token: String) {
        authState.jwtToken = token
        Task { await NetworkService.shared.updateToken(token) } // explicit bridging
    }

    func logout() {
        authState.jwtToken = nil
        Task { await NetworkService.shared.updateToken(nil) }
    }
}

Meanwhile, NetworkService executes on background threads:

actor NetworkService {
    static let shared = NetworkService()
    private var jwtToken: String?

    func updateToken(_ newToken: String?) {
        jwtToken = newToken
    }

    func authorizedRequest(to url: URL) -> URLRequest {
        var request = URLRequest(url: URL(string: "https://api.example.com")!)
        if let token = jwtToken {
            request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        }
        return request
    }
}

Currently, developers must explicitly propagate changes into the actor, which is error-prone. Missing an update can lead to stale state, incorrect requests, or subtle concurrency bugs.

Swift 6’s strict concurrency objectives aim to prevent overlapping access to mutable state. Introducing automatic synchronization between observables and actors would align perfectly with these objectives by ensuring safe, consistent updates across main and background actors.


Runtime Considerations

Synchronizing an observable property into an actor may involve runtime mechanisms such as task scheduling, priority handling, and isolation enforcement. For example:

  • Updates from the main actor to a background actor must respect task priorities to avoid starvation or inconsistent state.

  • The actor model ensures that updates are serialized, preventing data races even in highly concurrent contexts.

These runtime guarantees, combined with Swift 6’s compile-time concurrency checks, strengthen the reliability and correctness of concurrent apps.


Proposed Solution

Introduce an attribute @ActorSynced to automatically propagate changes from an @Observable property into an actor-isolated property.

Option 1: Actor-side declaration

actor NetworkService {
    @ActorSynced(\AuthState.jwtToken)
    private var jwtToken: String?
}

Option 2: Observable-side declaration

@Observable
struct AuthState {
    @SyncAcross(NetworkService.shared)
    var jwtToken: String? = nil
}

The compiler/runtime would automatically:

  1. Serialize updates to the actor property.

  2. Ensure actor isolation is preserved.

  3. Remove the need for manual Task { await … } calls or .onChange observers.


Benefits

  • Eliminates boilerplate bridging code.

  • Reduces risk of stale or unsynchronized state.

  • Improved run time scheduling of tasks in an actor (say priority inversion)

  • Preserves SwiftUI’s declarative semantics.

  • Ensures strictly safe concurrent access across actors executing on main and background threads.

  • Aligns with Swift 6’s actor/message-passing concurrency philosophy, conceptually closer to Erlang/Scala than C++ shared-memory threads.


Alternatives Considered

  • .onChange observers: works but verbose and error-prone.

  • didSet / manual Task pushes: repetitive and easy to forget.

  • Passing snapshots into actors: breaks live updates.


Discussion Points

  1. Should @ActorSynced be declared on the actor or on the observable?

  2. Behaviour for suspended actors and actors that are no longer available.

  3. Could this leverage Swift macros or attributes to generate bridging code safely?


Just nitpick, but I don’t think you can apply @Observable on a struct

Spinning up a Task for just setting a property is kinda not ideal for performance.

    func login(token: String) {
        authState.jwtToken = token
        Task { await NetworkService.shared.updateToken(token) } // explicit bridging
    }

could be re-written as:

    func login(token: String) async {
        authState.jwtToken = token
        await NetworkService.shared.updateToken(token)
    }

I would be interested in what you are inferring the expansion of @ActorSynced or @SyncAcross would emit. Like would that add new async methods to change the values?

What happens per composition of properties? For example if I change 2 properties that are logically connected: e.g. a userName and password for the service, would it then end up changing twice? or once upon suspension? (ideally one would want the latter since that would mean that it is not a torn state)

1 Like

Thanks for your questions! Here’s a more detailed explanation with examples and runtime.

What happens per composition of properties? For example if I change 2 properties that are logically connected: e.g. a userName and password for the service, would it then end up changing twice? or once upon suspension? (ideally one would want the latter since that would mean that it is not a torn state)

Runtime gathers updates within the current turn (essentially, one run loop). Consecutive updates to related properties (say, username and password) are coalesced. Delivery to the actor applies the whole batch inside a single critical section, maintaining atomic semantics from the actor’s perspective — no torn state.

This is built on top of the existing @Observable batching mechanism, with the addition of actor-aware propagation.

I would be interested in what you are inferring the expansion of @ActorSynced or @SyncAcross would emit. Like would that add new async methods to change the values?

Sure.

Let me explain this in more detail.

@SyncAcross
class AuthState {
    var jwtToken: String? = nil
    var username: String? = nil
    var password: String? = nil
}

  • AuthState is observable for views and actors.

  • Any actor reading properties receives batched updates automatically.

  • No separate @Observable declaration is needed. @SyncAcross implicitly includes @Observable, acting as a superset that adds automatic actor propagation, batching, and priority-aware delivery on top of standard observable behavior.

  • @SyncAcross at the class level resembles the @Observable macro introduced in iOS 17 / macOS 14. Like @Observable, it marks the class as reactive, but it extends the behavior by automatically propagating updates to actors, handling batching, and respecting actor isolation.

  • Optional manual actor list :-

@SyncAcross(NetworkService.shared, BackgroundWorker.shared)
class AuthState { … }

  • Useful when you want only specific actors to receive updates, providing fine-grained control over propagation and potential performance optimizations.

  • Only specify when you want limited, targeted notifications; otherwise, all actors observing the class are notified automatically.

  • Avoid spinning up a Task for every property update :-

func login(token: String) async {
    authState.jwtToken = token
    await NetworkService.shared.updateToken(token)
}

  • Yes, you pointed it right. Spawning tasks per property is inefficient.

  • @SyncAcross removes this need entirely — updates are delivered to actors maintaining atomic semantics from the actor’s perspective.

  • Multiple logically connected properties :-

  • If username and password are updated in the same run-loop tick:

  • Coalescer ensures one notification task to the actor.

  • Actor sees both changes atomically, preventing torn state.

  • Optional transactional grouping can force immediate delivery if needed.


Generated Expansion & Runtime Behavior of @SyncAcross


Generated Expansion (Conceptual)

  • The @SyncAcross macro generates property observers and generates notifications for observing actors:
class AuthState_ActorSynced {
    var jwtToken: String? {
        didSet {
            ActorSyncCoalescer.shared.schedule(self, changedProperties: ["jwtToken"])
        }
    }
}
  • didSet executes synchronously on property write; we do not spawn a task per write.
  • ActorSyncCoalescer enqueues changed properties in O(1) and coalesces multiple updates.
  • Several property changes that occur in the same run-loop tick are combined.
  • Observing actors receive a single async task with all batched changes, maintaining atomic semantics from the actor’s perspective.

Runtime Behavior

A. Coalescing & Batching (No Torn State)

  • ActorSyncCoalescer gathers updates per target actor within the current scheduler turn (essentially one run-loop tick).
  • First enqueue schedules a single delivery task (if not already scheduled).
  • Multiple logically connected properties are batched together.
  • Delivery applies the batch inside the actor in a single critical section, ensuring all changes are applied together without intermediate inconsistencies.

B. Delivery & Scheduling

  • Each batch is delivered using a single async task per scheduled turn.
  • Priority can inherit from the writer or be configured:
@SyncAcross(target: NetworkService.shared, priority: .userInitiated)
  • Ensures timely updates while respecting background workloads.

C. Actor Reentrancy (Not Strictly FIFO)

  • If the actor has an older work item that’s suspended, the batch notification can run ahead, maintaining mutual exclusion but improving responsiveness.
  • This ensures UI-sensitive consumers are not starved by long-running tasks on the same actor.

D. Failure & Lifecycle

  • Target actor gone / delivery failure: If an observing actor is deallocated or unreachable, updates are dropped or optionally logged (configurable policy). Batches may be retried on the next property change.
  • Delivery error: If an observing actor receives the batch but fails while processing it (e.g., its property setters or methods throw), a configurable error hook — defined globally or per observable — is invoked. Delivery itself does not crash, and other actors continue receiving updates.
  • Lifecycle: Automatic cleanup on deallocation; actors can unsubscribe explicitly if needed.

E. Explicit Transactional Grouping (Optional)

For cases where deterministic, immediate delivery is needed:

await withActorSync(for: NetworkService.shared) {
    auth.username = "neo"
    auth.password = "trinity"
}

Semantics:

  • Begins a transactional scope in the coalescer.
  • Buffers writes within the scope and delivers once, then awaits completion.
  • Useful for command handlers (e.g., login(token:) async) to avoid extra latency without per-write tasks.

Summary

@SyncAcross automatically propagates updates from an observable class to all interested actors, batching multiple changes per run-loop tick, maintaining atomic semantics, and respecting actor isolation.


I tried to keep the explanation clear and relatable. If anything is unclear, feel free to point it out — happy to clarify. Sometimes we get caught up in details as the discussion grows!


Thanks

Just nitpick, but I don’t think you can apply @Observable on a struct

You're correct — currently, @Observable is only supported on classes. @Observable / @SyncAcross would need to be applied to a class. Thanks for pointing that out.