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 a 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 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.
- SSR: Server-Side Rendering — rendering a client-side or universal app to HTML on the server.
- CSR: Client-Side Rendering — rendering an app in a browser, generally using the DOM.
- Prerendering: running a client-side application at build time to capture its initial state as static HTML.
- TTFB: Time to First Byte — seen as the time between clicking a link and the first bit of content coming in.
- FP: First Paint — the first time any pixel gets becomes visible to the user.
- FCP: First Contentful Paint — the time when requested content (article body, etc) becomes visible.
- TTI: Time To Interactive — the time at which a page becomes interactive (events wired up, etc).
Server 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.
Whether server rendering is enough for your application largely depends on what type of experience you are building. There is a longstanding debate over the correct applications of server rendering versus client-side rendering, but it’s important to remember that you can opt to use server 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 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 renderToString() or solutions built atop it like Next.js for server rendering. Vue users can look at Vue’s server 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 happens at build-time and offers a fast First Paint, First Contentful Paint and Time To Interactive — assuming the amount of client-side JS is limited. Unlike Server Rendering, it also manages to achieve a consistently fast Time To First Byte, since the HTML for a page doesn’t have to be generated on the fly. Generally, static rendering means producing a separate HTML file for each URL ahead of time. With HTML responses being generated in advance, static renders can be deployed to multiple CDNs to take advantage of edge-caching.
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. Others like Jekyl 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 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 JS, whereas prerendering improves the First Paint or First Contentful Paint of a Single Page Application that must be booted on the client in order for pages to be truly interactive.
Server Rendering vs Static Rendering
Server rendering is not a silver bullet — its dynamic nature can come with significant compute overhead costs. Many server rendering solutions don’t flush early, can delay TTFB or double the data being sent (e.g. inlined state used by JS on the client). In React, renderToString() can be slow as it’s synchronous and single-threaded. Getting server rendering “right” can involve finding or building a solution for component caching, managing memory consumption, applying memoization techniques, and many other concerns. You’re generally processing/rebuilding the same application multiple times — once on the client and once in the server. Just because server rendering can make something show up sooner doesn’t suddenly mean you have less work to do.
Server 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 rendering + HTML caching can massively reduce server render time. The upside to server 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.
Client-Side Rendering (CSR)
<link rel=preload>, which gets the parser working for you sooner. Patterns like PRPL are worth evaluating in order to ensure initial and subsequent navigations feel instant.
For folks building a Single Page Application, 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.
Combining server rendering and CSR via rehydration
The primary downside of SSR with rehydration is that it can have a significant negative impact on Time To Interactive, even if it improves First Paint. SSR’d pages often look deceptively loaded and interactive, but can’t actually respond to input until the client-side JS is 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 becoming frustrating… “Why is nothing happening? Why can’t I scroll?”
A Rehydration Problem: One App for the Price of Two
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 SSR rehydration indicate its use should be heavily discouraged. Ultimately, the reason comes down to User Experience: it’s extremely easy to end up leaving users in an “uncanny valley”.
There’s hope for SSR with rehydration, though. In the short term, only using SSR for highly cacheable content can reduce the TTFB delay, 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 rendering and Progressive Rehydration
Server rendering has had a number of developments over the last few years.
Streaming server rendering allows you to send HTML in chunks that the browser can progressively render as it’s received. This can provide a fast First Paint and First Contentful Paint as markup arrives to users faster. In React, streams being asynchronous in renderToNodeStream() — compared to synchronous renderToString — means backpressure is handled well.
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 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.
When deciding on an approach to rendering, measure and understand what your bottlenecks are. Consider whether static rendering or server rendering can get you 90% of the way there. It’s perfectly okay to mostly ship HTML with minimal JS to get an experience interactive. Here’s a handy infographic showing the server-client spectrum: