Implementing Angular Hydration at Halodoc

Angular Jun 21, 2024

Server-side rendering (SSR) using Angular Universal has been undoubtedly one of the most important features in Angular, massively boosting the performance and user experience of web pages built using Angular. At Halodoc, we were early adopters of SSR, reaping its performance and SEO benefits. However, there have been very few major enhancements to SSR since its introduction in Angular 4, until Angular 16 introduced non-destructive hydration.

Understanding SSR and its challenges

Before we delve into non-destructive hydration, let's first understand how SSR (Server-Side Rendering) works and what challenges it presents.

How SSR Works:

  1. Initial Request: When a user visits your page, their browser sends a request to the server.

  2. Server Response: The server generates an HTML page and sends it back to the browser.

  3. Initial Render: The browser displays this HTML page, making the content viewable quickly.

  4. Client-Side Rendering (CSR) Takes Over: The browser downloads the necessary JavaScript files and rebuilds the page on the client side. This process includes:

    • Reconstructing the DOM
    • Attaching event handlers
    • Binding the state

Challenges with SSR:

  • Delayed Interactivity: The page is not immediately interactive. You might see the content, but buttons and other elements won't respond until the JavaScript code is fully loaded and executed. The time it takes for this to happen depends on the user's network connection and device.
  • Flickering: ┬áSometimes, you might notice a flickering effect as the page transitions from the initial, static HTML version to the fully interactive version powered by JavaScript. This can be a jarring experience for users.

You can read more about SSR here.

The Angular team and the community have been hard at work for many years, trying to figure out ways to reduce the time to interactive and get rid of flickering. Non-destructive hydration is the solution to all of these problems.

What is Non-Destructive Hydration ?

Non-destructive hydration works exactly like destructive hydration, except that once the server-side DOM is rendered, it is not scrapped off. Angular matches the nodes from the server-side DOM with the client-side DOM structure and attaches the event handlers and binds the state. This removes flickering, and since we are not reconstructing the DOM again, it takes less time for the application to become interactive. In simple words, non-destructive hydration creates a bridge between SSR and CSR for a smooth transition. From here on, I'm going to refer to non-destructive hydration simply as hydration for simplicity.

What are the advantages of Hydration ?

I have already mentioned the benefits of using hydration above, but let me explain them in depth.

1. Flickering

It may seem like a good-to-have thing rather than a must-have. A site without flickering might go unnoticed and unappreciated, but on the other side, if a site is flickering, it can look a lot less presentable and glitchy. Multiple issues have been raised in the Angular source code repository regarding the same issue.

Let me show you a frame-by-frame comparison of the Halodoc web page, generated using a lighthouse test.

Before Hydration:

before-hydration

After Hydration:

after-hydration

As you can see, in the first image, there is a blank screen initially when waiting for the web server response. Then the page becomes viewable in the 3rd frame itself. This is the server-side generated DOM, which is not yet interactive. Angular then scrapes the server-side generated DOM, which results in a blank screen again (frame 4). This is the reason for the flickering, which we see. It then re-constructs the DOM on the client side, making the application fully interactive (as seen from frame 5).

Whereas, in the second image, the page is viewable in the second frame itself, and also, there is no blank screen. This indicates that there is no flickering.

2. Time to Interactive (TTI)

TTI was earlier a metric Google used to measure the time taken for the page to become interactive. Although this has been removed now due to its over-sensitivity to network outliers and long tasks, it is captured in a more robust form called Interactive to Next Paint (INP). INP consists of three phases: input delay, processing delay, and presentation delay. Here we are concerned only about input delay. If a site has a high INP because of input delay caused due to the user clicking the page before it is fully interactive, then implementing hydration can contribute towards INP improvement since Angular can reuse most of the DOM nodes.

How to enable Hydration ?

Now that we understand the basics of SSR and hydration, let's learn how to enable hydration in your Angular application. This process is straightforward, especially if your application is already set up for server-side rendering (SSR). If not, you'll first need to enable SSR.

Once SSR is enabled, we will need to import provideClientHydration from @angular/platform-browser and add it inside the providers list either in the app.config.ts or app.module.ts file depending on whether you have a standalone app component or not.

export const appConfig: ApplicationConfig = {
  providers: [
    provideClientHydration(),
  ],
};

What challenges did we encounter ?

I would also like to highlight some of the challenges we faced and overcame when implementing hydration because as easy as it is to enable hydration, it'll not quite be production-ready yet. Every page in the application needs to be tested because, for the complete hydration to happen, and the UI to be rendered properly, it needs to satisfy a few criteria, which cannot be validated at the application level. It needs to be done at a page level.

1. Valid HTML structure

Hydration requires that we always use a valid HTML structure, and invalid use of HTML elements in a component can lead to a DOM mismatch error on that page. Some of the examples of an invalid HTML are:

  • Nesting one <a> tag within another
  • Placing a <div> tag inside a <p> tag

We even faced issues due to the order in which some elements such as <ng-content> were placed in some of the components due to DOM mismatch. Placing them at the end of their parent's closing tag did the trick. So we need to keep an eye out for that as well. You can make use of https://validator.w3.org/ to validate the structure of your HTML.

valid-html-structure-error

Above is an example of the warning we get when one <a> is added inside another.

2. Zone.js dependency

Hydration relies on Zone.js to emit a signal when the application is stable, i.e., there is no pending micro or macro task in the queue such as setTimeout, setInterval, and unresolved promises, etc. It is only once the application is stable that Angular starts SSR serialization and post-hydration clean-up.

Using macro/micro tasks during the initialization phase can be seen in web pages where we have components such as an auto-sliding image carousel, in which setInterval is used to auto-slide the images after a set period. This causes the application to not become stable at all.

