DEV Community

Nigro Simone
Nigro Simone

Posted on

Angular SSR renders your page for every visitor. Here is what that costs.

Angular SSR renders your page on the server, which is the whole point. It renders it again for the next visitor, and again for the one after that, and it ships nothing to stop that happening.

On a page of mine, a 28 KB Angular 22 document, a render costs 17.2 ms of server time. That is not a disaster. It is also entirely wasted when a hundred people ask for the same page in the same minute and the answer has not changed.

So I wrote the cache. ng-ssr-caching is an ordinary Express middleware that keeps the rendered HTML and hands it back without rendering again:

import { ssrCaching } from 'ng-ssr-caching';

app.use(ssrCaching({ ttl: 60_000 }));

// the Angular handler the schematic wrote, unchanged
app.use((req, res, next) => {
  angularApp
    .handle(req)
    .then((response) => (response ? writeResponseToNodeResponse(response, res) : next()))
    .catch(next);
});
Enter fullscreen mode Exit fullscreen mode

That is the integration. What follows is what I learned building it.

1. The obvious cache measures nothing

The first version stored the bytes and served them with res.send(html). Then I measured it against no cache at all and the serving side came out level. Not faster. Level.

The reason is the ETag. Express computes one by hashing the response body, and it does that on every single response. So a cache that only keeps the bytes still makes your server hash the entire document for every hit. On a page of any size, that hash is most of what a hit costs. You have removed the render and kept the second most expensive thing.

The fix is to settle the ETag and the length once, when the page is stored, and set them from the entry:

// at store time, once
const entry = {
  body,
  etag: weakEtag(body),
  length: body.length,
  // ...
};

// on every hit, no hashing
res.setHeader('etag', entry.etag);
res.setHeader('content-length', String(entry.length));
res.end(entry.body);
Enter fullscreen mode Exit fullscreen mode

Measured, on the same 28 KB page:

render, no cache 17.2 ms
the same page from the cache 1.9 ms

The same entry is also what lets a returning visitor be answered with an empty 304, which is the cheapest response there is.

If you write your own cache instead of using mine, please write this part. It is the difference between a cache that pays for itself and one that just uses memory.

2. A shared cache is a security boundary

Here is the part I would ask you to read twice, because it is the part where a caching mistake stops being a performance problem.

An SSR cache holds one page per URL and hands it to whoever asks next. That makes it a shared cache, and a shared cache has one rule it cannot get wrong: a page rendered for a particular person must not be handed to the next person.

Two things make a request personal, and they are not the same kind of thing.

Authorization is unambiguous. RFC 9111 §3.5 requires a shared cache to leave those requests alone, and it is right: a bearer token sets no cookie, so a signed-in visitor's dashboard is a clean
200 that looks perfectly cacheable. Store it and the next anonymous visitor gets it. My package passes those requests straight through, in both directions, by default.

Cookies you have to name yourself, because a cookie means nothing on its own. An analytics cookie says nothing about who you are. A session cookie says everything. Only your application knows which of yours is which, so nothing can guess it for you:

ssrCaching({ bypassCookies: ['session', /^connect\.sid$/] });
Enter fullscreen mode Exit fullscreen mode

And one more, which surprised me in my own demo. If you mount express-session globally with saveUninitialized: true, every visitor gets a session cookie on their first page request, which makes every subsequent page request personal, which means your SSR cache will never fill. That is the correct reading, not a bug. An application that wants its SSR cached must not hand a session to a visitor who has not asked for one. In the demo the session middleware is mounted on /api only, with the cookie scoped to match.

And the thing that makes all of this matter more than it looks

Angular embeds the TransferState in the page it renders. The API responses your application fetched during the render, is serialized into a <script> inside your HTML.

Which means an SSR cache is not caching a template. It is caching the data that was in it. Your ttl is the staleness you are willing to serve on your API responses, not on your markup. Pick it from the data.

A smaller thing: your CSP and your cache disagree

Angular's hydration writes two inline scripts into the document, the event dispatch contract and its bootstrap call, and Angular 22 offers no way to turn event replay off. Under a strict script-src 'self' the browser blocks both.

The proper answer is a nonce. A nonce has to be different on every request. A cached page carries the same HTML to everyone, so the nonce inside it is fixed, so the header must carry that same fixed nonce forever, which is no better than 'unsafe-inline' and considerably harder to read.

I do not have a clever solution to that. I have a comment in the source explaining the trade, which is at least honest, and I would be glad to be shown a better one.

What it costs to run

The demo is a real Angular 22 application, server-side rendered, behind helmet, compression, cors, express-session and morgan, with a WebSocket chat on the same server. Measured on the container it deploys as:

first render, including two external API calls 808 ms
the same page from the cache 0.72 ms

The render is the same JavaScript whatever server you run it on, so that part of the win is yours on plain Express today. What the server underneath changes is how cheap the hit itself is. Nine alternating rounds, each server reporting its own process.cpuUsage():

CPU per request
cache hit on Express 262 µs
cache hit on Fulmine 175 µs 1.50x
a static asset on Express 411 µs
a static asset on Fulmine 131 µs 3.14x

Fulmine is a drop-in Express 5 replacement running on µWebSockets.js instead of node:http, and it is the same one line this package is. It is not required: ng-ssr-caching has no dependencies and is tested against Express and Fulmine side by side, every case run twice, because "compatible" should be a test rather than a sentence in a readme.

Links

If you find a case where the cache serves something it should not, open an issue. That is the class of bug I most want to hear about.

Top comments (0)