Rendering on the Web

Where should we implement logic and rendering in our applications? Should we use Server Side Rendering? What about Rehydration? Let's find some answers!

As developers, we are often faced with decisions that will affect the entire architecture of our applications. One of the core decisions web developers must make is where to implement logic and rendering in their application. This can be difficult, since there are a number of different ways to build a website.

Our understanding of this space is informed by our work in Chrome talking to large sites over the past few years. Broadly speaking, we would encourage developers to consider server-side rendering or static rendering over a full rehydration approach.

In order to better understand the architectures we're choosing from when we make this decision, we need to have a solid understanding of each approach and consistent terminology to use when speaking about them. The differences between these approaches help illustrate the trade-offs of rendering on the web through the lens of performance.

Terminology

Rendering

  • Server-side rendering (SSR): rendering a client-side or universal app to HTML on the server.
  • Client-side rendering (CSR): rendering an app in a browser via JavaScript to modify the DOM.
  • Rehydration: "booting up" JavaScript views on the client such that they reuse the server-rendered HTML's DOM tree and data.
  • Prerendering: running a client-side application at build time to capture its initial state as static HTML.

Performance

Server-side rendering

Server-side rendering generates the full HTML for a page on the server in response to navigation. This avoids additional round-trips for data fetching and templating on the client, since it's handled before the browser gets a response.

Server-side rendering generally produces a fast FCP. Running page logic and rendering on the server makes it possible to avoid sending lots of JavaScript to the client. This helps to reduce a page's TBT, which can also lead to a lower INP, as the main thread is not blocked as often during page load. When the main thread is blocked less often, user interactions will have more opportunities to run sooner. This makes sense, since with server-side rendering, you're really just sending text and links to the user's browser. This approach can work well for a large spectrum of device and network conditions, and opens up interesting browser optimizations like streaming document parsing.

Diagram showing server-side rendering and JS execution affecting FCP and TTI.

With server-side rendering, users are less likely to be left waiting for CPU-bound JavaScript to run before they can use your site. Even where third-party JS can't be avoided, using server-side rendering to reduce your own first-party JavaScript costs can give you more budget for the rest. However, there is one potential trade-off with this approach: generating pages on the server takes time, which may result in a higher TTFB.

Whether server-side rendering is enough for your application largely depends on what type of experience you are building. There is a long-standing debate over the correct applications of server-side rendering versus client-side rendering, but it's important to remember that you can opt to use server-side rendering for some pages and not others. Some sites have adopted hybrid rendering techniques with success. Netflix server-renders its relatively static landing pages, while prefetching the JS for interaction-heavy pages, giving these heavier client-rendered pages a better chance of loading quickly.

Many modern frameworks, libraries and architectures make it possible to render the same application on both the client and the server. These techniques can be used for server-side rendering. However, it's important to note that architectures where rendering happens both on the server and on the client are their own class of solution with very different performance characteristics and tradeoffs. React users can use server DOM APIs or solutions built atop them like Next.js for server-side rendering. Vue users can look at Vue's server-side rendering guide or Nuxt. Angular has Universal. Most popular solutions employ some form of hydration though, so be aware of the approach in use before selecting a tool.

Static rendering

Static rendering happens at build-time. This approach offers a fast FCP, and also a lower TBT and INP—assuming the amount of client-side JS is limited. Unlike server-side rendering, it also manages to achieve a consistently fast TTFB, since the HTML for a page doesn't have to be dynamically generated on the server. Generally, static rendering means producing a separate HTML file for each URL ahead of time. With HTML responses generated in advance, static renders can be deployed to multiple CDNs to take advantage of edge caching.

Diagram showing static rendering and optional JS execution affecting FCP and TTI.

Solutions for static rendering come in all shapes and sizes. Tools like Gatsby are designed to make developers feel like their application is being rendered dynamically rather than generated as a build step. Static site generation tools such as 11ty, Jekyll, and Metalsmith embrace their static nature, providing a more template-driven approach.

One of the downsides to static rendering is that individual HTML files must be generated for every possible URL. This can be challenging or even infeasible when you can't predict what those URLs will be ahead of time, or for sites with a large number of unique pages.

React users may be familiar with Gatsby, Next.js static export or Navi—all of these make it convenient to author pages using components. However, it's important to understand the difference between static rendering and prerendering: static rendered pages are interactive without the need to execute much client-side JavaScript, whereas prerendering improves the FCP of a Single Page Application that must be booted on the client in order for pages to be truly interactive.

