FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

Proposal: move Observable back to cold-by-default semantics · Issue #217 · WICG/observable · GitHub

Repository navigation

Proposal: move Observable back to cold-by-default semantics #217

Open

Description

I want to propose moving Observable back to the original per-subscription behavior:

Every call to subscribe() starts a new execution of the Observable's subscription logic.

Sharing/multicasting would still be possible, obviously. It just wouldn't be implicit behavior of Observable itself.

I think the work in #170, #178, #198, and now #216 has actually been really useful here. The ref-counted design was trying to solve a real problem @esprehn brought up: accidentally duplicating expensive work, requests, listeners, etc.

But I think what we've learned since then is that implicit sharing creates another class of problems that are harder to see, because they're based on timing and on what other subscribers happened to do.

@jakearchibald's examples in #216 make that pretty clear.

take(3) is a simple example

Consider:

const firstThree = source.take(3);

firstThree.subscribe(a);

// Later:
firstThree.subscribe(b);

I think most people would read firstThree as something like:

"An Observable that gives me the first three values from source."

But with the shared model, if b subscribes after a already received one value, b can only get the remaining two before take(3) completes.

So now the semantics of take(3) depend on when some other subscriber happened to show up.

That's the same basic issue Jake found with fetch:

previous execution already completed:
subscribe B → new execution

previous execution still running:
subscribe B → joins existing execution

The exact same subscribe() call means something different depending on invisible timing/state elsewhere.

Cold has a footgun too, but it's at least a simple rule

The old behavior has one invariant:

Every subscription starts an execution.

That absolutely has a footgun.

If this Observable creates a fetch:

request.subscribe(a);
request.subscribe(b);

then you might accidentally make two requests when you meant to share one.

But there's an opposite footgun too: maybe the second subscriber wanted fresh data, and instead it gets attached to an old or hung request.

The big difference to me is that the cold behavior is explicit:

subscribe A → execution A
subscribe B → execution B

It doesn't change depending on whether another request happened to finish 10ms earlier.

I think the discussions have basically found the abstraction boundary

There have been a bunch of really good observations across these issues:

I think that's a pretty useful result from trying the ref-counted model.

The model that falls out of all of that is really simple:

Observable = reusable execution plan.
Subscription = stateful execution.
Sharing/replay/lifetime = explicit policy.

This is also how I think about the relationship with async generators:

new Observable(subscriber => {
  // execution plan
})

is much closer to:

async function* values() {
  // execution plan
}

than it is to the AsyncGenerator returned by calling values().

Calling the async generator creates the stateful thing that eventually ends.

Calling subscribe() does the same thing on the push side: it starts an execution. That execution can end without the reusable plan itself becoming permanently dead.

Sharing should still be easy

I don't think we need to design share() in this issue.

But I do think #178/#198/#216 have shown us that sharing comes with real policy decisions:

  • replay previous values?
  • close when subscriber count reaches zero?
  • close/reset after error?
  • close/reset after completion?
  • delay closing?

RxJS ended up with a pretty configurable share() API because these turned out to be genuinely different concerns.

I definitely don't think we should copy that API directly. It's pretty library-ish and not particularly friendly.

If we eventually add sharing to the platform, I'd rather it read more like:

source.share({
  previousValues: 1,
  closeOnZeroSubscribers: false,
  closeOnError: false,
  closeOnComplete: false,
});

Something you can mostly understand by looking at it.

But that's a separate issue.

The important thing here is that those are sharing semantics, not Observable semantics.

Proposal

So my proposal is pretty small:

Calling subscribe() starts a new execution of the Observable. Completion/error ends that execution, not the Observable itself.

I don't think this means the work in #170/#178 was a mistake.

Actually, I think it was useful because we now have concrete examples of what happens when sharing is pushed down into the primitive. #198 and #216 surfaced things that were much harder to see before we had that model to reason about.

To me that feels like incubation doing its job.

We know more now than we did when the ref-counted behavior was added. I think what we've learned points back toward keeping Observable small and predictable, and making shared execution something we ask for explicitly.

Pinned by benlesh

