Photo by Martin Abegglen on Flickr
5-part Series: Exploring Web Rendering
- Isomorphic JavaScript & Hydration ⟵ you’re reading this
- Partial Hydration (a.k.a. “Islands”)
- Progressive Hydration
- Streaming HTML
- Server Components Architecture
What an exciting time the past few years have been in the evolution of web rendering technologies! Our team at Babbel is hyper-focussed on performance improvements to our applications this year, and we’re going to be using some very cool new concepts and technologies to achieve our goals. As a Principal Engineer, rather than keep my research and knowledge sharing within our walls, I’ve decided to share what I’m thinking about in hopes to expose and teach these topics to a wider audience, grow a dialog with the wider community, and to reinforce my own knowledge – the best way to learn is to teach after all. Before starting, thank you to Ryan Carniato – creator of SolidJS – whose YouTube streams and dev.to articles have been a huge inspiration and source of guidance for my learning and writing about many technologies and frameworks; I strongly encourage you to follow his work. Without further ado, please join me in my 5-part series exploring some of the more interesting modern web rendering techniques; this article is just the start! If you have any questions or feedback of any kind, please reach out to me on Twitter; I’d love to hear from you!
The influx of new performance-focussed frameworks like Astro, SolidJS, and Qwik has indicated we’re entering a new web era, exemplified by them topping the developer satisfaction charts for rendering and front-end frameworks respectively. In addition, considering the growing correlation between performance, search engine rank, and revenue, understanding the progression of web rendering technologies has never been more important.
The rise in popularity of component-based, single page app frameworks in the early to mid 2010s improved developer experience with the introduction of declarative UIs and simpler APIs. Such simplicity seeking led to rendering being done in the browser, otherwise known as client-side rendering (CSR). While reasoning about an app during development was now simpler than before, application initialization performance usually suffered because now the entire app lived in JavaScript which required it to be downloaded, parsed, then executed thus delaying HTML parsing and asset downloads; beforehand the user won’t see anything at all! An alternative was eventually evangelized known as isomorphic (a.k.a. universal) JavaScript which attempted to combine the best of what the server and browser had to offer.
Ultimately, isomorphic rendering of JavaScript means that a single application can be used on both a server and browser. Specifically, the server renders the application using a technique called server-side rendering (SSR) and the client uses the application to hydrate the HTML page in order to make it interactive; we’ll cover hydration a bit later. Traditionally, server-side rendering involves server-based routing meaning a “full page load”. In other words, when you click a link to navigate to a new page, the server decides what happens when the click occurs including what content is displayed on the next page. Contrast that with client-side routing where those decisions are made in the browser meaning the browser updates the URL, fetches the new URL’s data via API if needed for the route, then updates the UI; consider this a partial page load because usually only the part of the page that needs to change is updated but the rest is unaffected (e.g. the app shell). Isomorphic rendering is effectively a combination of the two: the server renders the full page HTML, then the client mounts the app and takes over all routing duties. In other words, the application starts off as SSR then automatically transforms and remains as CSR throughout the life of the page. See the following images for a visual comparison of the three aforementioned techniques in the same scenario: navigating from a start page (1) to two subsequent pages (2 and 3); note the differences between client-side and isomorphic routing for step 1 after which they operate identically.
Remember that an isomorphic application first involves a server rendering an HTML page using a JavaScript application then a browser hydrating that page using the same application the server used. So what is hydration? Let’s define it: hydration is the process of initializing an application to make a server-rendered HTML page interactive by setting up the necessary application state and event listeners. Traditionally, hydration downloads and executes all components in your application eagerly meaning that doing so occurs as soon as possible, but there are other, potentially more performant options which will be explored in future articles in this series including partial (part 2) and progressive hydration (part 3).
Because the application is shared between server and browser, state is as well. If your app has a single source of state as is often the case with Redux, for example, sharing state between server and browser can be as simple as passing your state object through JSON.stringify() then adding it to a <script> element at the end of your HTML output so as to not be render blocking; more elaborate state models require more complex methods of state sharing.
On the server, the DOM is non-existent so its events can effectively be ignored, but on the client they’re treated normally. Using React as an example, when an application is rendered with React.hydrateRoot(), React is smart enough to know that hydration is client-only so event listeners should be bound. However, when rendering that same application with React.renderToString(), binding event listeners is skipped because that function is part of React’s server-only API. As a result, occasionally your code will need to be environment-aware so client- or server-specific actions can be taken when appropriate. Clearly, while your application may be shared between server and browser, isomorphic rendering has additional mental overhead during development that must be factored in when choosing your desired rendering model.
Isomorphic applications can provide multiple benefits over client-rendered ones. Instead of an empty HTML shell like what’s often used to bootstrap a CSR app, a server renders the entire page’s content into HTML which allows browsers to begin parsing the page HTML sooner. Thus, page assets like images, JS, and CSS begin downloading earlier. Similarly, SEO is improved because web crawlers like googlebot can crawl your pages more quickly with the full page’s content available rather than having to wait for all client API requests to respond and the DOM to incrementally update; the latter can be time-consuming and error-prone for crawlers. Furthermore, because the HTML is fully available and assuming the response per page doesn’t change, you can optionally CDN cache or static build your pages for a performance boost, especially the prior which will cause the response to skip talking to your origin server and be returned from as close to the user as possible; latency and server load is minimized as a result. If your application values quick navigation times, the client-side routing of an isomorphic application after initial page load should act similarly to a CSR app with no server rendering.
For a complete analysis, downsides must be explored as well. Ignoring streaming rendering for now (part 4 in this series will cover it), Time to First Byte (TTFB) for isomorphic rendered HTML is larger than CSR’s static app shell HTML because all server-side API requests need to complete before being included in the returned HTML. In addition, total isomorphic payload size is larger because the aforementioned completed request data is included within the HTML. However, the ability to parse and download assets sooner usually outweighs that downside and results in an overall faster page load. Eager hydration can cause a period of unresponsiveness known as a long task which can negatively affect time to interactive; this symptom is especially painful for devices with slow single-threaded CPUs (I love hardware, too!). In other words, if too much hydration occurs all at once, users will experience freezing of the user interface during page load which is when they are most likely to be interested in interaction; the following articles in this series discuss ways to mitigate this and other problems to improve user experience. Because isomorphic rendering relies on JavaScript for interactivity, user attempts to click a button for example will have no effect until hydration completes; this example ignores progressive enhancement, and mitigation techniques do exist but they often involve loading tiny bits of JavaScript even earlier. Finally, the “double data” problem is an Achilles heel of the design and refers to data needing to be downloaded twice: once embedded into the HTML as content and again in the serialized JSON used to hydrate the application. An example of this repetition is the body of a blog post which gets rendered directly into the HTML and encoded into the JSON used for hydration; if the post content is the vast majority of your page weight, the page has now become effectively twice as large.
Now that isomorphic rendering has been explained, how can it be used? The most easy-to-use options are known as “metaframeworks” which take a base rendering framework like React, Qwik, or SolidJS and add additional features like routing and style management resulting in options like Next.js, QwikCity, and SolidStart, respectively, among others. Try them out and see what works best for you. 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! Happy rendering!