Mert Özen Further With Every Line
Frontend Web Performance — #08

Angular SSR, Hydration and Performance: Prerendering, Incremental Hydration, Event Replay and the TTFB Cost

Mert Özen Nov 15, 2026 14 min 10 views
Angular SSR, Hydration and Performance: Prerendering, Incremental Hydration, Event Replay and the TTFB Cost

SSR and prerendering put the LCP content into the HTML and lower FCP and LCP compared with CSR, but because SSR renders on every request it adds a slow backend to TTFB; the fix is a short Output Cache. On Bookstore's book page FCP went from 1.28 s to 0.3 s; we also measured a block hydrating late with hydrate on viewport missing the transfer cache, repeating its request and breaking event replay.

Version note: The examples in this post were run with Angular 22.2 (with TypeScript 6.0, the version Angular 22.2 supports, and @angular/ssr 22.2.7), Express 5.3, Node.js 24, web-vitals 6.2.3, .NET 10 (ASP.NET Core 10) and Playwright 1.65. All numbers are lab measurements taken on a single machine against a local build with headless Chromium 158.

Predict First

The book detail page of Bookstore, the online bookstore used as the shared example across this site's series, has been rendered in the browser so far. The HTML the server sends contains no book; the browser downloads and runs the JavaScript, requests the book from the API, and only then discovers the cover. We will serve the same page in three ways: CSR; SSR, which renders it on the server for every request; and SSG, which renders it once at build time and serves it as a static file, called prerendering in Angular. Predict: with the phone profile, which one gets the best LCP? If the API takes 300 ms longer on every request, does the order change?

A Render Mode per Route

With the server routes introduced in Angular 19, the render mode is chosen per route. The app.routes.server.ts file is the server-side counterpart of the browser's route table. The home page has been prerendered since the third post; for the book page there are three options:

import { RenderMode, ServerRoute } from '@angular/ssr';

export const serverRoutes: ServerRoute[] = [
  { path: '', renderMode: RenderMode.Prerender },
  // SSR: every request renders the book page on the server (2,000 books, prices and stock change)
  { path: 'books/:isbn', renderMode: RenderMode.Server },
  { path: '**', renderMode: RenderMode.Client },
];
  {
    path: 'books/:isbn',
    renderMode: RenderMode.Prerender,
    // The 12 books with a cover are rendered at build time; any other ISBN is rendered on request
    async getPrerenderParams() {
      const api = process.env['BOOKSTORE_API'] ?? 'http://127.0.0.1:5300';
      const books: { isbn: string }[] = await fetch(`${api}/api/books?take=12`).then((r) => r.json());
      return books.map((b) => ({ isbn: b.isbn }));
    },
    fallback: PrerenderFallback.Server,
  },

RenderMode.Client sends an empty HTML shell. RenderMode.Server runs the app in Node.js for every request, calls the API from the server and sends the complete HTML. RenderMode.Prerender renders at build time; for a route with parameters, getPrerenderParams says which ISBNs to generate, and fallback: PrerenderFallback.Server falls back to SSR at request time for those not in the list. Generating all 2,000 books on every build means rebuilding whenever prices and stock change, so SSG only makes sense here for the 12 most visited books.

Setup: a Node.js SSR Server Behind ASP.NET Core

For SSR we switched outputMode in angular.json from "static" to "server". We use the Express server from the Angular CLI template with two changes: it listens only on the loopback interface, because Bookstore.Api is the one exposed to the outside:

// Every page request: prerendered HTML, server-side render or the CSR shell, per RenderMode in app.routes.server.ts
app.use((req, res, next) => {
  angularApp
    .handle(req)
    .then((response) => (response ? writeResponseToNodeResponse(response, res) : next()))
    .catch(next);
});

if (isMainModule(import.meta.url) || process.env['pm_id']) {
  const port = Number(process.env['PORT'] || 4000);
  // Only Bookstore.Api talks to this server: listen on the loopback interface
  app.listen(port, '127.0.0.1', (error) => {
    if (error) {
      throw error;
    }
    console.log(`Node Express server listening on http://127.0.0.1:${port}`);
  });
}

