Angular Universal: real app problems

ikatsuba

Igor Katsuba

Posted on March 5, 2021

Angular Universal: real app problems

Angular Universal: real app problems

Angular Universal is an open-source project that extends the functionality of @angular/platform-server. The project makes server-side rendering possible in Angular.

Angular Universal supports multiple backends:

  1. Express
  2. ASP.NET Core
  3. hapi

Another package Socket Engine is a framework-agnostic that theoretically allows any backend to be connected to an SSR server.

This article will discuss the issues and possible solutions we encountered while developing a real application with Angular Universal and Express.


How Angular Universal Works

For rendering on the server, Angular uses the DOM implementation for node.js — domino. For each GET request, domino creates a similar Browser Document object. In that object context, Angular initializes the application. The app makes requests to the backend, performs various asynchronous tasks, and applies any change detection from components to the DOM while still running inside node.js environment. The render engine then serializes DOM into a string and serves up the string to the server. The server sends this HTML as a response to the GET request. Angular application on the server is destroyed after rendering.


SSR issues in Angular

1. Infinite page loading

Situation

The user opens a page on your site and sees a white screen. In other words, the time until the first byte takes too long. The browser really wants to receive a response from the server, but the request ends up with a timeout.

Why is this happening

Most likely, the problem lies in the Angular-specific SSR mechanism. Before we understand at what point the page is rendered, let's define Zone.js andApplicationRef.

Zone.js is a tool that allows you to track asynchronous operations. With its help, Angular creates its own zone and launches the application in it. At the end of each asynchronous operation in the Angular zone, change detection is triggered.

ApplicationRef is a reference to the running application (docs). Of all this class's functionality, we are interested in the ApplicationRef#isStable property. It is an Observable that emits a boolean. isStable is true when no asynchronous tasks are running in the Angular zone and false when there are no such tasks.

So, application stability is the state of the application, which depends on the presence of asynchronous tasks in the Angular zone.

So, at the moment of the first onset of stability, Angular renders the current state applications and destroys the platform. And the platform will destroy the application.

We can now assume that the user is trying to open an application that cannot achieve stability. setInterval, rxjs.interval or any other recursive asynchronous operation running in the Angular zone will make stability impossible. HTTP requests also affect stability. The lingering request on the server delays the moment the page is rendered.

Possible Solution

To avoid the situation with long requests, use the timeout operator from rxjs library:

import { timeout, catchError } from 'rxjs/operators';
import { of } from 'rxjs/observable/of';

http.get('https://example.com')
    .pipe(
        timeout(2000),
        catchError(e => of(null))
    ).subscribe()
Enter fullscreen mode Exit fullscreen mode

The operator will throw an exception after a specified period of time if no server response is received.

This approach has 2 cons:

  • there is no convenient division of logic by platform;
  • the timeout operator must be written manually for each request.

As a more straightforward solution, you can use the NgxSsrTimeoutModule module from the @ngx-ssr/timeout package. Import the module with the timeout value into the root module of the application. If the module is imported into AppServerModule, then HTTP request timeouts will only work for the server.

import { NgModule } from '@angular/core';
import {
    ServerModule,
} from '@angular/platform-server';
import { AppModule } from './app.module';
import { AppComponent } from './app.component';
import { NgxSsrTimeoutModule } from '@ngx-ssr/timeout';

@NgModule({
    imports: [
        AppModule,
        ServerModule,
        NgxSsrTimeoutModule.forRoot({ timeout: 500 }),
    ],
    bootstrap: [AppComponent],
})
export class AppServerModule {}
Enter fullscreen mode Exit fullscreen mode

Use the NgZone service to take asynchronous operations out of the Angular zone.

import { Injectable, NgZone } from "@angular/core";