If you're unsure whether a given solution is static rendering or prerendering, try disabling JavaScript and load the page you want to test. For statically rendered pages, most of the functionality will still exist without JavaScript enabled. For prerendered pages, there may still be some basic functionality like links, but most of the page will be inert.

Another useful test is to use network throttling in Chrome DevTools, and observe how much JavaScript has been downloaded before a page becomes interactive. Prerendering generally requires more JavaScript to become interactive, and that JavaScript tends to be more complex than the progressive enhancement approach used by static rendering.

Server-side rendering versus static rendering

Server-side rendering is not a silver bullet—its dynamic nature can come with significant compute overhead costs. Many server-side rendering solutions don't flush early, can delay TTFB, or double the data being sent (for example, inlined state used by JavaScript on the client). In React, renderToString() can be slow as it's synchronous and single-threaded. Newer React server DOM APIs supporting streaming, which can get the initial part of an HTML response to the browser sooner while the rest of it is still being generated on the server.

Getting server-side rendering "right" can involve finding or building a solution for component caching, managing memory consumption, applying memoization techniques, and other concerns. You're generally processing/rebuilding the same application multiple times—once on the client and once on the server. Just because server-side rendering can make something show up sooner doesn't suddenly mean you have less work to do—if you have a lot of work on the client after a server-generated HTML response arrives on the client, this can still lead to higher TBT and INP for your website.

Server-side rendering produces HTML on-demand for each URL, but can be slower than just serving static rendered content. If you can put in the additional leg-work, server-side rendering plus HTML caching can significantly reduce server render time. The upside to server-side rendering is the ability to pull more "live" data and respond to a more complete set of requests than is possible with static rendering. Pages requiring personalization are a concrete example of the type of request that would not work well with static rendering.

Server-side rendering can also present interesting decisions when building a PWA: is it better to use full-page service worker caching, or just server-render individual pieces of content?

Client-side rendering

Client-side rendering means rendering pages directly in the browser with JavaScript. All logic, data fetching, templating and routing are handled on the client rather than the server. The effective outcome is that more data is passed to the user's device from the server, and that comes with its own set of trade-offs.

Client-side rendering can be difficult to get and keep fast for mobile devices. If minimal work is done, client-side rendering can approach the performance of pure server-side rendering, keeping a tight JavaScript budget and delivering value in as few round-trips as possible. Critical scripts and data can be delivered sooner using <link rel=preload>, which gets the parser working for you sooner. Patterns like PRPL are also worth evaluating in order to ensure initial and subsequent navigations feel instant.

Diagram showing client-side rendering affecting FCP and TTI.

The primary downside to client-side rendering is that the amount of JavaScript required tends to grow as an application grows, which can have negative effects on a page's INP. This becomes especially difficult with the addition of new JavaScript libraries, polyfills and third-party code, which compete for processing power and must often be processed before a page's content can be rendered.

Experiences that use client-side rendering that rely on large JavaScript bundles should consider aggressive code-splitting to lower TBT and INP during page load, and be sure to lazy-load JavaScript—"serve only what you need, when you need it". For experiences with little or no interactivity, server-side rendering can represent a more scalable solution to these issues.

For folks building single page applications, identifying core parts of the user interface shared by most pages means you can apply the application shell caching technique. Combined with service workers, this can dramatically improve perceived performance on repeat visits, as the application shell HTML and its dependencies can be loaded from CacheStorage very quickly.

Combining server-side rendering and client-side rendering via rehydration

This approach attempts to smooth over the trade-offs between client-side rendering and server-side rendering by doing both. Navigation requests like full page loads or reloads are handled by a server that renders the application to HTML, then the JavaScript and data used for rendering is embedded into the resulting document. When done carefully, this achieves a fast FCP just like server-side rendering, then "picks up" by rendering again on the client using a technique called (re)hydration. This is an effective solution, but it can come with considerable performance drawbacks.

The primary downside of server-side rendering with rehydration is that it can have a significant negative impact on TBT and INP, even if it improves FCP. Server-side rendered pages can deceptively appear to be loaded and interactive, but can't actually respond to input until the client-side scripts for components are executed and event handlers have been attached. This can take seconds or even minutes on mobile.

Perhaps you've experienced this yourself—for a period of time after it looks like a page has loaded, clicking or tapping does nothing. This quickly becomes frustrating, as the user is left to wonder why nothing is happening when they try to interact with the page.

A rehydration problem: one app for the price of two