export const reqHandler = createNodeRequestHandler(app);
            "server": "src/main.server.ts",
            "outputMode": "server",
            "ssr": {
              "entry": "src/server.ts"
            },
            "security": {
              "allowedHosts": ["127.0.0.1", "localhost"]
            }

security.allowedHosts is required: as a protection against SSRF, Angular SSR compares the request's host with this list and returns 400 for a host that is not on it. In production the site's real domain goes here.

ASP.NET Core stays the single origin: it serves static files, the API and images itself, and forwards page requests to the Node.js server. This way the requests that the browser and SSR make to the API use the same URL, and the HTTP transfer cache, which embeds responses fetched on the server into the HTML for reuse in the browser, matches:

namespace Bookstore.Api.Ssr;

// Pages are rendered by the Angular SSR server (Node.js, loopback only); this API stays the single origin
public static class SsrProxy
{
    public static async Task ForwardAsync(HttpContext context, IHttpClientFactory httpClientFactory)
    {
        if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsHead(context.Request.Method))
        {
            context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed;
            return;
        }

        HttpClient client = httpClientFactory.CreateClient("ssr");
        using HttpRequestMessage request = new(HttpMethod.Get, context.Request.Path + context.Request.QueryString);
        // Angular checks the host against security.allowedHosts (SSRF protection)
        request.Headers.Host = context.Request.Host.Value;
        request.Headers.TryAddWithoutValidation("Accept", context.Request.Headers.Accept.ToString());
        request.Headers.TryAddWithoutValidation("Accept-Language", context.Request.Headers.AcceptLanguage.ToString());
        // No cookies are forwarded: the rendered page is the same for every user and can be cached

        using HttpResponseMessage response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, context.RequestAborted);
        context.Response.StatusCode = (int)response.StatusCode;
        context.Response.ContentType = response.Content.Headers.ContentType?.ToString();
        if (response.Headers.CacheControl is { } cacheControl)
        {
            context.Response.Headers.CacheControl = cacheControl.ToString();
        }
        await response.Content.CopyToAsync(context.Response.Body, context.RequestAborted);
    }
}
// Angular SSR server (Node.js) that renders the pages
builder.Services.AddHttpClient("ssr", client =>
    client.BaseAddress = new Uri(builder.Configuration["Ssr:Origin"] ?? "http://127.0.0.1:5301"));
// Rendered pages are the same for every user (no cookies are forwarded): keep them for a short time
int pageCacheSeconds = builder.Configuration.GetValue("Ssr:CacheSeconds", 60);
builder.Services.AddOutputCache(options =>
    options.AddPolicy("pages", policy => policy.Expire(TimeSpan.FromSeconds(pageCacheSeconds))));
app.UseRouting();
app.UseOutputCache();

app.MapCatalogEndpoints();
app.MapRumEndpoints();
app.MapCoverImageEndpoints();
// Every other page goes to the Angular SSR server: prerendered, server-rendered or the CSR shell, per route
RouteHandlerBuilder pages = app.MapFallback(SsrProxy.ForwardAsync);
if (pageCacheSeconds > 0)
{
    pages.CacheOutput("pages");
}

For the measurements we also added a lab-only delay setting to the API (Lab:ApiDelayMs): it imitates a backend waiting on a slow database or another service.

Measurement: Three Modes, Two Backend Speeds

Phone profile (4x CPU, 150 ms latency, 1.6 Mbps), median of 5 runs, ms:

ModeAPI delayTTFBFCPLCP
CSR015112841880
CSR30015112842200
SSR01513001328
SSR3003114561476
SSR + Output Cache3001513001328
SSG (prerender)01513001324
SSG (prerender)3001513001320

FCP is 1.28 s with CSR and 0.3 s with SSR and SSG. The LCP difference is smaller, because the LCP element is the 1200 px cover, which takes about 1.1 s to download on a 1.6 Mbps link; SSR only moves the moment the cover is discovered earlier (load delay 1321 ms with CSR, 29 ms with SSR). Making the image itself smaller was the topic of the image and font post.

There is a surprise in the CSR rows: the LCP that went up to 2220 ms because of the lazy route in the previous post is 1880 ms here. The book page is still RenderMode.Client, but the HTML shell now comes from Angular's server engine instead of the static index.csr.html, and the engine adds the route's chunk as a modulepreload:

