SE-0475: Transactional Observation of Values

Hello Swift community,

The review of SE-0475: Transactional Observation of Values begins now and runs through May 13th, 2025.

Reviews are an important part of the Swift evolution process. All review feedback should be either on this forum thread or, if you would like to keep your feedback private, directly to me as the review manager by forums DM or by email. When contacting the review manager directly, please put "SE-0475" in the subject line.

Trying it out

Toolchains including the implementation of this feature are available for macOS, Windows, and Linux.

What goes into a review?

The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, determine the direction of Swift. When writing your review, here are some questions you might want to answer in your review:

  • What is your evaluation of the proposal?
  • Is the problem being addressed significant enough to warrant a change to Swift?
  • Does this proposal fit well with the feel and direction of Swift?
  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

More information about the Swift evolution process is available at:

swift-evolution/process.md at main · swiftlang/swift-evolution · GitHub

Thank you for your participation!

Freddy Kellison-Linn
Review Manager

14 Likes

Looking pretty good to me, but I am trying to wrap my head around what this means:


let names = Observed { person.firstName + " " + person.lastName }

Task.detached {
  for await name in names {
    print("Task1: \(name)")
  }
}

Task.detached {
  for await name in names {
    print("Task2: \(name)")
  }
}

In this case both tasks will get the same values upon the same events. This can
be achieved without needing an extra buffer since the suspension of each side of
the iteration are continuations resuming all together upon the accessor's
execution on the specified isolation. This facilitates subject-like behavior
such that the values are sent from the isolation for access to the iteration's
continuation.

So, unless I have a fundamental misunderstanding of "physics", something like this either

  1. drops values
  2. applies back-pressure (ie: somehow limit the amount of changing)
  3. buffers

I don't see how it would back-pressure (you can set stuff on the source freely whenever your "isolation" gets scheduled), and the proposal says there is no buffering.

That would conclude that this sentence

In this case both tasks will get the same values upon the same events.

should probably read "the same events mostly, sometimes things do be dropping, depends on scheduling and threading, decisions had to me made".

Or am I not seeing something? There is no guarantee that each observer makes it back to .next() is time, right?


EDIT: ok, it seems I jumped the gun a bit ; ) there is a whole section further down explaining the details. I shall digest the whole thing first, please excuse my trigger-happiness.

3 Likes

I’m -1 on this proposal as it currently stands, as I’m not convinced asynchronous sequences will produce the expected behaviour, particularly in the context of UI updates and animation transactions.

The core problem this proposal attempts to address is important, and I’m very much in favour of solving it. However, packaging changes into a transaction without solving the timing issue only gets us halfway there.

This system would introduce a two-tier observation model:

  1. Synchronous observation on the leading (willSet) edge.

  2. Asynchronous observation on the trailing (didSet) edge.

For synchronising with the underlying animation engine, it’s critical that willSet is called first, so the UI engine can snapshot the before state of the interface. This enables it to animate to the after state at the end of the current event loop cycle.

The problem is that asynchronous sequences guarantee delivery after the current loop cycle. This makes the exact moment that observations arrive indeterminate—regardless of whether they’re batched in a transaction or not.

The result for end users will be broken invariants, unpredictable updates, and out-of-sync or janky animations.

What I’d really like is access to didSet behaviour that is synchronous and immediate, so I can respond to changes and update dependent properties inside the same UI transaction. That’s the model Combine (and other Rx-style frameworks) has supported for good reason.

13 Likes

In the Behavioral Notes section, I read:

This case dropped the last value of the iteration [...]

I don't see in the sample code anything that stops the task that iterates. I find it hard to contemplate any situation where an observer that misses the last value can be acceptable.

I have no problem with dropped values. But dropping the last means that the part of the program that observes remains stuck with an obsolete value. This looks like a failed observation to me, a deception of the user expectations, a serious bug.

Maybe it is possible to use the observation api in a way that guarantees that this problem is avoided. But I would suggest amending the proposal or its implementation so that it can never happen in the first place.


When designing the GRDB api for SQLite observation, I also wrote behavioral notes, in the section ValueObservation Behavior. I took care of mentioning:

ValueObservation may coalesce subsequent changes into a single notification.

This "coalesce" word was chosen so that the reader understands that if some values may be dropped, an observer that does not explicitly stops observing will never remain stuck with an obsolete value.

It was my "duty", as an api implementor, to come up with a mental model of the api user, and to make sure the implementation fits the expectations that come with this mental model.

In summary:

  • Coalescing changes is OK.
  • Letting an observer stuck with an obsolete value is a serious issue IMHO.
