DEV Community

Cover image for When Computed Becomes Async
Luciano0322
Luciano0322

Posted on

When Computed Becomes Async

While looking at how Solid 2.0 approaches async reactivity, one API in particular stood out to me because of how easy it is to underestimate the amount of machinery behind it:

const user = createMemo(async () => {
  return fetchUser(id());
});
Enter fullscreen mode Exit fullscreen mode

From the API alone, it looks like a small extension: createMemo, which normally takes a synchronous callback, can now accept an async function.

But the difficult part is not supporting a new return type.

It is this:

Once an async computation enters the reactive graph, the runtime is no longer managing only values. It is managing executions.

That distinction looks small, but it reaches into dependency tracking, scheduling, staleness, race conditions, and eventually policies around Suspense and rendering.

I am not going to discuss UI or Suspense here. I want to focus on one narrower question:

Why does async computed change the model of a reactive system so much?


Synchronous Computed Is Relatively Simple

Start with an ordinary computed. I will keep using Solid-style APIs as the example:

const firstName = createSignal("John");
const lastName = createSignal("Doe");

const fullName = createMemo(() => {
  return `${firstName()} ${lastName()}`;
});
Enter fullscreen mode Exit fullscreen mode

Regardless of the framework or reactive library, the computation can roughly be understood as:

read dependencies

→ compute value

→ cache value

→ dependency changes

→ mark stale

→ recompute when needed
Enter fullscreen mode Exit fullscreen mode

The runtime mainly needs to answer a few questions:

  • What does this computed depend on?
  • Is it stale?
  • What value is currently cached?
  • When should it be recomputed?

The model is largely built around the value lifecycle.

A simplified version looks like this:

dependency changes

→ mark computed stale

→ recompute

→ cache new value
Enter fullscreen mode Exit fullscreen mode

Synchronous computations also have one important property:

A single execution completes within the same call stack.

When the callback starts, the runtime can begin dependency tracking.

When the callback returns, the runtime also has the result of that computation.

There is no gap in the middle where the runtime has to wait for something that may complete later. That assumption makes a lot of reactive behavior relatively easy to reason about.

Async breaks it.


Once Async Enters the Picture, Computed Is No Longer Just a Value

Now change the same example to this:

const user = createMemo(async () => {
  const id = userId();

  const response = await fetch(`/api/users/${id}`);

  return response.json();
});
Enter fullscreen mode Exit fullscreen mode

At the type level, it may look like a change from T to Promise<T>.

From the runtime's point of view, however, the computation now looks more like this:

execution starts

→ read userId

→ start request

→ suspend

→ wait

→ resume

→ produce result
Enter fullscreen mode Exit fullscreen mode

The computation is no longer an operation that begins and finishes immediately. It now spans time.

That introduces a concept that a synchronous computed never really had to represent:

A particular computation execution may still be in progress.

A synchronous computed mostly needs to answer:

What is the current value?

An async computed also needs to answer:

  • Which executions currently exist?
  • Which execution is still valid?
  • Which execution is allowed to publish its result?

That is already more than value management.


Dependencies Do Not Wait for Promises

Suppose we currently have:

userId = 1
Enter fullscreen mode Exit fullscreen mode

The runtime starts the first computation:

execution A

→ fetch /users/1
Enter fullscreen mode Exit fullscreen mode

Before that request finishes, the user switches accounts:

userId = 2
Enter fullscreen mode Exit fullscreen mode

The reactive graph immediately knows that one of the computation's dependencies has changed.

So it may start another execution:

execution B

→ fetch /users/2
Enter fullscreen mode Exit fullscreen mode

Now both executions exist at the same time:

graph TD
    A1["userId = 1"]
    A2["Execution A starts"]
    A3["await request A"]

    B1["userId = 2"]
    B2["Execution B starts"]
    B3["await request B"]

    A1 --> A2 --> A3
    B1 --> B2 --> B3

Suppose B finishes first:

B resolves

→ user 2
Enter fullscreen mode Exit fullscreen mode

Then, some time later, A finishes:

A resolves

→ user 1
Enter fullscreen mode Exit fullscreen mode

Now there is a problem:

Is execution A still allowed to update the computed value?

A normal Promise has no concept of I am outdated.

It only knows:

I finished.

For the reactive graph, those are not the same thing.


A Resolved Result Is Not Necessarily a Valid Result

This is one of the easiest parts of async computed to underestimate.

A Promise can be pending, fulfilled, or rejected.

A reactive runtime needs to know more than that.

A fulfilled execution may already be stale, or it may have been superseded by a newer execution.

So the runtime cannot ask only:

Has this Promise finished?

It also has to ask:

When this execution finishes, is it still allowed to commit its result?

For example:

Execution A starts

Revision = 1

Execution B starts

Revision = 2

B resolves

Revision 2 is still current

→ commit

A resolves

Revision 1 is no longer current

→ discard
Enter fullscreen mode Exit fullscreen mode

Conceptually, it may look something like this:

const revision = currentRevision;

const result = await fetchSomething();

if (revision !== currentRevision) {
  return;
}

commit(result);
Enter fullscreen mode Exit fullscreen mode

A real reactive runtime may implement this in a completely different way, but the underlying problem is the same:

Async reactive computation needs execution identity.

Without some notion of execution identity, it becomes difficult to distinguish between work that merely finished and work whose result is still valid.


await Also Breaks Dependency Tracking

Concurrency is already difficult enough. await adds another problem:

Should dependency tracking continue across an await boundary?

Consider this:

const result = createMemo(async () => {
  const a = signalA();

  await something();

  const b = signalB();

  return a + b;
});
Enter fullscreen mode Exit fullscreen mode

