The Mechanics of Server-Side Rendering in Angular Universal

Angular Universal serves as an open-source initiative that enhances @angular/platform-server, bringing server-side rendering capabilities to Angular applications. A variety of backend solutions are supported by Angular Universal:

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

There is also the Socket Engine, a framework-agnostic module that, in theory, permits any backend to link up with an SSR server.

Our focus here will be on the practical challenges and workarounds that surfaced during the creation of a real-world application leveraging both Angular Universal and Express.


Server-side rendering relies on domino, a DOM implementation tailored for node.js. Each GET request triggers domino to construct an object akin to a Browser Document. Within this context, Angular bootstraps the application, which then communicates with the backend, executes various asynchronous activities, and propagates change detection from components to the simulated DOM, all within the node.js runtime. Afterward, the render engine transforms the DOM into a string, delivers it to the server, and the server responds to the GET request with this HTML. Post-rendering, the Angular application on the server is terminated.

1. The Never-Ending Page Load

What You See

Visitors encounter a blank page, with the time to first byte stretching excessively. The browser awaits a server response indefinitely until the request ultimately times out.

Underlying Cause

The culprit is often the unique SSR mechanism within Angular. To pinpoint when a page gets rendered, we must clarify two concepts: Zone.js and ApplicationRef.

Zone.js is a utility designed to monitor asynchronous operations. Angular leverages it to establish its own zone and run the app within it. Upon every asynchronous task's completion inside this Angular zone, change detection fires.

ApplicationRef stands as a reference to the active application (docs). Of its various features, we're particularly interested in the ApplicationRef#isStable property. This observable emits a boolean, signalling true when no asynchronous tasks are pending within the Angular zone, and false when there are.

In essence, application stability describes a state contingent on the presence of asynchronous tasks in the Angular zone.

Upon the first instance of stability, Angular renders the current application state and then terminates the platform, which in turn destroys the application.

With this foundation, we can infer that the user is attempting to reach an app that never reaches a stable state. Operations like setInterval, rxjs.interval, or other recurring asynchronous tasks inside the Angular zone prevent stability. Similarly, HTTP requests factor into stability; a pending request on the server will postpone the render moment.

A Potential Fix

One approach is to apply the timeout operator from rxjs to cap the request duration:

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()

After a set duration without a server response, this operator triggers an exception.

Yet, this path has 2 drawbacks:

  • Platform-specific logic isn't neatly separated;
  • Each request needs its own manually-added timeout operator.

An alternative is the NgxSsrTimeoutModule found in the @ngx-ssr/timeout package. By importing this module with a specified timeout value into the root module, HTTP request timeouts are enforced. Placing it in AppServerModule confines the timeout effect to server-side requests.

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 {}

Another tactic involves the NgZone service to shift asynchronous operations outside 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
      })
    });
  }
}

For this, consider employing tuiZonefree from the @taiga-ui/cdk package:

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()
  }
}

However, a cautionary note: any task must be cancelled upon application destruction to prevent memory leaks (refer to issue #5). Additionally, tasks moved outside the zone won't activate change detection.

2. No Built-in Caching Mechanism

What You See

A user visits your homepage, which triggers a server request for data, takes 2 seconds to render, and delivers the result. Later, they navigate to a subpage and then attempt to return. They're met with the same 2-second delay as the initial load.

Assuming the underlying data hasn't changed, that HTML was already generated once. Theoretically, we could serve that previously rendered HTML without recalculating it.

A Potential Fix

Caching strategies are our primary tool here. We'll examine two specific types: HTTP caching and in-memory caching.

HTTP cache. Network caching hinges on configuring the server's response headers correctly. These headers define both the cache's lifespan and its policy:

Cache-Control: max-age = 31536000

This method is best suited for unauthenticated areas and when dealing with data that seldom changes.

For a deeper dive into HTTP caching, follow this link.

In-memory cache. This cache type can be applied to rendered pages as well as API calls within the app. Both scenarios are covered by the [@ngx-ssr/cache](https://github.com/IKatsuba/ngx-ssr) package.

To cache API requests, add the NgxSsrCacheModule module to your AppModule, and it will function on both the server and the browser.

The maxSize parameter sets the cache's upper limit. A setting of 50 indicates the cache will hold only the last 50 GET requests.

The maxAge parameter dictates the cache's duration, measured 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 {}

Additionally, you can cache the HTML output directly.

For instance, @ngx-ssr/cache also provides the @ngx-ssr/cache/express submodule. It exports a sole function, withCache, which acts as a wrapper around 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,
    })
  )
);