resource                                                  start    end  render-blocking
/books/9781000000009                                          0    164  non-blocking
/chunk-CPg4KbJp.js                                          174    568  non-blocking
/chunk-JOppahxN.js                                          174    382  non-blocking
/main-CVA4GSYC.js                                           174   1033  non-blocking
/chunk-DLdUTTQE.js                                          174   1230  non-blocking
/styles-CYMCJ46A.css                                        177    337  non-blocking
/fonts/inter-latin-400-normal.woff2                         189    771  non-blocking
/fonts/inter-latin-700-normal.woff2                        1282   1591  non-blocking
/api/books/9781000000009                                   1306   1471  non-blocking
/api/books/9781000000009/similar?count=8                   1307   1476  non-blocking
/img/covers/dune?w=1200                                    1482   2227  non-blocking
analytics.thirdparty.test:5302/analytics.js                2229   2857  non-blocking
analytics.thirdparty.test:5302/collect                     2921   3180  non-blocking
FCP 1292 ms, LCP 2244 ms

chunk-JOppahxN.js is the book page's chunk; it was requested at 174 ms, at the same time as main.js. The extra round trip from the previous post is gone.

The first SSG measurement came out worse than SSR, with a TTFB of 312 ms. The cause was ASP.NET Core's UseDefaultFiles: /books/9781000000009 maps to a folder, and by default the middleware redirects to /books/9781000000009/ with a 301; with the phone profile that is one more round trip. With RedirectToAppendTrailingSlash = false the folder's index.html is served directly, and TTFB drops to the 151 ms in the table:

// "/" -> index.html, the home page prerendered at build time; "/books/{isbn}" -> books/{isbn}/index.html
// for prerendered book pages, without a 301 redirect to "/books/{isbn}/" (one round trip less)
app.UseDefaultFiles(new DefaultFilesOptions { RedirectToAppendTrailingSlash = false });

SSR's Cost in TTFB, and Output Cache

When the backend is 300 ms slower, the CSR page's shell still arrives at 151 ms, but the book data arrives late and LCP gets 320 ms worse. With SSR the wait is added to the HTML itself: the server waits for two API responses and then renders, so TTFB and FCP move together. Note a lab detail: measured with curl, SSR's server time with the delay is 311 ms, and with the phone profile TTFB is also 311 ms. In this measurement Chromium's network emulation counted the 150 ms latency within the server time; on a real network the two add up and TTFB would be around 460 ms. When you evaluate SSR in the lab, measure the server time separately.

The most effective way to lower SSR's TTFB cost is to cache the rendered HTML. The book page is the same for every user (SsrProxy forwards no cookies), so we keep it in Output Cache for 60 seconds. In the "SSR + Output Cache" row of the table the first run fills the cache (TTFB 337 ms) and the next four runs are at 151 ms; LCP is the same as SSR without the delay. A small load test of 400 requests with 8 in parallel, with client and server running side by side on the same machine:

http://127.0.0.1:5300/books/9781000000009  400 requests, 8 parallel
4025 req/s  p50 0.8 ms  p95 2.3 ms  p99 40.0 ms
http://127.0.0.1:5300/books/9781000000009  400 requests, 8 parallel
592 req/s  p50 12.6 ms  p95 17.8 ms  p99 33.9 ms
http://127.0.0.1:5301/books/9781000000009  400 requests, 8 parallel
572 req/s  p50 12.6 ms  p95 20.0 ms  p99 30.3 ms

The first line is through ASP.NET with Output Cache on, the second through ASP.NET with no cache, the third straight to the Node.js server. On a single thread Node.js renders 570-590 pages per second (about 1.7 ms of CPU per page); with 8 parallel requests the median request takes 12.6 ms because requests wait in line. From the cache, 4,000 pages per second come out. These are single-machine lab numbers, meaningful as a ratio, not for capacity planning: SSR runs the app on every request and moves the client's work to the server. Do not cache a user-specific page (cart, my orders) this way; Output Cache's default policy only stores GET/HEAD requests with 200 responses and does not store responses that set cookies or responses to authenticated requests. Such pages can stay CSR, or the user-specific part can be loaded on the client. Caching with a CDN is in the upcoming Domain, DNS and CDN series.

