Exploring Web Rendering: Progressive Hydration

Who said procrastination couldn’t be useful? Do it later.
Photo of Big Ben next to a sign that says UNDERGROUND

Photo by anthony kelly on Flickr

5-part Series: Exploring Web Rendering

  1. Isomorphic JavaScript & Hydration
  2. Partial Hydration (a.k.a. “Islands”)
  3. Progressive Hydration ⟵ you’re reading this
  4. Streaming HTML
  5. Server Components Architecture

As we reach the third article in this series focussed on exploring web rendering and performance, welcome back! Opportunities for performance improvements are really becoming apparent, so I hope you enjoy what’s coming up. If you have any questions, feel free to reach me on Twitter and I’ll do my best to help. As mentioned in the previous article about partial hydration a.k.a. “islands”, 2 main options exist to more quickly make a page interactive: (1) do less work by only hydrating interactive components and ignoring the rest, or (2) hydrate in the future; the former was covered in the previous article while the latter is the subject of this article and is known as progressive hydration.

With traditional hydration during page load, an undesirable race condition known as the “uncanny valley” can occur when components are visible but not yet hydrated and thus non-interactive; progressive enhancement – using native HTML primitives such as forms as a fallback mechanism until JavaScript loads – is a typical remedy because eliminating this loss of interactivity before hydration is very difficult otherwise. However, progressive hydration delays hydration on purpose, so care must be taken so the user doesn’t wait when attempting to interact with the page. This is truly a delicate balance between minimizing performance impact and ensuring a good user experience. If done correctly, hydration will not be noticed by the user as it happens, but he or she will benefit from it.

A key reason why the progressive hydration portion of this article series comes after partial hydration (a.k.a. “islands”) is because they are often used together. Remember the goal of islands is to improve performance by limiting the number of components whose code is sent to the browser; interactive components can be thought of as islands of interactivity and only that code need be downloaded. Without the ability to break an application into individual hydration targets, the only way to progressively hydrate is to do so for an entire application which would effectively be opting into uncanny valley behavior and potentially, purposefully breaking interactivity; this is certainly a good way to frustrate your users! Instead, if some islands are hydrated immediately and others later on, the aforementioned delicate balance is restored and a better performance profile at page load will result.

How long do we wait to progressively hydrate? Luckily good patterns have been established by popular frameworks like Astro and Eleventy. Waiting to hydrate when a component scrolls into view or when the browser is idle are the options that will likely show the benefits most clearly; using Astro, the client:visible and client:idle directives correspond, respectively. For example, a <Carousel /> React component can be hydrated when it scrolls into view by writing it as <Carousel client:visible />; that’s it! The beauty of progressive hydration is that even if those established patterns don’t meet your needs, any time or event can be chosen as a target, but you’ll likely have to write your own code to do so. To make the process easier, Astro added the ability to add custom hydration directives in version 2.6, so check it out if you’re interested.

To emphasize the point of how useful partial and progressive hydration can be when used together, let’s reexamine the example from the previous article but this time with an overlay showing the boundaries of what’s rendered on screen (a.k.a. the browser viewport).

The previous article’s island-based component structure relative to the browser viewport
The previous article’s island-based component structure relative to the browser viewport

Three islands of interactivity exist on this page corresponding to the colored boxes: (1) the Shopping Cart component, (2) the Buy Now Button component, and (3) the Product Reviews component. From a user interest perspective, the Buy Now Button component is in the center of the screen so is likely to get the user’s attention first, and the Shopping Cart component is also on-screen and related especially if that button is clicked. However, the user can’t see the Product Reviews component at all. From an optimization perspective, progressive hydration can be used to delay execution of the Product Reviews component code until it scrolls into view; maybe the user won’t scroll down to it, so why bother? Using Astro’s syntax as a guide, let’s add a directive to the component and render it as <ProductReviews client:visible /> to see how this decision changes the performance profile of the page:

Progressive hydration prevents eager download of the Product Reviews component code
Progressive hydration prevents eager download of the Product Reviews component code

The Product Reviews component box is now white meaning that HTML is effectively static until the user scrolls it into view. Assuming these colored components all have a similar weight, approximately 33% less code runs during hydration at page load; pretty nice improvement for typing a few extra characters!

Progressive hydration provides numerous benefits over eager hydration methods. The initial page load will be less impacted by hydration bottlenecks because less JavaScript code will execute. This benefit can be compounded for non-urgent components by not even downloading the code for the component until hydration needs to occur. In other words, application bundle size will be reduced while improving Time to Interactive – the elapsed time from a page load beginning until user input can be quickly and reliably responded to. However, the delicate balance between immediate reaction to user action and minimizing code download and execution will need to be weighed to best determine the correct choice for each case.

A proper evaluation would not be complete without also examining the drawbacks of progressive hydration. The largest concern is the potential loss of user interactions due to missing JavaScript code pre-hydration. This can be mitigated somewhat by capturing user events such as clicks and keystrokes and replaying them when hydration has completed, but this comes with its own complexities and race conditions that are outside the scope of this article. Without a framework like Astro or Eleventy that includes handy directives to make delaying hydration easier, progressively hydrating requires a solid understanding of web APIs like IntersectionObserver and requestIdleCallback; for example, Astro’s client:visible and client:idle directives use those APIs under the hood, respectively. Thus, progressive hydration without a user-friendly framework is probably not a good choice for inexperienced engineers or teams.

Now that progressive hydration’s pros and cons are understood, what are the recommended ways to try it? At the risk of being repetitive, Eleventy and Astro have improved the developer experience significantly in this area, so the recommended way to learn is to start with either tool to gain experience. If more granularity is desired, build on top of either framework’s primitives or eventually build your own once the concepts are mastered.

I hope you enjoyed this overview of progressive hydration. If you have any questions or feedback about this topic or any others, please share your thoughts with me on Twitter. I would certainly enjoy hearing from you! And be sure to check out Babbel’s engineering team on Twitter to learn more about what’s going on across the department. Stay tuned for part 4 about streaming rendering and how full page loads can act similarly to a single-page app but without most of the JavaScript: magic!