17 Likes

This proposal does not aim to address synchronous and immediate didSet behaviors; that is a different set of requirements and a different usage case. Neither of which preclude each other.
I don't think either this nor a didSet asynchronous or synchronous system will ever solve a 100% of all cases - there are certain use patterns for certain cases. This proposal aims to solve a good amount of non-SwiftUI uses. There are other existing solutions like property-observers ala adding willSet and didSet to your properties; this proposal does not remove those and is additive to their existence.

The "dropping behavior" is practically this: values themselves are not per-se dropped but instead it is the willSet that may not be serviced in time, the value is still coalesced to an eventual consistency so it will never be "stuck" with an obsolete value (unless the consumer is somehow blocking execution and never calls next - and in that case that is on them to be stuck or get unstuck).

1 Like

That’s fair – and I completely agree that this proposal targets a different set of requirements. But I do think it’s worth highlighting a practical concern: in the absence of a complementary solution for the synchronous/immediate case, many programmers will reasonably expect Observable to support that use case out of the box, especially in UI or animation-driven contexts.

From the outside, an Observable type implies a reactive model. In that model, users typically expect change notifications to be:

  • Deterministic in delivery timing,
  • Delivered during the same run loop, and
  • Safe to react to immediately, in ways that maintain invariants or animate coherently.

Because this proposal introduces trailing-edge asynchronous delivery, it breaks those expectations in subtle ways – and that can be a foot gun, particularly when used with UI frameworks or state machines that rely on fine-grained state coordination.

Absolutely – but those solutions don’t scale well to multi-consumer cases, which are exactly what an Observable abstraction is designed to support. In fact, many developers reaching for Observable will be doing so because they want something more composable and scalable than didSet or delegates.

To be clear: I think this proposal solves a real problem. But I worry that if it lands without a complementary solution for the immediate/synchronous case, it may create confusion or disappointment for programmers who expect Observable to behave more like Combine or Rx-style systems. Ideally, the language would offer both edge-triggered sync observations and async coalesced transactions – clearly documented and differentiated.

15 Likes

I want to understand how the proposal ensures (1) the mutations on the observed object, and (2) the "emitting" of async sequence elements, happen in the same isolation domain? I think this is crucial to avoid the tearing problem.

The current "emitting" API is accepting an @isolated(any) non-async closure:

public struct Observed<Element: Sendable, Failure: Error>: AsyncSequence, Sendable {
  public init(
    @_inheritActorContext _ emit: @escaping @isolated(any) @Sendable () throws(Failure) -> Element
  )
}

so the user can write such code:

final class Object: Sendable {
    let a = Mutex<Int>(0)
    let b = Mutex<Int>(0)
}

let object = Object()
func observe() {
    _ = Observed { @MainActor in
        object.a.withLock { $0 } + object.b.withLock { $0 }
    }
}
   
nonisolated func mutate() {
    object.a.withLock { $0 += 1 }
    object.b.withLock { $0 += 1 }
}

Here the mutation is nonisolated while the emitting is main-actor isolated. It then is possible for observe to get a midchanged object.

This example does not leverage any unsafe language features. Am I missing something here?

If a and b are intended to be conjoined then their mutations should be as well in a Sendable type. Sendable does not have any real restrictions that prevent this (because the issue is semantical not syntactical) and it violates the expectations of Sendable types. Observed does not change that this is a bug since it does not add any additional synchronization mechanism - all it brings is its own internal synchronization and transactionality.

2 Likes

i think the proposal provides some interesting ideas that are probably useful in certain contexts, but the guarantees the type aims to provide around value delivery still seem uncomfortably vague. additionally, the implementation still has some undesirable behaviors and it's unclear if/how they will be handled.

the following are, in no particular order, some further thoughts on the design and current implementation. some are similar to those raised in the pitch thread and implementation PR, but i think are worth repeating here:

Multi-consumption is prone to race conditions

the proposal states that if there are multiple consuming Tasks, then:

... tasks will get the same values upon the same events.

with the current implementation, this seems like behavior that cannot in general be upheld. suppose we have an Observed closure that produces increasing integer values v_i, and two iterators, I_1 and I_2 that both consume the sequence's values. assume the source closure invocations and iteration all occur on the same actor (let's say @MainActor). the following sequence of events can occur:

  1. I_1 & I_2 both suspend awaiting a 'will set' event
  2. the Observed input value increments to v_1, scheduling resumption of the iterators
  3. a second increment of the input to v_2 is asynchronously scheduled on the source isolation
  4. I_1 resumes and reads v_1
  5. the Observed input value increments to v_2
  6. I_2 resumes and reads v_2