Hydration and hydrate on viewport

SSR's HTML is visible but not interactive; events do not work until Angular starts the app in the browser and takes over the existing DOM without rebuilding it. This is called hydration. In Angular 22, provideClientHydration() turns on incremental hydration by default: in the @angular/platform-browser typings withIncrementalHydration() is deprecated with a note that it has been the default since v22, and withNoIncrementalHydration() exists to turn it off. Incremental hydration comes into play when you add a hydrate trigger to a @defer block.

The "Similar books" block from the CLS post was @defer (on viewport). On the server Angular renders only the placeholder of @defer blocks; the block is not in SSR's HTML at all:

$ curl -s http://127.0.0.1:5300/books/9781000000009 | grep -c 'Similar books'
0

With hydrate on viewport added, the server renders the block's content too, while the browser does not download the block's JavaScript until the block becomes visible, and until then the block stays dehydrated: exactly as it came from the server, not attached to Angular:

        <!-- SSR renders the block into the HTML; its JavaScript loads and hydrates when it becomes visible -->
        @defer (on viewport; hydrate on viewport) {
          <app-similar-books [books]="similar.value()" />
        } @placeholder {
          <div></div>
        }
$ curl -s http://127.0.0.1:5300/books/9781000000009 | grep -c 'Similar books'
1

The Transfer Cache Turns Off When the App Is Stable

In the first attempt the block still requested its own data (httpResource, as in the CLS post). The files and API requests when we open the page with the phone profile and scroll:

   659 ms  /chunk-CPg4KbJp.js                            11.6 kB
  1059 ms  /chunk-CzgvOFue.js                             1.5 kB
  1370 ms  /main-LUWISOPX.js                             50.2 kB
  1615 ms  /chunk-DLdUTTQE.js                            86.9 kB
  2314 ms  /analytics.js                                 76.1 kB
  3135 ms  -- load finished, waiting 3 s (browser idle)
  6135 ms  -- scroll 400 px
  6326 ms  /chunk-k-4-XG7_.js                             1.4 kB
  6510 ms  /api/books/9781000000009/similar?count=8       0.5 kB
  7641 ms  -- scroll 400 px
  9144 ms  -- scroll 400 px

There is no request from the browser for the book's data; the transfer cache works. But while the block hydrates on scroll, the /similar request goes to the API once more, although the same data was fetched on the server and embedded in the HTML. The reason is in Angular's source: the HTTP transfer cache only answers until the app becomes stable, and the block that hydrates on scroll runs much later. We also tried the id option that Angular 22 added to resource() (it writes to TransferState under its own key); the value was written into the HTML, but the read in @angular/core depends on the same "is the cache active" check, and the request went out again. The fix is to fetch the data during the first render, in the eager book page, and pass it to the block as an input:

export class BookPage {
  readonly isbn = input.required<string>();
  protected readonly book = httpResource<Book>(() => `/api/books/${this.isbn()}`);
  // Requested together with the book, during the first render: on the server, and in the browser
  // from the HTTP transfer cache
  protected readonly similar = httpResource<Book[]>(() => `/api/books/${this.isbn()}/similar?count=8`);
}
export class SimilarBooks {
  // Loaded by the book page: the block hydrates after the app is stable, when the HTTP transfer cache
  // no longer answers, so a request made here would go to the API a second time
  readonly books = input<Book[] | undefined>();
  // 4 cards at first, the other 4 behind "Show more"
  protected readonly showAll = signal(false);
  protected readonly skeleton = [1, 2, 3, 4];
  protected readonly visible = computed(() => (this.books() ?? []).slice(0, this.showAll() ? 8 : 4));
}
   664 ms  /chunk-CPg4KbJp.js                            11.6 kB
   903 ms  /chunk-JOppahxN.js                             1.5 kB
  1443 ms  /main-CVA4GSYC.js                             50.2 kB
  1601 ms  /chunk-DLdUTTQE.js                            86.9 kB
  2284 ms  /analytics.js                                 76.1 kB
  3106 ms  -- load finished, waiting 3 s (browser idle)
  6106 ms  -- scroll 400 px
  6302 ms  /chunk-DPGMTcbN.js                             1.4 kB
  7608 ms  -- scroll 400 px
  9110 ms  -- scroll 400 px

