If you are rendering your SPA using Angular Universal you may have noticed a common issue with the page loading: the content "blinks" and loads again. This behavior may occur due to duplicate requests being made, once in the server, and then in the client. Angular Universal provides a module called TransferHttpCacheModule to tackle this problem by caching requests made while rendering the page on the server-side.

Prerequisites

Before starting, add Angular Universal to an existing Angular project by running the following schematic.

ng add @nguniversal/express-engine

I won't delve further into this topic. For an Angular Universal's beginners guide please refer to Server-side rendering (SSR) with Angular Universal.

Setting up caching

Setting up server-side caching is pretty easy. All you have to do is add TransferHttpCacheModule to your app.module.ts, and ServerTransferStateModule to your app.server.module.ts.

// app.module.ts
import { TransferHttpCacheModule } from '@nguniversal/common';

@NgModule({
  imports: [
    (...)
    TransferHttpCacheModule
  ]
})
export class AppModule {}
// app.server.module.ts
import { ServerTransferStateModule } from '@angular/platform-server';

@NgModule({
  imports: [
    (...)
    ServerTransferStateModule
  ]
})
export class AppServerModule {}

After adding these, you will see that the requests done in the server while the page is being rendered will not be duplicated in the browser.

How it works

TransferHttpCacheModule provides a HTTP interceptor that uses the TransferState from @angular/platform-browser. TransferState is "a key value store that is transferred from the application on the server side to the application on the client side", according to Angular's documentation.

First, when intercepting a request, it checks the cache (key value store) to see if that specific request has been made. This may branch into two different behaviors whether the request has been cached or not.

If the request key is found in the key value store, it will handle the request by returning the cached response.

if (this.transferState.hasKey(storeKey)) {
  // Request found in cache. Respond using it.
  const response = this.transferState.get(storeKey, {} as TransferHttpResponse);

  return observableOf(new HttpResponse<any>({
    body: response.body,
    headers: new HttpHeaders(response.headers),
    status: response.status,
    statusText: response.statusText,
    url: response.url,
  }));
}

If the request key is not found in the cache, it proceeds with the original request and sets a key-value pair to the TransferState cache with the response.

else {
  // Request not found in cache. Make the request and cache it.
  const httpEvent = next.handle(req);

  return httpEvent
    .pipe(
      tap((event: HttpEvent<unknown>) => {
        if (event instanceof HttpResponse) {
          this.transferState.set(storeKey, {
            body: event.body,
            headers: getHeadersMap(event.headers),
            status: event.status,
            statusText: event.statusText,
            url: event.url || '',
          });
        }
      })
    );
}

Handling relative path requests

You may run into issues when using relative paths in requests on the server-side. You can use a HTTP interceptor in this case to transform your relative paths to absolute paths.

import {
  HttpInterceptor,
  HttpRequest,
  HttpHandler,
  HttpEvent
} from '@angular/common/http';
import { Observable } from 'rxjs';
import { Inject, Injectable } from '@angular/core';
import { REQUEST } from '@nguniversal/express-engine/tokens';
import { Request } from 'express';

function isAbsoluteUrl(url: string) {
  return url.startsWith('http') || url.startsWith('//');
}

@Injectable()
export class ServerStateInterceptor implements HttpInterceptor {
  constructor(@Inject(REQUEST) private request: Request) {}

  intercept(
    req: HttpRequest<any>,
    next: HttpHandler
  ): Observable<HttpEvent<any>> {
    if (this.request && !isAbsoluteUrl(req.url)) {
      const protocolHost = `${this.request.protocol}://${this.request.get(
        'host'
      )}`;

      const pathSeparator = !req.url.startsWith('/') ? '/' : '';
      const url = protocolHost + pathSeparator + req.url;
      const serverRequest = req.clone({ url });

      return next.handle(serverRequest);
    }

    return next.handle(req);
  }
}

This interceptor works explicitly with the Express engine, so if you are using another engine you will have to adapt it.

Final thoughts

Angular provides out-of-the-box features to easily tackle the duplicate requests issue, with none to minimal configuration from the developer.

I found some other issues working with Angular Universal and third-party packages, so I will be writing about these in the near future.

Sources: