There is an abundance of frameworks that can be used to create web applications. Within the past few years, it seems like the speed at which new frameworks are being released has increased. This made me wonder if the wheel was just being reinvented or if there was something new and innovative being provided by these frameworks. As it turns out, the available frameworks out in the wild have varying approaches on how to handle rendering HTML and making pages interactive.
In this article, I will go over Next.js, Astro, and Qwik and compare the performance of these three frameworks within a fairly simple app. I will also briefly touch upon various rendering strategies. We will not discuss underlying implementations employed by these frameworks, though–that’s a big enough topic for another article. There’s also a caveat: the app utilized in testing is very simple, and while it is intentionally made to not be performant (it fetches 1000 Pokemon entries in the request), it is still not necessarily reflective of the performance a large application at scale would have. This is to say that more nuanced differences between these frameworks might become more apparent with another test.
What is Hydration?
Hydration is the reconciliation of all the server application context on the browser. Simply put, it’s the process of taking HTML from the server and making it functional and interactive. I’ll explain: When you navigate to a server-rendered page, a few things go on behind the scenes before you can interact with that page. The server sends the application in the form of HTML and JS. The HTML was pre-rendered on the server, so the browser paints immediately. At this point, however, the app is not functional or interactive. Hydration is what reconciles the server’s application context with the browser, making it so events like button clicks work as intended. The JS needs to be downloaded, parsed, and executed in the browser before the app can become interactive.
The result of the execution of this JS bundle is the binding of event listeners to the DOM, but this can be a complex operation because the event listeners need to know information that is critical to the functionality of an app. Let’s consider an add-to-cart button. The application data that needs to be known are things like:
- What is the cart?
- What am I adding to it?
- Under which user am I logged in?
In addition to application data, we need to know about framework data:
- What is the hierarchical structure of the component?
- When do I need to rerender?
- What do I rerender?
You might be asking yourself, why is application context lost in the first place? Well if you ship a Single Page Application (SPA), the browser will have a blank screen until the application boots up. And with a reasonably sized application, the delay can be significant. This is not a great user experience. In an effort to prevent the user from having to wait with a blank browser screen, we can Static Site Generate (SSG) or Server Side Render (SSR) the application; we send over a cached copy via a CDN to the client and then we bootstrap the application. In summary, we initiate the application on the server to minimize the First Contentful Paint (FCP) delay that the user experiences.
Next.js is very flexible and provides options for Client Side Rendering (CSR), Server Side Rendering (SSR), and—although it’s still experimental—progressive hydration via React Server Components and concurrency.
CSR, as the name implies, is when an empty root element is obtained from the server and the entire app is rendered on the client. This method uses hydration since the JS executes and loads the app into the root element, but perhaps not in the traditional sense where the app was rendered in both the server and the client requiring reconciliation. Our next pattern, SSR, is when an app is rendered twice and the reconciliation happens on the client.
Lastly, progressive hydration is an attempt to minimize the hydration delay or Time to Interactive (TTI) by selectively hydrating specific components rather than the entire application. As a reminder, an app is not interactive until all downloaded JS has been executed. When you think of giant eCommerce sites like Amazon, Nike, or Etsy, reducing the TTI by strategically hydrating highly trafficked and high-value components provides a non-trivial performance gain. For example, when I log into my Etsy account for the first time I’m much more likely to add something to my cart than to click the logout button. It should be noted that this rendering method may not be ideal for an application in which all elements in the viewport need to be interactive on load. This is due to the fact that it’s hard to predict which elements the user is likely to click on first.
Our Next.js app is very simple. On the server, we fetched a list of 1000 Pokémon, rendered a
div containing the Pokemon name for each one, then a button to fetch the next page of Pokémon. Again I’ll reiterate that due to the simplicity and load on our demo app, the majority of the performance scores reviewed here will be on the higher end of the range. These tests, however, will at least provide an idea of the kind of results we can expect to see within a production app at scale.
Lighthouse is an automated open source tool used for evaluating and improving the quality of web pages. It provides several metrics that are useful in assessing the performance of a particular page. The Next.js app received a performance score of 88. Time to Interactive (TTI) was 1.7s—this is the time elapsed from the initial request until the app is ready for user input. Total Blocking Time (TBT) was 180ms and refers to the time the user’s input was blocked as a result of a main thread process from the first contentful paint. The Largest Contentful Paint (LCP) was also 1.7s.
For reference, a fast TTI score would be of 3.8 seconds or less, a good TBT score is 200ms or less, and a good LCP score is 2.5s or less.
Island architecture is similar to progressive hydration, but there are notable differences. Island architecture is a form of selective hydration or partial hydration, where individual component islands are hydrated among static content. Components can load in parallel and hydrate in isolation. Contrastingly, progressive hydration selectively hydrates high-priority components first, but the entire UI is hydrated eventually. Astro is compatible with the majority of today’s modern UI frameworks, simplifying the integration process.
From our lighthouse results, we can see that Astro is very fast: a performance score of 100, a TTI of 0.3s, a 0s TBT, and an LCP of 0.4. This is no surprise since all of the content above is static. What happens, though, when we take our React component from our Next.js component from earlier and render it within an Astro page?
Our results were almost identical to our previous run. TTI increased by 0.1s and that is all. It’s mind-blowing how much of a performance boost you’re able to achieve by rendering with Astro. By taking the same Next.js component, we were able to reduce the TBT from 180ms to 0s and the Largest Contentful Paint from 1.7s to 0.4s.
Qwik is the newest of the three frameworks and it takes a unique approach to tackling the setbacks associated with hydration: eliminate hydration altogether. That’s right, don’t hydrate anything. As you may recall from earlier on, hydration is only necessary because the app is initially rendered on the server and all of the context, or state of the app, is discarded or lost on the client. Hydration is the reconciliation of the app on the client with what was rendered on the server.
Qwik is able to reason about the system without having access to the application code and thus eliminating the need for hydration. This is done in part by what Qwik calls “The Optimizer,” a compiler written in Rust that serializes the state of the app that would normally be lost and allows the app to resume on the client. Of course, this is a drastic oversimplification. You can read more about it in Qwik’s documentation. Perhaps you’ve heard the term resumable application? Well, this is where it comes from.
This differs from Next.js which more or less builds everything twice, and from Astro which adds in JS only where it’s needed.
The Qwik application received a performance score of 100, a 0.5s TTI, a 0s TBT, and a 0.5s LCP.
While ways of hydrating interfaces have come a long way and can be very performant, as seen in our Astro app, it is a breath of fresh air to see frameworks like Qwik gaining traction and popularity among the community. The fact that Qwik has found a way to serialize the state of the system, along with the ease in which you can reason about the state of that system within your browser dev tools is truly impressive.
I hope that you have learned a thing or two about rendering patterns and can use that information to inform your decision next time you’re looking for a framework for your next project.