Skip to content

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

Description

@benlesh

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.

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