Source Code and Live Demos

To make the Initial Load Optimization workflow easier to follow, each improvement step is captured in its own commit. The complete source code is available in this Git repository:

You can also inspect the live demo applications here:

For those who want to verify the numbers themselves, the Google PageSpeed Insights tests used for measuring Initial Load Performance are linked below:

The demonstration app is the Flight App, a compact and straightforward Angular application. To produce realistic timings, we intentionally included links to large CSS files (Bootstrap) and a heavy chart library (AnyChart), with the latter being lazy-loaded.

Don't expect any record-breaking performance numbers here—the goal is simply to illustrate the gains from adding SSR, Prerendering, and Client Hydration.

The Baseline: Client Side Rendering

Our starting point is a nginx container serving the Angular application named performance.

# Stage 0, Node.js, install deps & build the app
FROM node:16-alpine as builder

# set working directory
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app

# add app
COPY . /usr/src/app

# install deps and cli
RUN npm install
RUN npm install -g @angular/cli

# add .bin to $PATH
ENV PATH /usr/src/app/node_modules/.bin:$PATH

# build app
RUN ng build

# Stage 1, nginx, copy & serve app
FROM nginx:alpine

COPY --from=builder /usr/src/app/dist/performance/browser /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/conf.d/default.conf

Keep in mind that this Dockerfile assumes Angular Universal is already configured, so the built app sits in a subdirectory called browser.

To activate gzip compression in nginx, the default nginx.conf needs adjustments. Be sure to include every mime-type your application relies on.

server {
    gzip on;
    gzip_types      application/javascript font/woff image/jpg image/png image/webp image/x-icon text/css text/plain;
    gzip_proxied    no-cache no-store private expired auth;
    gzip_min_length 1024;

    server_name localhost;
    listen      80;
    root        /usr/share/nginx/html;
    index       index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }
}

Because the Flight App is served over https, the Flight API must also be accessed through its secure endpoint.

-  url = 'http://www.angular.at/api/flight';
-  // url = 'https://demo.angulararchitects.io/api/Flight';
+  // url = 'http://www.angular.at/api/flight';
+  url = 'https://demo.angulararchitects.io/api/Flight';

Now let's run the first Google PageSpeed Insights evaluation:

Client Side Rendering

The baseline scenario displays FCP at 2.2s and LCP at 3.8s, with no CLS detected.

Step 1 — Adding Server Side Rendering

The first move is to install Angular Universal to bring SSR capabilities into the project:

ng add @nguniversal/express-engine

This command pulls in the necessary dependencies and generates all configuration files required for SSR mode:

Angular Universal Configuration

Next, we switch the hosting setup to a Node.js container.

A Node.js runtime is required because it allows the page to be rendered on the server.

# Stage 0, Node.js, install deps, build & run the app
FROM node:16-alpine as builder

# set working directory
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app

# add app
COPY . /usr/src/app

# install deps and cli
RUN npm install
RUN npm install -g @angular/cli

# add .bin to $PATH
ENV PATH /usr/src/app/node_modules/.bin:$PATH

# build app & server
RUN ng build
RUN ng run performance:server

# build default port
EXPOSE 4000

# start server
CMD ["node", "/usr/src/app/dist/performance/server/main.js"]

To avoid guessing the hashed main.js filename, we disable hashing in the angular.json configuration:

"outputHashing": "none",

For gzip compression, we first install the compression npm package:

npm i compression --save
npm i @types/compression --save-dev

Then, we modify the server.ts file accordingly:

-  server.engine('html', ngExpressEngine({
-    bootstrap: AppServerModule
-  }));
+  server.engine(
+    'html',
+    ngExpressEngine({
+      bootstrap: AppServerModule
+    })
+  );
+
+  // Compress all HTTP responses
+  server.use(compression());

Let's see what changes with SSR:

Server Side Rendering

The server-rendered version improves FCP to 2.0s (-0.2s) and LCP to 2.2s (-1.6s). However, a CLS of 0.165 appears because the client re-renders the page. While the improvement over CSR is clear, the layout shift is a downside. We can do better!

Step 2 — Prerendering Routes

Route prerendering lets the backend pre-generate specific routes and store them in a static cache on the server. Instead of rendering on the fly, these pages are served straight from the cache. If your app is already routed through a CDN like CloudFlare that caches pages globally, this step might be unnecessary.

In your Dockerfile, insert the following line after the build step and before the server starts:

# build app, prerender & run server
RUN ng build
RUN ng run performance:prerender
RUN ng run performance:server

In the angular.json, you define which routes are to be prerendered:

"prerender": {
  "builder": "@nguniversal/builders:prerender",
    "options": {
      "routes": ["/", "/home", "/flight-booking/flight-search", "/flight-booking/charts"]
    },

Alternatively, a text file listing the static pages can also be used:

RUN ng run performance:prerender --routes-file routes.txt

What do the numbers look like with prerendering?

Prerendering of routes

The prerendered SSR version reaches FCP at 1.7s (-0.5s) and LCP at 2.4s (-1.4s). The CLS of 0.165 remains due to client-side re-rendering. Relative to plain SSR, the gain is modest, mainly because the Flight App is simple and server rendering is quick.

Step 3 — Enabling Non-Destructive Hydration

Activating Non-Destructive Hydration in Angular is straightforward: just add the provider to the AppModule. This only works when importing from code>@angular/platform-browser</code at version 16 or higher.

// app.module.ts
@NgModule({
  providers: [
    [...],
    provideClientHydration()
  ],
})
export class AppModule {}

For stand-alone bootstrapping, the provider goes into the ApplicationConfig instead.

// app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [
    [...],
    provideClientHydration(),
  ]
};

Here are the final results:

Non-Destructive Hydration

With SSR combined with Client Hydration, FCP drops to 1.5s (-0.7s) and LCP to 1.5s (-2.3s). There is zero CLS. The performance is exceptional, and we eliminate layout shifts entirely because the existing DOM is reused instead of rebuilt.

Performance Deep Dive Workshop

For those aiming to master Angular Performance, we offer a dedicated workshop available in both English and German:

Wrapping Up

Server Side Rendering stands out as a powerful method for cutting Initial Load times and creating a smoother user journey. Angular V16's Non-Destructive Hydration pairs nicely with SSR by turning static server-rendered HTML into a fully interactive view on the client without a full re-render (and the associated repaint).

Merging the strengths of SSR and Hydration means your Angular apps start faster, feel more responsive, and rank better in search results. The Angular team is also poised to bring even richer Hydration capabilities in upcoming releases.

The walkthrough above shows how little effort it takes to get Angular SSR with Hydration running and how dramatically Initial Load Performance improves. If your Angular app is reachable by users on the web, introducing SSR and Hydration should be a priority.

This post was authored by Alexander Thalhammer. Follow him on Linkedin, X, or giThub.