In the previous article, I looked at a problem that seems simple on the surface but can fundamentally change the model of a reactive runtime:
const user = createMemo(async () => {
return fetchUser(id());
});
The difficult part is not making createMemo accept a Promise.
The difficult part is that once a computation can span time, the runtime can no longer manage only values.
It also has to manage things like:
pending
stale
superseded
execution identity
result validity
In other words, the runtime starts moving from a Value Model toward an Execution Model.
Once the runtime knows:
A reactive computation is currently waiting.
the next question, from the framework's perspective, becomes:
Where should the UI wait?
At first, this sounds like a rendering implementation detail. But it actually exposes another important architectural boundary:
Where async work starts and where the UI gets blocked do not have to be the same place.
Solid's idea of:
Fetch High, Block Low
is a very concrete example of this.
The Most Intuitive Model: Fetch Where You Wait
Let's start with the most straightforward model.
Suppose we have a page like this:
Dashboard
├─ Header
├─ Profile
└─ Activity
Profile needs to fetch user data:
async function Profile() {
const user = await fetchUser();
return /* ... */;
}
The most natural mental model is:
Profile starts fetching
↓
Profile waits
↓
fetch completes
↓
Profile renders
This lines up perfectly with how we usually think about imperative async code.
Execution reaches await fetchUser(), pauses, and resumes when the data arrives. If we only look at a single function, there is nothing unusual about this.
But once we put that function back into a UI tree, things become less straightforward.
The Component Tree Is Not the Data Dependency Graph
Suppose we have:
Dashboard
├─ Header
├─ Profile
└─ Activity
And both:
Profile
↓
needs user
Activity
↓
also needs user
If ownership of the data fetch is completely tied to Profile:
Profile
→ fetch user
then what should Activity do?
One possibility is:
Profile
→ fetch user
Activity
→ fetch user
Now the same work may happen twice.
Another option is to lift the data fetching higher in the component tree:
Dashboard
→ fetch user
├─ Profile
└─ Activity
At this point, the location of the data fetch starts being determined by the shape of the rendering tree.
But these are not actually the same data structure.
graph TD
A1["User"]
A2["Profile"]
A3["Activity"]
B1["Dashboard"]
B2["Profile"]
B3["Activity"]
subgraph Data dependency
A1-->A2
A1-->A3
end
subgraph Component tree
B1-->B2
B1-->B3
end
The component tree describes how the UI is composed.
The data dependency graph describes which computations depend on which data.
When the two are forced to line up, a problem appears:
Where data should be fetched starts being dictated by the UI hierarchy.
This is where Fetch High becomes important.
Fetch High: Start the Work Earlier
Suppose we already know that both:
Profile
Activity
will need user.
Instead of waiting until one of those components actually renders before starting the request, a better model may be:
We know this route / branch will need user
↓
Start fetching early
↓
Other computation continues
↓
Read the result when user is actually needed
In other words:
Move the start of async work higher.
Ryan refers to this idea as Fetch High.
The point is not that every fetch should live at the root.
It is closer to:
Don't wait until the final consumer actually needs the value before starting async execution.
This helps avoid a common pattern:
render
↓
discover A is needed
↓
fetch A
↓
A completes
↓
render deeper
↓
discover B is needed
↓
fetch B
Which produces a waterfall like:
A ──────────────>
B ──────────────>
If those dependencies are known earlier, we may instead get:
A ──────────────>
B ──────────────>
The goal is to avoid unnecessary async waterfalls.
But this immediately raises another question.
If fetching starts earlier:
Does the UI also have to start blocking earlier?
Fetch High Does Not Mean Block High
Consider the same tree:
Dashboard
├─ Header
├─ Profile
└─ Activity
Now suppose the user request starts at the Dashboard level:
Dashboard
↓
start fetch user
If:
fetch high = block high
then the entire Dashboard may have to wait:
user pending
↓
Dashboard pending
↓
Header cannot render
Profile cannot render
Activity cannot render
That would throw away much of the benefit of starting the work early.
Header does not depend on user at all.
It could already render, but now it is forced to wait for an unrelated async operation.
Block Low: Wait Where the Data Is Actually Needed
Block Low can be understood as:
Async work may start early, but only the UI branches that actually depend on its result should have to wait.
For example, the user request may begin while Dashboard is being processed:
Dashboard starts
↓
user request starts
But Header does not need to wait.
Only the branches that actually read user enter a pending state.
At this point:
The place where work starts and the place where the UI waits have been separated.
That separation is a key change in the model.
Execution Boundaries and Rendering Boundaries Are Different Things
If we take this idea one step further, we get two distinct boundaries.
The first is the Execution Boundary.
It answers:
When does the async work start?
The second is the Rendering Boundary.
It answers:
Which part of the UI has to wait for that work?
These should not be treated as the same thing.
graph TD
A["Reactive dependency becomes known"]
B["Start async execution"]
C["Execution pending"]
D["Result resolves"]
E["Other work continues"]
F["Consumer reads pending value"]
G["Rendering boundary blocks"]
H["Continue rendering"]
A-->B-->C-->F-->G
B-->D-->H
C-->E
The important part of this graph is that Start Async Execution does not immediately mean Block Rendering.
There is still a consumer dependency and a rendering boundary in between.
Suspense Starts to Play a Different Role
If we think of Suspense as simply:
A component that shows a loading UI.
we undersell its role in this model.
More precisely, Suspense describes something closer to:
This region of the rendering tree is allowed to temporarily wait for an async dependency.
For example:
<Header />
<Suspense fallback={<ProfileSkeleton />}>
<Profile />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<Activity />
</Suspense>
If both Profile and Activity read an async value that has not resolved yet:
Header
→ no dependency
→ renders normally
Profile
→ dependency pending
→ blocks at Profile boundary
Activity
→ dependency pending
→ blocks at Activity boundary
The scope of the async dependency is no longer determined by where the fetch started.
Instead, it is determined by:
Which rendering branch actually reads it?
and:
Which Suspense boundary catches it?
Solid's documentation also describes Suspense as a boundary that can isolate async behavior to a particular region. With server streaming, other parts of the page can be emitted first while a pending boundary temporarily renders its fallback, then sends the final content once the data is ready.
Reactive Graphs Make This Model Feel Natural
This connects directly back to the previous article.
Why does this kind of design become more natural once async computed values enter the reactive graph?
Because the runtime already knows who depends on whom.
For example:
userId
↓
user
↓
Profile
and:
user
↓
Activity
If user is a real reactive async node:
user = pending
the runtime does not need to declare:
The entire application is now loading.
It already knows which downstream dependencies are actually affected.
Conceptually:
graph TD
A["userId"]
B["user async node"]
C["Profile"]
D["Activity"]
E["Header"]
A-->B-->C
B-->D
Header is not even part of that dependency chain.
So if user is pending, there is no reason, at least in principle, for Header to become pending with it.
This is one of the strongest properties that emerges when reactive graphs and async rendering integration meet.
But Reactive Dependencies Still Do Not Equal Rendering Decisions
There is an important distinction here.
The reactive graph can tell the runtime:
Profile depends on user
But that alone does not necessarily tell the UI what to do while Profile is pending.
It could:
- show a fallback
- keep the previous UI visible
- delay the entire UI transition
- use some other rendering policy
At this point, we have moved into rendering policy.
The dependency graph is responsible for describing:
Which computations are affected by async work?
The rendering system is responsible for deciding:
Now that something is affected, how should it be presented?
Those are still different responsibilities.
Transition Is Yet Another Layer of Decision-Making
Suppose:
userId = 1
and the UI is currently showing User 1.
The user clicks Next:
userId = 2
A new async computation starts:
fetch User 2
At least two reasonable rendering strategies are possible.
Strategy A: Enter the fallback immediately
User 1
↓
Loading...
↓
User 2
Strategy B: Keep the old UI visible
User 1
↓
User 1 remains visible
(User 2 pending)
↓
User 2
Neither strategy changes the dependency:
userId → user
What changes is the:
Rendering transition policy
Solid's transition APIs coordinate with Suspense and resource reads. A pending transition can be observed, while client-side transitions allow updates to proceed asynchronously.
Once again, this reinforces the same distinction:
The state of async execution and how the UI responds to that state are two different problems.
Server Rendering Makes the Boundary Even More Obvious
With SSR, this separation becomes easier to see.
Imagine the server is rendering:
Dashboard
├─ Header
├─ Profile
└─ Activity
Profile takes 100ms.
Activity takes 800ms.
If the entire render waits for the slowest async task:
Profile ── 100ms
Activity ─────────────── 800ms
800ms
↓
entire HTML page
the user gets nothing until everything is ready.
But if rendering boundaries are local:
Header
→ immediately available
Profile
→ ready after 100ms
Activity
→ ready after 800ms
then the process can become:
shell
→ send
Profile
→ stream when ready
Activity
→ stream later
Solid Router's streaming model follows this pattern: outer HTML can be sent first, while individual Suspense boundaries wait independently for their async data and progressively stream their content back to the client as they complete.
This is not merely an SSR optimization.
It follows the same underlying principle:
One pending async operation should not automatically block unrelated work.
So What Does Fetch High, Block Low Actually Mean?
If we reduce the idea to a performance trick:
fetch earlier + show loading later
we miss most of what makes it interesting.
What it really reveals is that two kinds of ownership can be separated:
Execution Ownership and Rendering Ownership
Execution ownership answers:
When should this async work begin?
Rendering ownership answers:
Which part of the UI should this pending state affect?
If the two are completely coupled:
where fetch starts = where rendering blocks
we can easily end up with:
- unnecessary waterfalls
- over-blocking
- data ownership being dictated by the component tree
Once they are separated:
fetch high
↓
work starts early
block low
↓
only affected UI waits
the async pipeline becomes much more flexible.
Why Is Solid Particularly Well-Suited to This?
There is an important prerequisite here.
Solid can integrate these ideas very tightly because it owns the entire stack:
Reactive Graph
+
Async Execution
+
Component Model
+
Rendering Runtime
+
Suspense
+
Transition
+
SSR / Streaming
So when the reactive graph knows that an async node is pending, the rendering runtime can directly make use of that information.
graph TD
A["Reactive Graph"]
B["Async Execution"]
C["Pending State"]
D["Suspense / Transition"]
E["Rendering"]
F["SSR / Streaming"]
A-->B-->C-->D-->E
D-->F
That kind of vertical integration is powerful.
The runtime does not need to translate pending into some separate external state-management protocol and then ask the UI framework to infer what happened.
The pipeline itself already knows:
Which reactive computation is pending?
and:
Which rendering dependency is affected?
But This Raises Another Question
If the reactive runtime also owns rendering, that integration feels natural.
But what if:
The reactive runtime never wanted to own rendering in the first place?
Suppose we want the same async execution model to be usable by:
- React
- Vue
- Worker
- Server process
- CLI
- Agent
Then Suspense cannot be the async runtime's only answer.
A Worker has no Suspense.
An Agent has no component tree.
The runtime still needs to understand:
execution pending
execution stale
execution cancelled
execution superseded
But how those states are eventually presented has to be left to the consumer.
In other words:
graph TD
A["Reactive Execution"]
B["Async State"]
C["Consumer"]
D["React"]
E["Vue"]
F["Agent"]
A-->B-->C-->D
C-->E
C-->F
Instead of simply:
Reactive Execution
↓
Rendering
And that takes the problem in a different direction.
Once Execution and Rendering Boundaries Are Separated
In the first article, we arrived at this:
Async moves a reactive system from a Value Model toward an Execution Model.
This article lets us take that one step further:
An Execution Model is not the same thing as a Rendering Model.
A reactive runtime may know that work is in progress.
But the UI runtime is the one that decides:
- whether to block
- where to block
- whether to show a fallback
- whether to keep the old content
- whether to wait for a transition
So:
Async execution starts here
and:
Rendering waits here
should not be treated as the same boundary.
That is what Fetch High, Block Low really demonstrates.
It is not simply advice about which component should contain a fetch.
It shows that:
Ownership of async work can be separated from ownership of UI waiting.
And once we accept that separation, the next question becomes much more interesting:
What if rendering is not the endpoint of reactive execution at all?
If the UI is only one kind of consumer, could the same reactive runtime also drive:
- Background tasks
- Server workflows
- Stream processing
- Agent execution
That is the question for the next article:
What If Reactive Execution Doesn't End at Rendering?
Top comments (0)