zonejs-hydration-error

Above is the warning displayed if the application does not become stable after 10 seconds.

A good workaround for this kind of scenario is running the micro/macro task outside the Angular change detection cycle so that Angular does not consider it before emitting the signal. But we also know that if there are changes in the state outside Angular change detection, then it is not going to reflect in the template. So here is what we can do.

this.ngZone.runOutsideAngular(() => {
  this.intervalSubscription = setInterval(() => {
    this.ngZone.run(() => {
      // do something
    });
  }, 100);
});

Here we are running only the macro task outside change detection, and then whatever we want to do inside the callback function is again run inside change detection.

3. Transfer Cache

Hydration also provides us the option to cache API responses. We can enable this by passing additional parameters to provideClientHydration function.

export const appConfig: ApplicationConfig = {
  providers: [
    provideClientHydration(
    withHttpTransferCacheOptions({
      includeHeaders: ['X-XSRF-TOKEN'],
      filter: (req) => {
        return req.url.indexOf('assets/i18n') === -1;
      },
    })
    ),
  ],
};

Above is an example of how we can include certain headers and filter certain requests to be cached. One more important thing to note here is that this automatically transfers all API caches from the server to the browser, and you do not have to do it manually by injecting TransferState.

This is like two sides of the same coin. It can be beneficial for most cases as you do not manually have to do it, but on the other hand, it can cause some undesired side effects if not handled properly.

  • Certain APIs require the latest data only, such as a payment status polling API. For such cases, we will need to use the filter when enabling the cache option to filter out the APIs as discussed in the previous section.
  • If by any chance, your application has a different application state on the server and the client, then it can again cause issues. For example, if you are not passing a cookie to your web server, it may not be aware of the user's login status and hence can return a different response. For such cases, we either need to filter out the APIs or we need to make sure that the state is the same irrespective of the platform, i.e., both on the server and the browser.

Here is a error guide, consisting of probable errors one might encounter in Angular. I found it very helpful for debugging and resolving the error.

Other Constraints

Here are some of the other constraints/challenges that you need to be aware of before implementing hydration.

  1. According to the official Angular docs, if you make use of native DOM native APIs to change the structure of the DOM, i.e., adding/deleting a node, then Angular will not be aware of that during hydration, and this can lead to DOM mismatch error. Hence we always need to use Angular's wrapper APIs to do such things.
  2. If you make use of any third-party libraries, then there are chances that it might be doing some direct DOM manipulation or have an invalid HTML structure, in which case hydration will not happen properly, and ngSkipHydration has to be used.
  3. Hydration does not support internationalization (i18n) yet as of this writing. So the blocks using i18n will not be hydrated. The support is probably coming very soon, though, and will land along with Angular 18.
  4. Some CDNs offer a feature that optimizes the rendering process by stripping away nodes that they think are unnecessary, especially comment nodes. But these comment nodes are very important for hydration to happen correctly. So if you get this error https://angular.io/errors/NG0507, then please ensure the optimization is turned off.
  5. Using a custom or Noop Zone.js may result in the isStable event being triggered early or late, which affects the serialization process. So you may need to adjust the timing of this event for hydration to work properly.

How to disable Hydration for specific components ?

If you are facing any of the above mentioned challenges in hydrating a component and are unable to resolve the issue, then you can disable hydration for that particular component as a last resort. Here is how you can do so:

// Method 1

<my-component ngSkipHydration />

// Method 2

@Component({
  ...
  host: {ngSkipHydration: 'true'},
})
class MyComponent {}

Also, please note that the ngSkipHydration attribute can only be added to component host nodes.

Conclusion

In conclusion, Angular Universal's Server Side Rendering (SSR) with Hydration feature brings significant improvements to web application performance and user experience. By retaining the server-side rendered DOM and seamlessly transitioning to client-side rendering, hydration eliminates flickering and reduces the time to interactive.

However, implementing hydration requires thorough testing and might be time consuming as it requires fixing issue at a component level ensuring all the constraints are satisfied. Despite these challenges, the benefits of hydration in terms of improved user experience and performance make it a valuable addition to Angular's SSR capabilities. As Angular continues to evolve, we can expect further enhancements and refinements to the hydration process, making it even more effective and seamless for developers and users alike.

References

  1. Angular Hydration Documentation
  2. Angular SSR Documentation

Join Us

Scalability, reliability and maintainability are the three pillars that govern what we build at Halodoc Tech. We are actively looking for engineers at all levels and if solving hard problems with challenging requirements is your forte, please reach out to us with your resume at careers.india@halodoc.com.

About Halodoc

Halodoc is the number 1 all around Healthcare application in Indonesia. Our mission is to simplify and bring quality healthcare across Indonesia, from Sabang to Merauke. We connect 20,000+ doctors with patients in need through our Tele-consultation service. We partner with 3500+ pharmacies in 100+ cities to bring medicine to your doorstep. We've also partnered with Indonesia's largest lab provider to provide lab home services, and to top it off we have recently launched a premium appointment service that partners with 500+ hospitals that allow patients to book a doctor appointment inside our application. We are extremely fortunate to be trusted by our investors, such as the Bill & Melinda Gates Foundation, Singtel, UOB Ventures, Allianz, GoJek, Astra, Temasek, and many more. We recently closed our Series D round and in total have raised around USD$100+ million for our mission. Our team works tirelessly to make sure that we create the best healthcare solution personalised for all of our patient's needs, and are continuously on a path to simplify healthcare for Indonesia.

Pravesh S Shetty

SDE 1 at Halodoc