Lazy-loading and executing less code is standard advice to speed up a bloated application. The advice is sound, but I am here to tell you that hydration will try to sabotage you every step of the way.
Let's start with building the most straightforward application we can think of, a humble counter.
A counter consists of a state, a state mutation, and state binding. Sure, it is a trivial application, but it represents a real-world application because real-world apps also have state, state mutation, and state binding, just in a more complex form.
The place where this analogy breaks down is that real-world applications consist of many components, so let's modify our counter by breaking it up from a single component to three components. Each component contains a single aspect of the application: state, state mutation, and state binding.
Real-world applications are not so clean; they mix behavior and presentation. Let's make our counter more true to life by introducing wrapper components. These components have no behavior function other than to add visual styling.
For all practical purposes, they are static and inert. (We represent static components with dashed borders.) But because they wrap non-static components, they are responsible for passing information to their children (prop-drilling.)
OK, One more thing. Let's add some actual static components that have no prop-drilling. Let's add leaf icons to our counter application to represent those.
You should think about which components are needed for the application's behavior. Notice that the only data flow in the application is from the Action
to the Display
going through Counter
where the state is defined; all other components bring no value to the application's behavior, only to the presentation.
While the counter example here is trivial, let's imagine it is large and slow to start. What can we do to improve the startup performance? The most common advice is lazy-loading and lazy execution of code. So, let's look at our example and consider which components need to be present eagerly, which can be lazy-loaded, and which should never load because they are static.
Let's talk about an ideal world where the server sends pre-rendered HTML to the client, and the client downloads and executes a minimal amount of code:
Code which should never make it to the client:
- All of the components with dashed borders (
AppRoot
,ActionWrapper
,ActionIcon
,DisplayWrapper
, andDisplayIcon
) never need to re-render on the client, own no state or listeners, and are, therefore, static. There should be no need to load them for the duration of our application.
Code which probably makes it to the client but should be lazy:
Counter
component does not need to re-render on the client, but it is responsible for creating the application's state. Ideally, the code should only be executed once the user interacts with the increment button.Display
component needs to re-render on the client, but only if the state of the application changes. It should be lazy-loaded on state mutation.
Code which needs to be eagerly executed on the client:
Action
component never needs to re-render on the client, but it has the event listener that the framework needs. Ideally, we should not need to load theAction
, but we do need to tell the framework about the listener somehow, so this code needs to be executed before the application can be interactive.
OK, so now that the stage is set, let's talk about how hydration turns our static HTML into an interactive counter. For an application to become interactive, the framework needs to know three things:
- The state of the application (current count.)
- The component boundaries and bindings within them (what to repaint on change.)
- The location and the closure of the event listeners (what to listen for and what to do.)
Most frameworks gain this information by executing the application components during startup, and we call this hydration. The framework starts by executing the root component, and the component tells the framework about its state, bindings, and child components.
The framework then recurses into the newly discovered components until all the components in the current render tree of the application are processed.
Execute AppRoot
and learn about Counter
. Execute Counter
, learn about ActionWrapper
and DisplayWrapper
. Execute ActionWrapper
to learn about Action
component. Execute Action
component and learn about click listener, and so on…
Think about how the above hydration process affects lazy loading and lazy execution:
- The
AppRoot
component must eagerly be downloaded and executed because it is the application's entry point. The framework learns aboutCounter
. - The
Counter
component must be executed to learn about the state of the application and to learn aboutActionWrapper
andDisplayWrapper
. - Even though
ActionWrapper
is static, the framework must execute it so that it can learn about theAction
component. - The framework must execute
Action
to learn about the click listener. Executing the component allows the framework to get a hold of the event handler closure, which closes over the state of the counter. - Without some additional data (to be discussed later,) the framework must execute the
ActionIcon
component just in case there is a listener (or other components.) DisplayWrapper
needs to be executed to pass the state fromCounter
toDisplay
.Display
must be executed to ensure there are no listeners (and to learn about the binding of state).DisplayIcon
must also be eagerly executed to ensure no extra components or listeners.
Notice what just happened. Every component in the render tree had to be downloaded and executed to make the application interactive.
This must be done eagerly and completed before the application can be interacted with. Trying to do any lazy-loading of components does not help because hydration will eagerly load them anyway.
Hydration must:
- Start at the root component; the implication is that hydration must process components from root to leaf. It can’t start in the middle of the render tree.
- Execute the component to learn about state, listeners, and child components.
Hydration has no hints about which components are static, and so it must exhaustively visit every component just in case. This really puts a damper on any lazy loading or lazy execution. Let’s count how many functions we must download and execute in order to make the application interactive.
To get a better overview of the cost of hydration, let’s keep a table that shows which components had to be eagerly or lazily executed to make the application interactive. In the case of classical hydration, all eight components (+1 event handler) had to be eagerly downloaded and executed. (In the case of the event handler, the code closure had to be eagerly instantiated and attached to the DOM, even though the handler closure does not run eagerly.)
We know that hydration is slow and expensive because it must visit every component in the render tree. What can we do to lessen the impact?
Many of the reasons why components had to be run was just-in-case. What if we could give the hydration algorithm more information to short-circuit many inefficiencies? So, let’s look at some hydration improvements that people have tried.
Progressive hydration is like regular hydration (in the sense that it visits all components), but it can prioritize different branches in a tree based on user interaction.
When the hydration processes the Counter
component, it learns about ActionWrapper
and DisplayWrapper
. Which branch should be processed first? If the hydration can observe that the user has clicked on the button
that is inside the ActionWrapper
sub-tree, then progressive hydration can prioritize processing ActionWrapper
over the DisplayWrapper
. (It could also pause a branch in a tree and process a higher priority branch first if the user interactions call on it.)
From the point of view of how much code has to be downloaded and executed, progressive hydration is the same as classical hydration. Still, progressive hydration is an improvement from user-observed time to interactivity.
Partial hydration is about recognizing that not all of the components in a tree need to be hydrated because they are static. In our case, AppRoot
can be skipped because it is static and it has no state (or providers) or events.
But partial hydration usually can’t skip static components in the middle. For example, ActionWrapper
and DisplayWrapper
, even though static, must be executed because they allow the framework to get from Counter
to Action
and Display
and to pass relevant data to the child components. Even though they are static, they are load-bearing.
The creation of islands also helps the bundler, as it designates different entry points to the application. This allows the bundler to carve out AppRoot
and not ship it to the client.
Of course, the savings depend significantly on how many components can be marked as static, so it can vary greatly depending on the kind of app you are building.
If the islands are small enough, then the cost of hydration can be absorbed as part of the initial interaction; in that case, the whole cost can be transferred to the lazy column. It is also possible to break the Action
and Display
into separate islands, but now you have a state outside of and spanning islands (further complicating the problem).
ActionIcon
and DisplayIcon
are static branches. If hydration could be told that these branches are static, it could short circuit visiting these components since they have no interactivity.
The complication is that at runtime, there is only one way for the framework to know if a component is static, and that is to execute it. So, the static nature of a component must be collected either at build time or pre-rendering time and provided to the hydration algorithm in a declarative way.
Such information will also affect the bundlers, as we don’t want to ship components that will not be executed. Typical tree shaking will not help here because the parent component directly references the branch component. (Display
knows about DisplayIcon
; therefore, bundler includes it.) So it is not just about not executing it, but also not bundling it in the first place.
Server Components (as popularized by React Server Components) ask the developer to mark which components are static on the client by marking them in code. Static components can’t have state or event handlers.
For this reason, it is possible to serialize them during SSR/SSG. This benefits the framework because the framework has all of the information about the component without actually executing the component.
Through clever use of projection (children
) it is possible to interleave static and dynamic components. So even though DisplayWrapper
is sandwiched between Counter
and Display
, hydration can skip it. The same projection tricks could be applied to DisplayIcon
, but the further the component is from the root, the harder it is to project.
Part of the constraint is that components can only have one children
property, so additional projections require other attributes, which are possible but become unergonomic, and so developers tend only to use it for the largest/most expensive components.
Here, ActionIcon
and DispalyIcon
are marked with a light checkmark and give a cost 0.5, to indicate that they could be avoided but probably not.
Some frameworks can look at ActionIcon
or DisplayIcon
as part of the compilation and recognize that they are leaves that will never re-render on the client and automatically omit it from the build. The branches get pruned. But this only works for static branches.
This trick can’t be used to prune DisplayWrapper
because it contains Display
, which will render on the client.
Of course, these strategies can be combined to further improve the results.
An important concept to understand is that the hydration is sequential. Hydration must start at an entry point component (typically root) and work itself towards leaves. Hydration can prioritize different branches but can’t skip intermediate components to go directly to a leaf.
This means that a child component in the tree will require all parent components to be hydrated before the child can be hydrated. Part of the reason is that the components get props from parent components, so to create a component, the parent component must have executed and generated the props, which is recursive to the root.
An important concept to understand is that the hydration is sequential. Hydration must start at an entry point component (typically root) and work itself towards leaves. Hydration can prioritize different branches but can’t skip intermediate components to go directly to a leaf.
This means that a child component in the tree will require all parent components to be hydrated before the child can be hydrated. Part of the reason is that the components get props from parent components, so to create a component, the parent component must have executed and generated the props, which is recursive to the root.
function DisplayWrapper({count}) {
return (
<div className="my-app-styles">
<Display count={count} />;
</div>
);
}
function Display({count}) {
return <div><DisplayIcon/>{count}</div>;
}
function DisplayIcon() {
return <svg>...</svg>
}
Let’s lazy load a Display
as it is unnecessary until the user interacts with the counter.
- Move
Display
(andDisplayIcon
) into a separate file. - Use dynamic
import()
to get a hold ofDisplay
. - Wrap the dynamically loaded
Display
inlazy()
. - Finally, use the lazy
Display
in aSuspense
.
New lazy-loaded file:
export function Display({count}) {
return <div><DisplayIcon/>{count}</div>;
}
function DisplayIcon() {
return <svg>...</svg>
}
Existing file with new changes:
import { lazy, Suspense } from 'react';
const LazyDisplay = lazy(() => (await import('./lazy')).Display);
function DisplayWrapper({count}) {
return (
<div className="my-app-styles">
<Suspense fallback={<span>loading...</span>}>
<LazyDisplay count={count} />
</Suspense>
</div>
);
}
A few things to note:
- This is not a small change. Moving components to different files and adding the necessary wrappers (
lazy
andSuspense
) is not trivial. A trivial change would be to add an attribute to designate a lazy-loaded boundary to declare intent. - In our example, the
Display
component depends onDisplayIcon
, which also had to be moved to get the full benefit. This is a basic case, but in the real world, just identifying which components need to move to a separate file may be a complex undertaking.
But the biggest problem is that the hydration will force the Display
component to be loaded eagerly, even though we went through a lot of work to introduce a lazy loaded boundary. (The argument can be made that lazy loading components during hydration has a negative impact on startup time.)
Hydration will always cause all components in the current render tree to load eagerly because hydration must visit all components to determine if they may have state, bindings, or event handlers. This is why hydration is sabotaging your efforts to lazy-load and lazy-execute your code.
Another place that we can use lazy loading is on the event handler.
export function Action({ setCount }) {
return (
<button onClick={() => setCount((v) => v + 1)}>
<ActionIcon />
</button>
);
}
In our trivial example, there is little value in lazy-loading it, but let’s review the motions to get insight into what is involved.
Just as before, create a new file that contains the lazy-loaded handler.
// Action_click.tsx
export function ActionClickHandler(setCount) {
setCount((v) => v + 1);
}
Modify existing code to perform lazy-loading.
export function Action({ setCount }) {
return (
<button
onClick={async () => {
const module = await import('./Action_click.tsx'));
module.Action_click(setCount);
}}
>
<ActionIcon />
</button>
);
}
A few things to note:
1. While not as much work as moving multiple components to a new file, moving functions still takes work.
2. The hardest part of this refactoring is that by moving the code, we have changed the code from a closure to a function. A closure closes over the state of the component, such as setCount
, so it can just be used. But a function is top-level and hence can’t close over any component state. So, during refactoring, we must explicitly identify all of the closed-over references and pass them to the lazy loaded function.
3. Even though we have moved our code to the lazy loaded function, we still left behind a trampoline closure. The job of the trampoline is to:
- Tell the framework where to set up a listener.
- Capture the references from within the component.
- Lazy-load our code.
- Pass in the closed-over references to the lazy-loaded code.
Depending on the size of the code we are refactoring, the added complexity of the trampoline may not be worth it. But at least hydration does not force the lazy loading of this function.
However, we have now created a new problem. When the user interacts with the component, they will have to wait for the code to be loaded, and depending on their network, that may be anywhere from instant to broken.
Prefetching is a technique of pre-populating the browser cache with data in anticipation that the user will need it later. Click handler from the previous section is a perfect use case for prefetching.
Most meta-frameworks come with prefetching for the components that the meta-framework lazy-loads (typically route-based components). But if you create your own lazy loaded code, it is up to you to either prefetch those or tell the meta-framework about them to prefetch them for you. The meta-framework can’t infer which files need to be prefetch, in which order, and when without some communication from the developer.
The problem of prefetching falls onto the developer. Whenever a new lazy-loaded entry is created, that entry needs to be registered with a prefetcher so that the user has a good experience. On a large codebase, constantly keeping track of which entry point to prefetch can easily become a full-time task.
Hydration sabotages lazy loading. And when you do get lazy-loading to work, you are on your own to ensure you don’t degrade the user experience by ensuring all lazy-loaded entries are pre-fetched.
But it is not all bad news. There are cases when lazy loading works as advertised when the component is out of reach of hydration.
Lazy loading works great for components not in the render tree and, therefore, does not need to be visited during hydration. Examples are dialog boxes that pop up on interactions or components in different routes.
A framework must have information about the application state, component boundaries, and event listeners to do its work. Typically, frameworks get that information by re-executing the application. But what if there is an alternative?
The key is to recognize that the framework had that information as part of SSR/SSG, but it threw it away. What if, instead of throwing the information away, the framework would serialize it into HTML and then send it to the client? Instead of executing application components, the framework would deserialize the information from HTML.
What kind of impact would that have on start-up?
The impact is that there is no code that the framework needs to run eagerly on start-up! But even better, the amount of code that needs to be run on interaction is also significantly reduced.
In the case of Qwik, the only code we need is the event handler. (Depending on the complexity of Display we may also require its execution, which is why it is a light checkmark and gets a cost of 0.5) This is way less code than any of the hydration options.
Lazy-loading and resumability are not strictly coupled, but in case of Qwik, they come together through code extraction. Because the framework is responsible for lazy-loading, the framework is also responsible for prefetching the lazy-loaded code, ensuring that all user interactions will be instant.
Yes, it is possible, through a lot of engineering effort, to make things fast in any technology. But the real world tells us that we have limited engineering time, and there are features to be built.
So, there is a difference between “it is possible” and “it is just how it works.” Yes, it is possible to make hydration fast through a lot of engineering effort by breaking your application into islands, inserting lazy loading, configuring the prefetcher, and scrutinizing every import.
But these tasks are better left to a computer because they are tedious, and once you finish all the optimization, you begin to slide backward on performance with every new feature added.
Introducing Visual Copilot: convert Figma designs to high quality code in a single click.