so in this case, the two iterators will see different values, despite the fact that all events take place on the same isolation, and they were initially suspended awaiting the same 'will set' trigger.

Multi-consumer initial value delivery

currently only one iterator will get the initial value of the sequence. this was raised a couple times in the pitch thread, but the proposal doesn't clarify what the expected behavior is.

Dependency changes while processing values breaks iteration

in the current implementation, if an iterator's consumer is processing a value returned by next() and the withObservationTracking change handler fires before the consumer installs and awaits its next 'will set' continuation, then, due to the 'one-shot' nature of the change handler, the sequence effectively breaks and the iterator remains suspended indefinitely. subsequent changes to the Observed object go unseen because the 'chain' of observations is broken in this case. the proposal obliquely touches on this in the 'behavioral notes' section with the 'producer outpaces consumer' example. it suggests that all values should eventually be seen, but this isn't how the implementation works today, and further clarity on how this is intended to be handled would be good.

IMO this is probably the most serious issue with the current implementation, since it's fairly easy to cause (even inadvertently), and can result in an iterator being entirely non-functional.

Use of @isolated(any) may be exploiting a compiler bug

sort of a tangential and implementation specific observation, but the original design was changed to have the Observed closure be marked @isolated(any). this makes sense as it is supposed to capture a fixed isolation upon initialization. however, it is a synchronous closure, and as such should not be callable within the context of a withObservationTracking block, as @isolated(any) functions must generally be awaited. the implementation gets around this today by seemingly relying on a compiler bug[1] that allows an isolation dropping function conversion to take place when calling the function through Result(catching:).


  1. see A few @isolated(any) function conversion questions ↩︎

4 Likes

If a and b are intended to be conjoined then their mutations should be as well in a Sendable type. Sendable does not have any real restrictions that prevent this (because the issue is semantical not syntactical) and it violates the expectations of Sendable types.

I really doubt this.

If the proposed APIs rely on this interpretation of "correctly implemented" Codable types, I assume there'll be countless anti-pattern use cases in production.

I understand that Sendable alone is not enough to guarantee logic correctness, but if at the end of the day, it is still the developer's work to add some locking mechanisms, like this:

final class Object: Sendable {
   struct InternalState { 
       var a: Int 
       var b: Int
   }
   func withLock<R>(_ mutations: (inout InternalState) -> R) -> R { /*...*/ }
}

let object = Object()
func observe() {
    _ = Observed { @MainActor in
        objcect.withLock { state in
            state.a + state.b
        }
    }
}

nonisolated func mutate() {
    object.withLock { 
        $0.a += 1 
        $0.b += 1
    }
}

then, the concept of suspension points does not contribute much towards solving the tearing problem, which negates the following rationale

Tearing is ... Swift has a mechanism for expressing the grouping of changes together: isolation ... Swift concurrency enforces safety around these by making sure that isolation is respected.

Further more, in the above code, there're still chances for tearing in greater granularity:

mutate()
// <- sometimes, the closure passed to `Observed.init` could run in between
mutate()
2 Likes

As a mathematician who hasn't done UI programming in about two decades, I'll offer my two cents on synchronous didSet observation and try to provide a little context for where Philippe is coming from with this proposal:

I recognize that there are a whole bunch of libraries/frameworks/etc that have been built around synchronous didSet observation, but it has broadly been my experience, and much more importantly the experience of UI folks within Apple that we've talked to, that willSet observation (as provided by the existing withObservationTracking in combination with some synchronization method such as a runloop is broadly the best option. That's why it's the first thing that we built.

It turns out that there are some non-UI uses that benefit from some form of transactional observation that is not tied to a runloop. For these cases, an asynchronous sequence of didSet transactions is appropriate, and the drawbacks associated with asynchronous observation (which a number of people have identified on this thread) do not pose much of an issue.

I am not going to claim that anyone doing synchronous didSet observation is doing it wrong. Obviously some people with a lot more UI experience than me choose it, and it is possible to make that paradigm work, as there are a number of libraries in the broader ecosystem that work that way. But it's best left to developers or users of those libraries to propose the observation features that they would benefit from. Because we're not day-to-day users of those frameworks, we'd likely do it wrong if we try to build it anyway. Introducing asynchronous didSet observation doesn't close the door on them; if anything (IMO) it lays out a template for what they might look like that someone else can hopefully run with.

11 Likes

I think my only concern with this proposal is the name Observed.