@Injectable()
export class SomeService {
    constructor(private ngZone: NgZone){
        this.ngZone.runOutsideAngular(() => {
            interval(1).subscribe(() => {
                // somo code
            })
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

To solve this problem, you can use the tuiZonefree from the@taiga-ui/cdk:

import { Injectable, NgZone } from "@angular/core";
import { tuiZonefree } from "@taiga-ui/cdk";

@Injectable()
export class SomeService {
    constructor(private ngZone: NgZone){
        interval(1).pipe(tuiZonefree(ngZone)).subscribe()
    }
}
Enter fullscreen mode Exit fullscreen mode

But there is a nuance. Any task must be interrupted when the application is destroyed. Otherwise, you can catch a memory leak (see issue #5). You also need to understand that tasks that are removed from the zone will not trigger change detection.

2. Lack of cache out of the box

Situation

The user loads the home page of the site. The server requests data for the master and renders it, spending 2 seconds on it. Then the user goes from the main to the child section. Then it tries to go back and waits for the same 2 seconds as the first time.

If we assume that the data on which the main render depends has not changed, it turns out that HTML with this set has already been rendered. And in theory, we can reuse the HTML we got earlier.

Possible Solution

Various caching techniques come to the rescue. We'll cover two: in-memory cache and HTTP cache.

HTTP cache. When using a network cache, it's all about setting the correct response headers on the server. They specify the cache lifetime and caching policy:

Cache-Control: max-age = 31536000
Enter fullscreen mode Exit fullscreen mode

This option is suitable for an unauthorized zone and in the presence of long unchanging data.

You can read more about the HTTP cache here

In-memory cache. The in-memory cache can be used for both rendered pages and API requests within the application itself. Both possibilities are package @ngx-ssr/cache.

Add the NgxSsrCacheModule module to the AppModule to cache API requests and on the server in the browser.

The maxSize property is responsible for the maximum cache size. A value of 50 means that the cache will contain more than 50 of the last GET requests made from the application.

The maxAge property is responsible for the cache lifetime. Specified in milliseconds.

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { NgxSsrCacheModule } from '@ngx-ssr/cache';
import { environment } from '../environments/environment';

@NgModule({
    declarations: [AppComponent],
    imports: [
        BrowserModule,
        NgxSsrCacheModule.configLruCache({ maxAge: 10 * 60_000, maxSize: 50 }),
    ],
    bootstrap: [AppComponent],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

You can go ahead and cache the HTML itself.

For example, everything in the same package @ngx-ssr/cache has a submodule@ngx-ssr/cache/express. It imports a single withCache function. The function is a wrapper over the render engine.

import { ngExpressEngine } from '@nguniversal/express-engine';
import { LRUCache } from '@ngx-ssr/cache';
import { withCache } from '@ngx-ssr/cache/express';

server.engine(
    'html',
    withCache(
        new LRUCache({ maxAge: 10 * 60_000, maxSize: 100 }),
        ngExpressEngine({
            bootstrap: AppServerModule,
        })
    )
);
Enter fullscreen mode Exit fullscreen mode

3. Server errors of type ReferenceError: localStorage is not defined

Situation

The developer calls localStorage right in the body of the service. It retrieves data from the local storage by key. But on the server, this code crashes with an error: ReferenceError: localStorage is undefined.

Why is this happening

When running an Angular application on a server, the standard browser API is missing from the global space. For example, there's no global object document like you'd expect in a browser environment. To get the reference to the document, you must use the DOCUMENT token and DI.

Possible Solution

Don't use the browser API through the global space. There is DI for this. Through DI, you can replace or disable browser implementations for their safe use on the server.

The Web API for Angular can be used to resolve this issue.

For example:

import {Component, Inject, NgModule} from '@angular/core';
import {LOCAL_STORAGE} from '@ng-web-apis/common';

@Component({...})
export class SomeComponent {
    constructor(@Inject(LOCAL_STORAGE) localStorage: Storage) {
        localStorage.getItem('key');
    }
}
Enter fullscreen mode Exit fullscreen mode

The example above uses the LOCAL_STORAGE token from the @ng-web-apis/common package. But when we run this code on the server, we will get an error from the description. Just add UNIVERSAL_LOCAL_STORAGE from the package @ng-web-apis/universal in the providersAppServerModule, and by the token LOCAL_STORAGE, you will receive an implementation of localStorage for the server.

import { NgModule } from '@angular/core';
import {
    ServerModule,
} from '@angular/platform-server';
import { AppModule } from './app.module';
import { AppComponent } from './app.component';
import { UNIVERSAL_LOCAL_STORAGE } from '@ngx-ssr/timeout';

@NgModule({
    imports: [
        AppModule,
        ServerModule,
    ],
    providers: [UNIVERSAL_LOCAL_STORAGE],
    bootstrap: [AppComponent],
})
export class AppServerModule {}
Enter fullscreen mode Exit fullscreen mode

4. Inconvenient separation of logic

Situation

If you need to render the block only in the browser, you need to write approximately the following code:

@Component({
    selector: 'ram-root',
    template: '<some-сomp *ngIf="isServer"></some-сomp>',
    styleUrls: ['./app.component.less'],
})
export class AppComponent {
    isServer = isPlatformServer(this.platformId);

    constructor(@Inject(PLATFORM_ID) private platformId: Object){}
}
Enter fullscreen mode Exit fullscreen mode

The component needs to get the PLATFORM_ID, target platform, and understand the class's public property. This property will be used in the template in conjunction with the ngIf directive.

Possible Solution

With the help of structural directives and DI, the above mechanism can be greatly simplified.

First, let's wrap the server definition in a token.

export const IS_SERVER_PLATFORM = new InjectionToken<boolean>('Is server?', {
    factory() {
        return isPlatformServer(inject(PLATFORM_ID));
    },
});
Enter fullscreen mode Exit fullscreen mode

Create a structured directive using the IS_SERVER_PLATFORM token with one simple target: render the component only on the server.

@Directive({
    selector: '[ifIsServer]',
})
export class IfIsServerDirective {
    constructor(
        @Inject(IS_SERVER_PLATFORM) isServer: boolean,
        templateRef: TemplateRef<any>,
        viewContainer: ViewContainerRef
    ) {
        if (isServer) {
            viewContainer.createEmbeddedView(templateRef);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The code looks similar to the IfIsBowser directive.

Now let's refactor the component:

@Component({
    selector: 'ram-root',
    template: '<some-сomp *ifIsServer"></some-сomp>',
    styleUrls: ['./app.component.less'],
})
export class AppComponent {}
Enter fullscreen mode Exit fullscreen mode

Extra properties have been removed from the component. The component template is now a bit simpler.

Such directives declaratively hide and show content depending on the platform.

We have collected the tokens and directives in the package @ngx-ssr/platform.

5. Memory Leak

Situation

At initialization, the service starts an interval and performs some actions.

import { Injectable, NgZone } from "@angular/core";
import { interval } from "rxjs";

@Injectable()
export class LocationService {
    constructor(ngZone: NgZone) {
        ngZone.runOutsideAngular(() => interval(1000).subscribe(() => {
          ...
        }));
    }
}
Enter fullscreen mode Exit fullscreen mode

This code does not affect the application's stability, but the callback passed to subscribe will continue to be called if the application is destroyed on the server. Each launch of the application on the server will leave behind an artifact in the form of an interval. And this is a potential memory leak.

Possible Solution

In our case, the problem is solved by using the ngOnDestoroy hook. It works for both components and services. We need to save the subscription and terminate it when the service is destructed. There are many techniques for unsubscribing, but here is just one:

import { Injectable, NgZone, OnDestroy } from "@angular/core";
import { interval, Subscription } from "rxjs";

@Injectable()
export class LocationService implements OnDestroy {
  private subscription: Subscription;

  constructor(ngZone: NgZone) {
    this.subscription = ngZone.runOutsideAngular(() =>
      interval(1000).subscribe(() => {})
    );
  }

  ngOnDestroy(): void {
    this.subscription.unsubscribe();
  }
}
Enter fullscreen mode Exit fullscreen mode

6. Lack of rehydration

Situation

The user's browser displays a page received from the server, a white screen flickers for a moment, and the application starts functioning and looks normal.

Why is this happening

Angular does not know how to reuse what it has rendered on the server. It strips all the HTML from the root element and starts painting all over again.

Possible Solution

It still doesn't exist. But there is hope that there will be a solution. Angular Universal's roadmap has a clause: "Full client rehydration strategy that reuses DOM elements/CSS rendered on the server".

7. Inability to abort rendering

Situation

We are catching a critical error. Rendering and waiting for stability are meaningless. You need to interrupt the process and give the client the default index.html.

Why is this happening

Let's go back to the moment of rendering the application. It occurs when the application becomes stable. We can make our application stable faster using the solution from problem #1. But what if we want to abort the rendering process on the first caught error? What if we want to set a time limit on trying to render an application?

Possible Solution

There is no solution to this problem now.

Summary

In fact, Angular Universal is the only supported and most widely used solution for rendering Angular applications on the server. The difficulty of integrating into an existing application depends largely on the developer. There are still unresolved issues that don't allow me to classify Angular Universal as a production-ready solution. It is suitable for landing pages and static pages, but on complex applications, you can collect many problems, the solution of which will break in the blink of the page due to the lack of rehydration.

💖 💪 🙅 🚩
ikatsuba
Igor Katsuba

Posted on March 5, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related