On scroll only the block's 1.4 KB chunk downloads, and there is no API request.

Event Replay: a Click Before Hydration

What happens if the user clicks the "Show more" button that came from the server before the JavaScript has downloaded? Angular puts a small inline script, the event dispatch contract, into SSR's HTML; the script records clicks and hands them to the right handler once hydration is done. This is event replay. With incremental hydration on, event replay is on too. With the low-end phone profile we tried both versions with a script that clicks the button as soon as it appears in the HTML:

click on "Show more" at 429 ms (main.js downloaded: no)
after 15 s still 4 cards: the click was not replayed; JavaScript files finished at 1408, 1908, 3175, 3537, 4856, 4016 ms
click on "Show more" at 418 ms (main.js downloaded: no)
8 cards visible at 4061 ms; JavaScript files finished at 1459, 1959, 3176, 3552, 4034 ms

In the first version the click was lost. Because the block sent its own request again while hydrating, it was briefly without data, @if removed the button from the DOM, and the target of the recorded click disappeared. In the final version the same click happened at 418 ms, before main.js had even downloaded; when the block hydrated (right after the chunk that finished at 4034 ms) Angular replayed the click and eight cards appeared. During hydration, the server and the browser producing the same DOM from the same data is required not only for performance but also for correctness.

Production Notes

Turning on SSR means running another Node.js process on the server: it needs process management, logging, a memory limit and health checks. Track render time; the 1.7 ms render we measured grows as the page and its API calls grow, and it adds directly to TTFB. Put a timeout on SSR's API calls, otherwise one slow service holds up every page. Do not forget to clear Output Cache during a deploy either: the cached HTML contains the hashed file names of the previous build, and if those files are gone after the new deploy, the page is left without JavaScript. Either empty the cache on deploy (with a tag and EvictByTagAsync) or keep serving the previous build's files for a while.

Choose the render mode by looking at RUM data. In our measurements SSG and cached SSR gave the same result; the difference between them is build time and data freshness. SSR with a short cache for frequently changing content with many pages, SSG for a small number of rarely changing pages, and CSR for user-specific pages is a reasonable start. The search engine side of SSR (rendered HTML, meta tags, canonical) is in posts 08 and 09 of the Technical SEO series, and the security side is in post 07 of the Frontend Security series.

Common Pitfalls

  • Accessing window, document or localStorage directly during SSR. These objects do not exist on the server; the "document is not defined" error from web-vitals during prerendering in the third post was exactly this. Use isPlatformBrowser or afterNextRender.
  • Hydration mismatches. The server and the browser producing a different DOM (a library that changes the DOM by hand, invalid HTML nesting, different data in the browser) leads to the NG0500 family of errors in Angular and to the content being rebuilt.
  • A deferred block requesting its own data. If the transfer cache is already off when the block hydrates, the request goes out twice and the content is briefly empty during hydration.
  • A trailing-slash redirect on prerendered routes with parameters; one round trip on every visit.
  • Turning on SSR for everything and caching user-specific pages, or caching nothing and rendering every request.
  • Evaluating SSR only in the lab with an emulated network. Measure the server time separately; the emulation can overlap latency with server time.

A Small Experiment

★☆☆ Run ssr-bench.mjs with Lab:ApiDelayMs=300, with Output Cache off and on. Why does the number of requests per second drop so much: is the server CPU busy, or waiting?

★★☆ Put the "Details" section of the book page inside @defer (on idle; hydrate never). There is no interaction in this section; does the downloaded JavaScript or the hydration time change? What happens if you later add a button to the hydrate never block?

★★★ Give the book page headers: { 'Cache-Control': 'public, max-age=60' } in app.routes.server.ts, and instead of Output Cache in ASP.NET Core, measure a second visit to the same page with the browser cache. In which case does the browser not go to the server at all, and in which case does it get a 304?

On the book page FCP went from 1.28 s to 0.3 s and LCP from 1.88 s to 1.33 s, and cached SSR stayed the same even with a slow backend. In the next post we look at moving from one page to another: bfcache making the back button instant, preparing the next page in advance with Speculation Rules, Angular Router's preloading, and Brotli levels, precompressed files and zstd.