The behavior of this sequence is well-defined, but it is a little too low-level for such a general name. Observed sounds like a high-level construct that "does what's obviously expected". I know that "obviously" is ill-defined, and very few people are even able to articulate what they "obviously" expect from an observation sequence. But as several posts above have noted, this sequence has caveats and sharp edges that go against the naive expectations.

For those reasons, this proposal is not a conclusion to the observation story. When other observation behaviors are implemented in the future, in the stdlib or in third-party libraries, it would be more fair if they would not have a name that sounds like a "second-class citizen" when compared to Observed.

In conclusion, this proposal implements one of several observation behaviors. It has no right to be named Observed, as if its particular behavior was the canonical one.

10 Likes

Overall, I think the problem this proposal aims to solve is worth solving. It has been a common request to get subject like behaviour for a root asynchronous sequence.

I like the idea of using isolation as the synchronization mechanism that powers the transactional changes. Though I'm wondering how this works if the observed objected is task isolated and not actor isolated.

The closure is not run immediately it is run asynchronously upon the first call to the iterator's next method. This establishes the first tracking state for Observation by invoking the closure inside a withObservationTracking on the implicitly specified isolation.

What happens in this part in particular when isolation = nil? There is no actor to enqueue on so where does the observed closure run? In particular what happens in such cases when there are multiple consumers that are racing the first call to next?

3 Likes

Maybe it's not the name "Observed", but maybe we should use Observed.___ { } or Observed(___) { } instead of plain Observed. In a different thread it was discussed if Task { } actually has the right behavior for being the "default" way to create a task. Independent of if this is true or not, it may serve as an example to maybe be a bit more cautious when defining the "prime" API?

All Task.___ factory methods return an instance of Task, unless I'm mistaken.

If Observed.___ would return an instance of Observed for each different kind of observation behavior, then there would be difficulties:

  • I'm not sure this would scale nicely, implementation-wise, for the implementors of Observed.

  • It would be difficult, for users, to work with an instance of Observed, without knowing how it was created: how does one write reliable code when the behavior of the input sequence is indeterminate?

On the other hand, if Observed.___ would return distinct types for each different kind of observation behavior, in order to avoid the above problems, then we'd need to name those types anyway.

Independent of if this is true or not, it may serve as an example to maybe be a bit more cautious when defining the "prime" API?

The "prime" api is a dangerous phase indeed. I indeed suggest to be cautious and prevent the first implementation of a given concept to "squat" the most general name, when the concept can be realized with multiple sensible implementations.

To me, the most striking aspects of the proposed sequence is that it (1) emits values, (2) observes the emitted value, (3) notifies immediately at the end of transaction, without buffering. I could add (4) supports multiple consumers. [EDIT] (5) It does not emit the initial value.

None of these aspects are essential to an observation sequence in general.

(1) We could imagine a sequence of end of transactions that only notifies that some values were modified, without emitting those values. The SQLite library GRDB has such a sequence, which allows observers to process changes before any other change could be performed.

(2) We could imagine a sequence that observes one value, but emits a different one (acquired from the same transaction). Evidence for such a need.

(3) Notifications could be asynchronous, and some buffering could avoid undesired dropped values.

(5) The lack of initial value has been discussed in the pitch thread, IIRC. Again, that could have been different.

In this regard, instead of Observed, we could prefer ImmediateObservedValues, CommittedValues, etc.

1 Like
  • What is your evaluation of the proposal?

In general, I'm supportive of the problem this proposal seeks to address—allowing Observable to transcend its current attachment to UI frameworks. I appreciate these design decisions:

  • The proposed declarative API shape of "access what you want to observe in a closure and just get updates" is meaningfully more general than e.g. something KeyPath-based.
  • Using suspension points to promise that observed updates yield only internally consistent views of data feels clever.

