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());
});
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()}`;
});
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
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
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();
});
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
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
The runtime starts the first computation:
execution A
→ fetch /users/1
Before that request finishes, the user switches accounts:
userId = 2
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
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
Then, some time later, A finishes:
A resolves
→ user 1
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
Conceptually, it may look something like this:
const revision = currentRevision;
const result = await fetchSomething();
if (revision !== currentRevision) {
return;
}
commit(result);
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
awaitboundary?
Consider this:
const result = createMemo(async () => {
const a = signalA();
await something();
const b = signalB();
return a + b;
});
There is an important question here:
Is
signalBa 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
Conceptually:
currentComputation = memo
run callback
currentComputation = null
But await interrupts that execution.
run callback
↓
await
↓
call stack ends
↓
other work runs
↓
Promise resolves
↓
callback resumes
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
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
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
starts to look more like:
Computed Node
→ Value
→ Execution State
→ Active Execution
→ Previous Execution
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
Asynchronous:
dependency changes
→ start execution
→ pending
→ dependency may change again
→ start another execution
→ execution resolves
→ validate execution
→ commit or discard result
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 () => ...)
as an API that simply supports another return type.
If the entire problem were changing:
() => T
into:
() => Promise<T>
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 () => {
// ...
});
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
It is moving toward:
execution coordination
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)