3. The \`localStorage is not defined\` Server Error

What You See

A developer directly invokes localStorage within a service body to fetch data by key. However, on the server, this execution fails with an error: ReferenceError: localStorage is undefined.

Underlying Cause

When an Angular app runs on the server, the typical browser API isn't present in the global namespace. For example, the global document object you'd find in the browser is missing. To obtain a document reference, one must rely on the DOCUMENT token and dependency injection (DI).

A Potential Fix

Avoid using browser APIs through globals. Angular's DI system exists for this purpose. Through DI, you can substitute or deactivate browser implementations to ensure safety on the server.

This issue can be addressed using the Web API for Angular library.

Here's a sample:

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');
  }
}

This example references the LOCAL_STORAGE token from the @ng-web-apis/common package. Yet, executing this code on the server would reproduce the error mentioned earlier. The remedy is to include UNIVERSAL_LOCAL_STORAGE from @ng-web-apis/universal in the AppServerModule's providers. This will supply a server-appropriate localStorage implementation for the LOCAL_STORAGE token.

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 {}

4. Awkward Platform Logic Separation

What You See

To conditionally render a block in the browser only, the code often resembles this:

@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){}
}

This necessitates the component to retrieve PLATFORM_ID, identify the platform, and then set a public class property. That property is subsequently used in the template with an ngIf directive.

A Potential Fix

Structural directives combined with DI can streamline this process considerably.

First, define a token to encapsulate the server check.

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

Next, create a structural directive using the IS_SERVER_PLATFORM token with a straightforward aim: only render content 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);
    }
  }
}

The directive code closely mirrors IfIsBowser.

Let's refactor the component now:

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

The unnecessary properties are gone, and the template is now more concise.

With such directives, you can declaratively show or hide content based on the platform.

We've consolidated these tokens and directives into the @ngx-ssr/platform package.

5. The Memory Leak Problem

What You See

On initialization, a service sets up an interval and executes certain actions.

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

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

While this code might not disrupt application stability, the callback from subscribe persists even after the app is destroyed on the server. Every app launch on the server leaves behind an interval artifact, potentially causing a memory leak.

A Potential Fix

Our solution leverages the ngOnDestroy hook, which functions for both components and services. The key is to store the subscription and then cancel it upon the service's destruction. While many unsubscription strategies exist, here's 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();
  }
}

6. The Missing Rehydration Solution

What You See

The user's browser displays a server-received page, there's a brief flicker of a white screen, and then the app springs to life, looking normal.

Underlying Cause

Angular doesn't have a mechanism to re-utilize server-rendered output. It removes all existing HTML from the root element and begins a fresh render from scratch.

A Potential Fix

A definitive fix isn't available as of now. However, there's an indication it's on the horizon. The Angular Universal roadmap contains an item: "Full client rehydration strategy that reuses DOM elements/CSS rendered on the server".

7. The Inability to Halt Rendering

The Scenario

When a critical error is caught, continuing to render and wait for stability serves no purpose. The process must be interrupted, and the default index.html should be returned to the client instead.

Root Cause

The rendering of the application, as it happens at the point where the app becomes stable, offers little flexibility. The approach described in problem #1 can be used to make the app stable sooner, but what if the requirement is to stop rendering as soon as the first error is encountered? Or to impose a timeout on the entire rendering attempt?

Potential Workaround

Currently, no workaround for this limitation exists.

Final Thoughts

Angular Universal remains the only officially supported and widely adopted method for server-side rendering of Angular applications. The effort required to integrate it into a pre-existing application largely rests on the developer's shoulders. Nevertheless, certain unresolved defects prevent it from being considered fully production-ready. While it works well for landing pages and static content, complex applications often trigger a host of issues. Fixing these issues can still lead to a noticeable flash of unstyled content, as no rehydration mechanism is available to smooth over the transition.