I also have some reservations:

  • As other reviewers have noted, ambiguity in whether a consumer receives an immediate value or not is undesirable. Other comparable observation interfaces either make this guarantee (e.g. Combine subjects) or leave it up to the client (KVO's .initial option).
  • I worry that the declarative shape of this API may lead to confusion in scenarios that unwittingly combine non-Observable state together with Observable state; see "comparison to other libraries" below.
  • I share @gwendal.roue's concern about consumers being left with stale values, in particular this line from the proposal:

This case dropped the last value of the iteration because the accumulated differential exceeded the production

(If it's not the case that observers can be left with stale values, that should be made explicit, as this sentence suggests otherwise.)

I don't share the concern of other reviewers that synchronous observation must come in a single proposal together with this transactional style of observation.


  • Is the problem being addressed significant enough to warrant a change to Swift?

Yes: currently, it's hard to advise using Observable unless it's explicitly @MainActor @Observable, as withObservationTracking isn't an ergonomic tool outside the context of run-loop-driven UI frameworks. This has produced a divide in the ways in which model code is written:

  • Models intended to drive UI get the ease of use of Observable.
  • General-purpose models not explicitly tied to UI must wire up their own observation through AsyncStream or similar.

This divide has undesirable side effects:

  • Code which doesn't need to be @MainActor-bound (i.e. architecturally might benefit from greater distance from the UI) might be made @MainActor anyway because it's less work to adopt Observable.
  • Code which needs to be refactored from originally being @MainActor-bound requires heavier refactoring to support observation.
  • Fewer technical skills transfer between writing models for UI and non-UI code.

Augmenting Observable to help bridge this gap is wonderful.


  • Does this proposal fit well with the feel and direction of Swift?

I haven't quite internalized the isolation semantics described by the proposal. In particular, an example that clarifies expected behavior when an instance of Observed is formed from within a nonisolated context would be valuable.

  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

The proposal draws comparison to SwiftUI's existing usage of Observation. Unlike SwiftUI, where effectively every reachable value is implicitly observable, I suspect it may be easy to mistakenly include non-observable state in an Observed closure, e.g.

var useNicknames = true // Some variable outside an Observable class
let names = Observed {
    if useNicknames { person.nickname } else { person.fullName }
}

This sequence won't emit a new value when useNicknames changes, which might be surprising to some. The behavior makes sense given the design, but I wonder if this type will become a sufficiently fundamental building block to warrant deeper compiler integration for diagnostics here.

  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

A thorough read + engagement in the Pitch thread's discussion of type signatures for internally-driven sequence termination.

2 Likes

There have been a number of really good points raised here that I need to take a bit to draft some responses to; it may take me a day or so more to really digest everything.

One drive-by comment:

That particular case might not be as prevalent as you might think since it would end up violating the rules of the closure being @Sendable since it would be capturing mutable state. But if you are looking at it in the general sense of closures participating in observation that branch to non-observable accesses (e.g. nothing will ever trigger) that is something that can and probably should be addressed as a runtime issue. Generally speaking, I think there are forward directions that can be taken to avoid potential misuse.

1 Like

Good callout; imagine it's a boolean property on a @MainActor class instead—something like a UIViewController subclass. Sendable, but not observable.

1 Like

Broadly speaking, I don’t disagree with this approach – and I can absolutely see why willSet-based observation combined with SwiftUI’s view invalidation model works well in many scenarios.

However, I think this misses some nuance around why developers sometimes reach for synchronous didSet observation – and the kinds of control it can enable beyond what withObservationTracking provides.

To give a concrete example: imagine an Observable object representing a media player, with a frequently updating elapsedTime: Duration property. This object might drive a progress bar and a “remaining time” label in the UI.

If you compute “seconds remaining” directly from elapsedTime via a computed property, SwiftUI’s observation system will mark the view as needing redraw every single frame – because from SwiftUI’s perspective, it cannot know whether the computed property’s output has changed. Even if the computed value rounds to the same number of seconds across several frames, SwiftUI still invalidates the view each time.

In this case, having access to a synchronous didSet observation would allow wrapping that rapidly updating source property and promoting changes only when the rounded “seconds remaining” value actually changes. You could then selectively update a derived Observable property (e.g., secondsRemaining) to more precisely control when invalidation happens.

In that sense, synchronous didSet observation is not just about reacting to changes after the fact – it’s also a mechanism that enables layering and throttling of updates, giving developers finer control over invalidation timing and frequency.

Somewhat counterintuitively, it can also become a tool to drive willSet observation more precisely – by ensuring that changes are only surfaced when meaningful, reducing unnecessary redraws and improving performance.

This is why I feel it’s important not just to frame synchronous didSet observation as a niche alternative, but to recognise that it fills an important role within the existing model – particularly around derived state, layered observation, and efficient UI invalidation.

The current proposal absolutely has value for transactional, coalesced change delivery – especially outside of UI contexts. But because it introduces a general-purpose Observable mechanism, I worry that without some clearer path (or at least acknowledgment) for these synchronous use cases, developers may either misuse the system or end up back in the world of ad-hoc delegate chains and manual throttling to achieve what they expect from a reactive observation model.

I appreciate that this proposal isn’t aiming to solve that problem directly, and I don’t think it has to. But I do think it would strengthen the design if the potential need for synchronous, layered observation were explicitly recognized as an adjacent concern – even if the solution comes later or from elsewhere.

6 Likes