Activity

  1. jakearchibald commented on Aug 11, 2026

    I feel we can do better than shipping something we know to be a footgun.

    Consider:

    const firstThree = source.take(3);
    
    firstThree.subscribe(a);
    
    // Later:
    firstThree.subscribe(b);

    I think most people would read firstThree as something like:

    "An Observable that gives me the first three values from source."

    It's not really that though. If the source is an EventTarget (which will often be the case), then firstThree is "an Observable that gives me the next three values from the EventTarget associated with source from the point that I call subscribe or one of the other methods that triggers execution."

    …and I think that's really confusing. I can imagine developers thinking that execution happens at the time the observable is created, or when take is called. Like, take sounds much more like an 'execution' on the source than subscribe. subscribe sounds passive.

    In #216 (comment) I tried to sketch out a way to make the point of execution less of a mystery.

  2. jakearchibald commented on Aug 11, 2026

    Compare to:

    const nums = [1, 2, 3, 4, 5];
    const vals = nums.values();
    
    const firstThree = vals.take(3);
    
    console.log([...firstThree]); // [1, 2, 3]
    // Later:
    console.log([...firstThree]); // []
  3. benlesh commented on Aug 11, 2026

    CollaboratorAuthor

    I don’t buy that we’re “shipping something we know to be a footgun.” The contract is explicit, well-established, and spelled out in the README: every call to subscribe() creates a new subscription, and nothing executes before subscription. Beyond being spelled out in the README, it's also something that's used at massive scale in the ecosystem for more than a decade.

    If someone assumes take() starts consuming immediately, where do those values go before there’s an observer—buffered, replayed, dropped? That introduces far more hidden policy.

    Honestly, the firstThree iterator example you posted is almost an indictment of the Iterator helpers design, more than anything else. They clearly should have been on the Iterables, which should have been a type.

    The iterator comparison is also the wrong layer. nums.values() is already a stateful, one-shot execution—the analogue of a subscription, not an Observable. The reusable analogue is the generator function that creates it.

    And execute() plus a new Observation type doesn’t solve this. If execute() starts work, synchronous sources can emit or complete before subscribe(). If subscribe() starts work, we’ve changed nothing except adding another object. If the first subscriber starts shared work, we’re back to the same timing-dependent sharing problem.

    So we’d be inventing a brand-new exhaustible async abstraction at the last minute, diverging from the well-known Observable contract and from what this proposal has promised from the beginning. Explicit sharing belongs in an explicit sharing API.

  4. jakearchibald commented on Aug 11, 2026

    I can't resolve

    The old behavior has one invariant:

    Every subscription starts an execution.

    That absolutely has a footgun.

    vs

    I don’t buy that we’re “shipping something we know to be a footgun.”

    Are you saying the footgun issues that @esprehn experienced aren't real?

    If someone assumes take() starts consuming immediately, where do those values go before there’s an observer—buffered,

    Yeah, buffered. The process of taking starts when you call take. Not when you start listening for taken items. But I see your point - that doesn't play well with multiple subscribers, as the second subscriber will miss buffered items (this is also a flaw with the current hot model).

    The iterator comparison is also the wrong layer. nums.values() is already a stateful, one-shot execution—the analogue of a subscription, not an Observable.

    Right. My point was that take is on the equivalent of the 'subscription', not the 'Observable'. I see you personally disagree with that, but that behaviour is already part of JavaScript.

  5. jakearchibald commented on Aug 11, 2026

    The contract is explicit, well-established, and spelled out in the README: every call to subscribe() creates a new subscription

    A bunch of methods implicitly create subscriptions, so I don't think it's even that simple.

  6. benlesh commented on Aug 11, 2026

    CollaboratorAuthor

    No, I’m not saying @esprehn’s experience wasn’t real. My wording there was imprecise.

    Cold Observables have one basic footgun: if you consume one twice, it executes twice. But that follows directly from a simple, explicit contract: every consumption—whether through subscribe() or a terminal operator—starts a fresh execution. That can bite someone who overlooks the rule, but it’s local, deterministic, teachable, testable, and easy for tooling to reason about.

    The alternatives being proposed introduce a very different class of footgun. Does this subscription join an existing execution or start another? Did that execution complete a millisecond ago? What was buffered before anyone subscribed? What does a late subscriber receive? Those answers depend on hidden state and timing elsewhere, not on the code at the call site.

    That seems like a materially worse trade: replacing one predictable failure mode with several nonlocal, timing-dependent ones, plus brand-new lifecycle types with no established community knowledge or precedent. Observables have been widely used for well over a decade. If this alternative model solved a common need, I’d expect to see successful implementations of it already.

    And “Iterator Helpers already work this way” doesn’t make that behavior desirable for Observable. The firstThree example returning nothing the second time is itself a footgun. We cite Iterator Helpers as precedent for the operators, not as proof that a reusable Observable should inherit the lifecycle of a one-shot iterator.

  7. jakearchibald commented on Aug 11, 2026

    The firstThree example returning nothing the second time is itself a footgun.

    I think the difference is, if that behaviour is indeed unexpected, it's something you experience and learn pretty quickly during dev. I wouldn't really class that as a footgun.

    My understanding of the issues @esprehn raised, is they were making their way to prod without anyone noticing. That's absolutely a footgun.

    So yeah… I still feel that we shouldn't be shipping something we know to be a footgun.

  8. benlesh commented on Aug 11, 2026

    CollaboratorAuthor

    I think we’re conflating sharing an underlying producer with sharing an Observable execution.

    It’s completely fine for EventTarget.when() to use one native event listener internally and fan events out to every subscription. That’s an implementation detail. Each subscription should still have independent operator state, cancellation, and completion. Two subscribers to when("click").take(3) can share the same DOM listener while each getting their own three clicks.

    Cold Observables have one basic footgun: consuming one twice may duplicate work. But that follows from one explicit, deterministic rule: every consumption starts an execution.

    Trying to eliminate that one footgun introduces several worse ones:

    • A subscription may join existing work or start new work depending on invisible timing.
    • A late subscriber may miss, buffer, or replay values depending on additional policy.
    • take(), completion, errors, and cancellation may become shared or exhaustible state.
    • Ref-count teardown and restart behavior races with subscribers arriving or leaving.
    • If execute() starts synchronous work before subscribe(), values must be lost or buffered.
    • Reusable derived graphs become one-shot, forcing developers to introduce factories everywhere.

    And that last point recreates the original “footgun” anyway:

    const createObservation = (): Observation<T> =>
      observable.execute();

    Does calling createObservation() start new work every time, or does it return a cached/shared execution? The type cannot tell you. () => Observation<T> reveals no more about its captured producer than () => Promise<T>, a callback-taking function, or an Observable does.

    If it creates a new execution every call, we’ve recreated the exact duplicate-work footgun we were supposedly eliminating. If it returns shared work, we still have all the timing, lifetime, and late-subscriber ambiguity. We’ve just moved the problem behind another abstraction.

    Observable is a primitive, like a function. One call means one function execution. One subscription means one Observable execution. What that execution does internally—including whether it uses a shared listener, cached request, or other shared producer—is an implementation detail.

    That is one simple contract. Replacing it with more types and hidden temporal state seems like a much worse trade.

  9. stipsan commented on Aug 25, 2026

    @jakearchibald as someone who writes observable code with rxjs every day I'll take the "footgun" I know over "hidden temporal states" any day of the week.

    From the "User needs":

    In an attempt to codify the strong userland precedent of the Observable API, this proposal would save dozens of custom implementations from being downloaded every day.

    If the browser primitives deviates on such a fundamental level as not adhering to cold-by-default then my fear is we'll end up with this instead:

    Changes to timing behavior means we have to be extremely careful when migrating, as TS or linters can't detect issues statically 😅

  10. jakearchibald commented on Aug 25, 2026

    const response = await fetch('https://example.com');
    const obj1 = AsyncIterator.from(response.body);
    const obj2 = document.body.when('click');

    Both obj1 and obj2 represent asynchronous data sources, and both have methods named take, drop, map, filter etc etc. This issue is proposing those methods behave very differently between the two objects.

    I think appeals to consistency are kinda low value here because consistency with one means inconsistency with another - and that should be justified.

    @stipsan I get that, as someone who writes observable code with rxjs every day, you'd like consistency with rxjs, but not everyone writes observable code with rxjs every day. Also, in many cases, like above, developers will be encountering these objects without feeling like they asked for an observable.

    I think we need to have pretty strong reasoning for making the platform inconsistent in this way. Although, since async iterator helpers haven't shipped yet, one solution may be to block them in favour of observables.

  11. bakkot commented on Aug 25, 2026

    Contributor

    Although, since async iterator helpers haven't shipped yet, one solution may be to block them in favour of observables.

    Oh man, please no. They've been coming along; It's been taking me a while because of having a kid but I've been able to put a lot of work in over the last year. I think the design ends up being quite natural and useful.

  12. jakearchibald commented on Aug 25, 2026

    @bakkot I'm interested in your take on this. Is there a consistency issue here between async iterable methods and observable methods (assuming cold observables), or am I worrying over nothing?

  13. stipsan commented on Aug 25, 2026

    I get that, as someone who writes observable code with rxjs every day, you'd like consistency with rxjs, but not everyone writes observable code with rxjs every day. Also, in many cases, like above, developers will be encountering these objects without feeling like they asked for an observable.

    @jakearchibald it's more that the current ecosystem around observables has such high interop, which is one of its best features. For example we have a large legacy codebase built on rxjs, that we've been able to re-use in newer code that uses state machines using xstate's observable actors. I could cite example after example, the point is that the vast majority of observable code written today is written this way.
    The cost of using different semantics, as @benlesh already explained in great detail why they're problematic, is that the ecosystem will be fragmented around two very different observable primitives.
    We've already been through a similar fragmentation in the ecosystem when nodejs decided to implement esm with different semantics than bundlers, before eventually coming full circle with require(esm).

    In my opinion a fragmented ecosystem will do more harm than good, and I fear far more harm than having less consistency between native observables and async iterable methods, especially since the semantic differences can't be caught with static analysis.

    For example, the difference in how unsubscribes are done with the current ecosystem being something like:

    let observable = new Observable();
    
    const subscription = observable.subscribe({next() {}})
    // later
    subscription.unsubscribe()

    Versus this proposal

    let observable = new Observable();
    
    let controller = new AbortController();
    observable.subscribe({next() {}, {signal: controller.signal});
    // later
    controller.abort()

    These are changes that are fairly easy to adapt to.
    The timing differences are not, and while I understand the concern for the DX of folks encountering observables "without asking for them" (like .when methods and what not), the proposal here is calling out eliminating massive amounts of JS down the wire as a huge win:

    In an attempt to codify the strong userland precedent of the Observable API, this proposal would save dozens of custom implementations from being downloaded every day.

    If the native observable primitive can't be adopted in these codebases without a high risk of regressions (as this absolutely would be due to the lack of static analysis being able to catch changes in behavior that would regress production) then this proposal will fail to deliver on that goal.
    If there is a massive win that could justify the behavior change, I can't think of a "real" example of what that would be, but lets say it could do something that userland implementations can not (like how requestIdleCallback can't be shimmed in userland since it requires lower level access to scheduling), then I could understand the reasoning.

    As it is now, consistency with with async iterable methods doesn't feel like it's worth fragmenting the ecosystem and forgoing the JS bundlesize reduction we would see from having native primitives that would be low-risk to adopt (native promises for example were a no-brainer to adopt and it was quite easy to get rid of bluebird, even though that proposal also had a lot of debates and spicy discussions around how to cancel pending promises).

  14. bakkot commented on Aug 25, 2026

    Contributor

    Is there a consistency issue here between async iterable methods and observable methods (assuming cold observables), or am I worrying over nothing?

    I don't think it's any different from how .map on an iterator produces a new one-shot iterator whereas .map on an array produces a new array you can consume as many times as you like. Some types are one-shot, others aren't. There is an inconsistency here, and you do need to learn which is which, but that's already true and is really a fact about the world (some underlying data sources are one-shot!) so it's not fundamentally avoidable.

    More generally, my feeling is that async iterators are pretty different from Observables, and these kinds of differences are the reason they're different types - if the mechanics of async iterators were what we wanted, we would just be using those. The whole point of adding a new type is that it has different mechanics.

    I don't think this necessarily argues for or against any particular design for Observables, and I haven't made up my mind about the specific proposal in this issue; just, I would recommend figuring out what the desirable mechanics are for Observable based on how we expect or want it to be used, and then not worrying if that ends up different from async iterators or async iterables.


    All that said, regarding the take example in the OP, I don't share @benlesh's feeling that this example has any one obvious behavior. You really can't know what's going to happen there without knowing how that type behaves. We have discussed all of

    • independent, i.e., any execution always observes all values
    • shared, i.e., any execution observes only the values not already emitted at the time it runs
    • shared-but-lazy, i.e., any execution observes only the values not already emitted at the time it runs, except that no value can be emitted before the first .subscribe() call
    • shared-but-lazy-and-restarting, i.e., as in shared-but-lazy except that if all values have been emitted at the time of a .subscribe() call, then it will see them again

    Both "independent" and "shared" are totally reasonable IMO. "independent" is how you'd represent data at rest (e.g. an array in memory, a file on disk, etc). "shared" is how you'd represent a sequence of events in the world that happen whether or not anyone is watching (e.g. clicks). You cannot know just from looking at the take code snippet which of these it's supposed to be.

    "shared-but-lazy" is a little surprising; I can see the rationale but I'd have to remember that quirk every time. This is how you'd represent a sequence of events in the world where you are implicitly kicking off the sequence by starting to pay attention to it (basically fetchs; I don't see any other case where this is natural).

    If I've understood correctly, the current proposal does "shared-but-lazy-and-restarting". I think that is very surprising; I would not expect anything to work like that.

  15. jakearchibald commented on Aug 26, 2026

    I don't think it's any different from how .map on an iterator produces a new one-shot iterator whereas .map on an array produces a new array you can consume as many times as you like. Some types are one-shot, others aren't.

    That's fair. Although, in the cases where it isn't one-shot, like array.map, there aren't side-effects to multiple-shots. That seems to be where the observable footgun is - developers don't realise it's multi-shot, and the consequences of that end up live.

    Both "independent" and "shared" are totally reasonable IMO.

    Yeah, agreed.

  16. 10 remaining items

  17. acutmore commented on Oct 28, 2026

    Having a built in `.share()' is my preference too. Keeping the vanilla Observable constructor "cold", as this is the more primitive mechanism.

    I'm not sure about .restart() as that doesn't compose. Once a stream has been wrapped to make it shareable you can't go back and peak behind the curtain to make it restart. (Though maybe I've misunderstood the idea).

  18. jakearchibald commented on Oct 28, 2026

    I reckon we can make peak behind the curtain work.

  19. liamcmitchell commented on Oct 28, 2026

    I'll add my 2c as someone using RxJS observables daily.

    My worry is that the cold model leaves us with a big footgun that already catches out folks, even those with lots of observable experience. As a result of that, we'll end up adding an RxJS-like share() method, and end up with the race-condition footgun too. The worst of all worlds.

    Both cold and hot (shared) models have their uses and gotchas. Having both is the point, not the worst of all worlds. For all the pain the gotchas have caused me, I still come back to observables again and again for the simplicity of the abstraction and the power of it's composition.

    I think of observables like functions; lazy, inert and stateless until called.

    Your websocket example above as a function:

    function socketMessages(url) {
      return () => new Promise((resolve, reject) => {
        const ws = new WebSocket(url);
        const messages = [];
        ws.addEventListener('message', (event) => {
          messages.push(JSON.parse(event.data));
        });
        ws.addEventListener('close', () => resolve(messages));
      });
    }

    Note that it is lazy, it returns a function that will actually make the websocket and collect the results.

    And with the same usage example:

    const messages = socketMessages('wss://…');
    
    messages().then((messages) => console.log(messages));
    
    messages().then((messages) =>
      messages
        .filter((message) => message?.type === 'notification')
        .forEach((message) => notifyUser(message)
    );
    
    const firstMessage = await messages().at(0);

    That's three connections, yes. Is that expected? Depends on what you wanted.

    When I see the above code, I see the three messages() calls and know there are 3 function executions.

    Do I know what happens inside that messages function? Maybe. It could cache a single result, it could cache only an in-progress connection, it could cache results for a certain time or it could just blindly repeat the same thing every time.

    Regardless of how the function is written, I can compose my own cached version from it, with exactly the behavior I'm looking for:

    function memo(fn) {
      let cache, cached;
      return () => {
        if (!cached) {
          cache = fn();
          cached = true;
        }
        return cache
      }
    }
    
    const messages = memo(socketMessages('wss://…'));

    To me, the argument that observables should be hot by default is like saying all functions should be memoized by default. All to prevent someone doing work multiple times? But what if I actually need to do some work multiple times...?

    The above quote in the function analogy:

    My worry is that the plain function leaves us with a big footgun that already catches out folks, even those with lots of functional experience. As a result of that, we'll end up adding an Lodash-like memo() method, and end up with the cache-invalidation footgun too. The worst of all worlds.

  20. jakearchibald commented on Oct 28, 2026

    To me, the argument that observables should be hot by default

    That wasn't the argument

  21. jakearchibald commented on Oct 29, 2026

    @acutmore to be clear, I'm against adding RxJS-style share(), as it gives us the really bad footgun.

    The share() and restart() methods I'm proposing would just set an internal enum that is picked up when subscribe happens. The enum would be something like:

    • undecided
    • share
    • restart
    • shareOrRestart (maybe?)
  22. liamcmitchell commented on Oct 29, 2026

    That wasn't the argument

    Sorry to imply that was your argument.

    Did the analogy with functions make sense to you? Is it different to your mental model?

    I was trying to point out that with my mental model, I see observable.subscribe() the same as function.call() and if I see multiple .subscribe() (or .first()) calls, I expect multiple executions. This is not quietly repeating side-effects, they were requested. I don't think calling it a footgun is fair.

    An alternative: make the developer choose, every time

    The choice already exists in the construction/composition of the observable. Subscription is the execution of whatever the developer has decided.

    Continuing with your alternative in the function analogy:

    console.log(await messages.call());
    // messages.call() // <- throws
    
    notifyUser(
      await messages
        .share() // <-- Join the existing execution
        .call()
    );
    
    const firstMessage = (await messages.restart().call()).at(0); // <-- New execution

    Do the above .share() and .restart() return new functions or do they mutate the original?

    What if the function calls nested functions? Are the nested functions also shared or restarted?

    With composition it looks like this:

    const messages = socketMessages('wss://…');
    const sharedMessages = memo(messages)
    
    console.log(await sharedMessages.call());
    
    notifyUser(await sharedMessages.call()); // <-- Join the existing execution
    
    const firstMessage = (await messages.call()).at(0); // <-- New execution
  23. jakearchibald commented on Oct 29, 2026

    @liamcmitchell

    Did the analogy with functions make sense to you?

    No, not at all. But again, I think you were arguing against a point I wasn't making.

    I was trying to point out that with my mental model, I see observable.subscribe() the same as function.call()

    That isn't how other things work on the platform, like iterators. Also, there are many methods that call subscribe under the hood. Can you see how "I see toArray() the same as function.call()" isn't intuitive at all?

    #170 highlights how this isn't intuitive, even in large RxJS codebases.

    I fundamentally don't believe your function analogy holds. If you show the average developer:

    const observer = element.when('pointermove');
    const twoItems = await observer.take(2).toArray();
    const firstItem = await observer.first();

    …and ask them "point to the only equivalents of function calls here", if you think the answer will be "obviously it's toArray() and first()" then you're living in a bubble.

    I'm happy to continue discussing my proposal but the function comparison isn't useful.

  24. benlesh commented on Oct 30, 2026

    CollaboratorAuthor

    I think @jakearchibald's idea is genuinely interesting, but I've only had since yesterday to think about it.

    I have amended my list a few comments above to include his design as number six, including some open questions about the designs and a few foot guns I could find, in some cases depending on what those answers might be.

    I'm not for or against it. I trust it less than tried-and-true cold observables mostly because I haven't had a lot of time to ruminate on it or see it in practice.

  25. jakearchibald commented on Oct 30, 2026

    • After restart() creates a second execution while the first is still running, which execution does a later share() join?

    Share joining the last restart seems like the most simple model, but I haven't thought too much abot it.

    • If const x = observable.share(), can x be consumed twice, or is the permission spent after one consumption?

    It's spent per consumption, else we're back to the problems of other models.

    • How does the policy propagate through normal operator chains?

    I think it's easier to think of it setting an internal value to be used on the next subscribe call. An alternative would be putting a required arg on every subscribing method where you pick restart or share - although the DX doesn't seem as nice there.

    • For takeUntil(source2), does restart() restart both upstream Observables?

    I think it would restart both, same as the cold model right?

    What if I want to restart one and share the other?

    How would this be done with the cold model?

    • When switchMap() selects the same inner Observable again, must the mapper explicitly return inner.restart() or inner.share() every time?

    Yes. Is that a problem?

    • Who owns the lifetime of a shared execution if the subscriber that originally started it unsubscribes but shared consumers remain?

    Can you give me a code example to explain this (using the cold model)?

    • What happens to share() after an execution errors?

    You get an already errored observable. shareOrRestart() would restart. But again, I haven't thought much about this, so if that's bad, show why/how.

    • If shareOrRestart() exists, haven't we explicitly reintroduced the timing-dependent "join or restart depending on current state" behavior we're trying to remove?

    Yes, hence "I'm not sure it's a good idea, but at least it'd be explicit."

    So the question is how much the explicitness overcomes the footgun, since the footgun is mostly due to unexpected behaviour. Like, you're directly asking for something that may share or may restart. But maybe the conditions of that & and race condition still aren't clear.

  26. benlesh commented on Oct 30, 2026

    CollaboratorAuthor

    If y'all don't mind, I've pinned the comment in which I've listed each proposed Observable and what I see as the advantages, foot guns, and open questions. Hopefully that's helpful.

  27. jakearchibald commented on Oct 30, 2026

  28. jakearchibald commented on Oct 30, 2026

    @benlesh

    If y'all don't mind, I've pinned the comment in which I've listed each proposed Observable and what I see as the advantages, foot guns, and open questions. Hopefully that's helpful.

    Sure, but the analysis doesn't seem good to me. Like, my proposal has most of the benefits of the cold model, yet they're not re-stated in the table. A lot of the 'footguns' listed are actually benefits. Some of the concerns are repeated in order to make the cons look like a longer column. The issues around cold are understated to make it look like a shorter column. It also doesn't make it clear that shipping cold means that we, or developers, will have to add a 'share' method, which than lands you with the issues of… whatever share method is used.

  29. benlesh commented on Oct 30, 2026

    CollaboratorAuthor

    @jakearchibald I will try to have a look and revise it as soon as I get a chance.

  30. benlesh commented on Oct 30, 2026

    CollaboratorAuthor

    I think I've figured out what bothers me most about share() / restart().

    It puts producer policy on the wrong side of the abstraction.

    The consumer should basically know:

    I'm getting values of this shape, and I want to transform/filter/take/etc. them.

    It shouldn't need to know how the producer is created, whether it's shared, whether it can be recreated, when it gets torn down, whether reconnecting is expensive, or what "restart" even means for that source.

    Those are source/producer concerns. The implementation site is the place that actually knows the correct policy.

    If a source wraps a WebSocket, DOM events, a cache, a worker, a request, a singleton service, whatever, the source author knows whether that underlying producer should be recreated, shared, cached, replayed, ref-counted, etc.

    Asking downstream code to write:

    source.share().subscribe(...)

    or:

    source.restart().subscribe(...)

    couples the consumer to those implementation details.

    And weirdly, restart() can't actually guarantee what its name implies anyway.

    const producer = createProducer();
    
    const source = new Observable(subscriber => {
      producer.addObserver(subscriber);
      subscriber.addTeardown(() => producer.removeObserver(subscriber));
    });

    What does this mean?

    source.restart().subscribe(...)

    It can create another Observable execution, but it doesn't restart producer. It just attaches to the same producer again, because the source implementation controls that.

    EventTarget.when() is an even clearer example.

    element.when("click").restart().subscribe(...)

    doesn't "restart" the EventTarget or restart the sequence of real-world clicks. It can only create another Observable execution/listener over the same underlying event source.

    So we're asking the consumer to make a producer-lifecycle decision that:

    1. the consumer shouldn't need to know about, and
    2. the abstraction can't actually enforce anyway.

    That also changes how I think about #170.

    The problem there wasn't really:

    Wow, it's crazy that consuming an Observable three times can cause three side effects.

    That's just the cold contract doing exactly what it says: three consumptions, three executions.

    The real problem was closer to:

    We had a source where the underlying work was supposed to be shared, but we implemented that source so every execution recreated the work.

    That's a producer-policy bug.

    If three subscriptions opening three WebSockets or making three network requests is incorrect for that particular source, then that source should encapsulate the correct sharing/caching/lifetime behavior, and ideally that behavior should be tested at the source boundary.

    Changing the semantics of every Observable consumer to protect against a source being implemented with the wrong producer policy feels like solving the problem in the wrong place.

    It's like discovering that a function performs an expensive operation every time it's called, when what you actually wanted was memoization, and concluding that functions should throw on the second call unless the caller writes .repeat().

    No. Memoize the function whose work is supposed to be shared.

    I think the cleaner separation is:

    The source controls producer creation, sharing, caching, teardown, reconnect/restart behavior, etc.

    The consumer controls how it observes and composes the values.

    Cold Observable preserves that boundary nicely.

    Each consumption gets an independent Observable execution, while the source remains completely free to decide whether those executions create independent producers or all attach to some shared underlying resource.

    That seems like a much cleaner abstraction boundary than requiring every consumer to know enough about the producer's implementation to choose share() or restart() correctly.

    (NOTE: Update of my previous list still pending, I didn't forget)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions


      Back | FazBrowse Home | New Git URL