Rehydration issues can often be worse than delayed interactivity due to JavaScript. In order for the client-side JavaScript to be able to accurately "pick up" where the server left off without having to re-request all of the data the server used to render its HTML, current server-side rendering solutions generally serialize the response from a UI's data dependencies into the document as script tags. The resulting HTML document contains a high level of duplication:

HTML document
containing serialized UI, inlined data and a bundle.js script

As you can see, the server is returning a description of the application's UI in response to a navigation request, but it's also returning the source data used to compose that UI, and a complete copy of the UI's implementation which then boots up on the client. Only after bundle.js has finished loading and executing does this UI become interactive.

Performance metrics collected from real websites using server-side rendering and rehydration indicate its use should be discouraged. Ultimately, the reason comes down to the user experience: it's extremely easy to end up leaving users in an "uncanny valley", where interactivity feels absent even though the page appears to be ready.

Diagram showing client rendering negatively affecting TTI.

There's hope for server-side rendering with rehydration, though. In the short term, only using server-side rendering for highly cacheable content can reduce TTFB, producing similar results to prerendering. Rehydrating incrementally, progressively, or partially may be the key to making this technique more viable in the future.

Streaming server-side rendering and progressive rehydration

Server-side rendering has had a number of developments over the last few years.

Streaming server-side rendering allows you to send HTML in chunks that the browser can progressively render as it's received. This can result in a fast FCP, as markup arrives to users faster. In React, streams being asynchronous in [renderToPipeableStream()]—compared to synchronous renderToString()—means backpressure is handled well.

Progressive rehydration is also worth considering, and something that React has landed. With this approach, individual pieces of a server-rendered application are "booted up" over time, rather than the current common approach of initializing the entire application at once. This can help reduce the amount of JavaScript required to make pages interactive, since client-side upgrading of low priority parts of the page can be deferred to prevent blocking the main thread, allowing user interactions to occur sooner after the user initiates them.

Progressive rehydration can also help avoid one of the most common server-side rendering rehydration pitfalls, where a server-rendered DOM tree gets destroyed and then immediately rebuilt—most often because the initial synchronous client-side render required data that wasn't quite ready, perhaps awaiting resolution of a Promise.

Partial rehydration

Partial rehydration has proven difficult to implement. This approach is an extension of the idea of progressive rehydration, where the individual pieces (components/views/trees) to be progressively rehydrated are analyzed and those with little interactivity or no reactivity are identified. For each of these mostly-static parts, the corresponding JavaScript code is then transformed into inert references and decorative functionality, reducing their client-side footprint to nearly zero.

The partial hydration approach comes with its own issues and compromises. It poses some interesting challenges for caching, and client-side navigation means we can't assume server-rendered HTML for inert parts of the application will be available without a full page load.

Trisomorphic rendering

If service workers are an option for you, "trisomorphic" rendering may also be of interest. It's a technique where you can use streaming server-side rendering for initial/non-JS navigations, and then have your service worker take on rendering of HTML for navigations after it has been installed. This can keep cached components and templates up to date and enables SPA-style navigations for rendering new views in the same session. This approach works best when you can share the same templating and routing code between the server, client page, and service worker.

Diagram of Trisomorphic rendering, showing a browser and service worker communicating with the server.

SEO considerations

Teams often factor in the impact of SEO when choosing a strategy for rendering on the web. Server-side rendering is often chosen for delivering a "complete looking" experience that crawlers can interpret with ease. Crawlers may understand JavaScript, but there are often limitations worth being aware of in how they render. Client-side rendering can work, but often not without additional testing and leg-work. More recently, dynamic rendering has also become an option worth considering if your architecture depends heavily on client-side JavaScript.

When in doubt, the mobile friendly test tool is invaluable for testing that your chosen approach does what you're hoping for. It shows a visual preview of how any page appears to Google's crawler, the serialized HTML content found (after JavaScript is executed), and any errors encountered during rendering.

Screenshot of the Mobile Friendly Test UI.

Wrapping up

When deciding on an approach to rendering, measure and understand what your bottlenecks are. Consider whether static rendering or server-side rendering can get you most of the way there. It's perfectly okay to mostly ship HTML with minimal JavaScript to get an experience interactive. Here's a handy infographic showing the server-client spectrum:

Infographic showing the spectrum of options described in this article.

Credits

Thanks to everyone for their reviews and inspiration:

Jeffrey Posnick, Houssein Djirdeh, Shubhie Panicker, Chris Harrelson, and Sebastian Markbåge