There is an important question here:

Is signalB a dependency of this memo?

Synchronous reactive tracking often works with a model roughly like this:

start computed

→ set current observer

→ signal is read

→ register dependency

→ computation ends

→ clear current observer
Enter fullscreen mode Exit fullscreen mode

Conceptually:

currentComputation = memo

run callback

currentComputation = null
Enter fullscreen mode Exit fullscreen mode

But await interrupts that execution.

run callback

↓

await

↓

call stack ends

↓

other work runs

↓

Promise resolves

↓

callback resumes
Enter fullscreen mode Exit fullscreen mode

When the callback resumes, should currentComputation still refer to the original memo?

If the runtime simply keeps it around, unrelated async executions can easily contaminate one another.

If it clears the context, dependencies read after await are no longer tracked.

So async computed forces the runtime to deal with something like:

async context propagation
Enter fullscreen mode Exit fullscreen mode

or to define a very explicit dependency-tracking boundary.

Either way, this is much more involved than letting a callback return a Promise.


Computed Starts to Have a Lifecycle

A synchronous computed may only need states such as:

  • clean
  • dirty

Or, in even simpler terms:

  • value available
  • value needs recomputation

Once async enters the model, the state space starts to look more like this:

idle

pending

resolved

rejected

stale

superseded
Enter fullscreen mode Exit fullscreen mode

Not every runtime will use these exact names, and it does not necessarily need to implement them as an explicit state machine.

But the distinctions themselves are hard to avoid.

For example, pending means:

The computation has started, but no new stable result is available yet.

stale may mean:

One of the computation's dependencies has changed, so the current result no longer represents the latest graph state.

superseded is different again:

A newer execution has already replaced this one.

What used to look like:

Computed Node

→ Value
Enter fullscreen mode Exit fullscreen mode

starts to look more like:

Computed Node

→ Value

→ Execution State

→ Active Execution

→ Previous Execution
Enter fullscreen mode Exit fullscreen mode

This is where the model starts to expand substantially.


The Value Model Starts Turning Into an Execution Model

Put synchronous and asynchronous computed side by side and the difference becomes much clearer.

Synchronous:

dependency changes

→ compute

→ value
Enter fullscreen mode Exit fullscreen mode

Asynchronous:

dependency changes

→ start execution

→ pending

→ dependency may change again

→ start another execution

→ execution resolves

→ validate execution

→ commit or discard result
Enter fullscreen mode Exit fullscreen mode

A rough flow looks like this:

graph TD
    A1["Dependency changes"]
    A2["Start execution A"]
    A3["Execution A pending"]

    B1{"Dependency changes again?"}
    B2["Execution resolves"]
    B3["Start execution B"]

    C1{"Still current?"}
    C2["Commit result"]
    C3["Discard stale result"]

    A1 --> A2 --> A3 --> B1
    B1 -- "No" --> B2
    B1 -- "Yes" --> B3 --> B2
    B2 --> C1
    C1 -- "No" --> C3
    C1 -- "Yes" --> C2

At this point, the change is easier to see:

The runtime is no longer only deriving values. It is coordinating multiple computation executions over time.

That is the real model change introduced by async computed.


The Hard Part Is Not the Promise

It is tempting to look at:

createMemo(async () => ...)
Enter fullscreen mode Exit fullscreen mode

as an API that simply supports another return type.

If the entire problem were changing:

() => T
Enter fullscreen mode Exit fullscreen mode

into:

() => Promise<T>
Enter fullscreen mode Exit fullscreen mode

there would not be much to discuss.

The real issue is that async breaks several assumptions that were quietly true in a synchronous reactive graph:

  • A computation finishes immediately.
    Not anymore.

  • Dependency tracking happens within one execution context.
    Not necessarily.

  • The most recently completed computation represents the latest result.
    Definitely not.

  • A computed node only needs to store a value.
    That is no longer enough.

Once those assumptions disappear, the runtime has to answer a different set of questions:

  • What is currently executing?
  • Which graph state does this execution belong to?
  • Has it become stale?
  • Has a newer execution replaced it?
  • Is it still allowed to commit when its Promise resolves?
  • How does dependency-tracking context survive across async boundaries?

Solid 2.0 Is Doing More Than “Making Memo Async”

This is why I think the direction Solid 2.0 is taking can be easy to underestimate.

From the user's perspective, the final API may look almost trivial:

createMemo(async () => {
  // ...
});
Enter fullscreen mode Exit fullscreen mode

From the runtime's perspective, however, the real question is:

How should the reactive graph interpret a derived node that has started computing but has not finished yet?

Does it currently have a value?

Can its previous value still be used?

Should downstream computations continue?

What happens if another dependency changes while the execution is pending?

What happens when an older execution eventually resolves?

At this point, the runtime is no longer dealing only with:

state propagation
Enter fullscreen mode Exit fullscreen mode

It is moving toward:

execution coordination
Enter fullscreen mode Exit fullscreen mode

That is why a one-line API can imply a significant amount of systems work underneath.


Reactive Systems Move from Values Toward Executions

The most interesting part of async computed is not simply that it makes data fetching more convenient.

It exposes a deeper shift in the reactive model:

Once a reactive computation can span time, reactivity is no longer just a value-dependency system.

The runtime now needs to understand:

  • when work starts
  • when it finishes
  • whether it is still valid
  • whether it has been superseded
  • whether its result should propagate

In other words, the value model starts turning into an execution model.

And once the runtime knows that an execution is currently pending, another question follows naturally:

Where should the UI wait?

Should async computation begin in the same place where rendering is blocked?

That is the question I want to explore next:

Once async enters the reactive graph, where should the UI wait?

Top comments (0)