Angular SSR Caching and Edge Deployment: CDN, Cache-Control and TTFB [2026]
Server-side rendering moves work from your users' devices to your servers, and that work happens on every request unless something stops it. A server-rendered page that takes 300 ms to produce is fine at ten requests a second and a problem at a thousand. The fix is rarely a faster renderer. It's making sure most requests never reach the renderer at all, and that the ones that do are served from somewhere close to the user with the data they need nearby.
This is lesson 9.7 of the Angular Tutorial, and the last lesson of Module 9. Lesson 9.6 removed repeated data fetches between server and browser; this lesson looks at the infrastructure around the renderer. You'll learn how to cache rendered pages and assets with Cache-Control, how to keep personalised content out of shared caches, how to run Angular on edge runtimes with AngularAppEngine, the trade-offs of Node and serverless hosting, and a set of tactics for a lower time to first byte.
Where a response can come from #
| Layer | Example | Speed | Shared between users? |
|---|---|---|---|
| Browser cache | The user's own HTTP cache | Instant | No |
| CDN / shared cache | A CDN edge node near the user | Very fast | Yes |
| Prerendered file | HTML built at deploy time (lesson 9.1) | Fast | Yes |
| Server render | Node or edge function running Angular | Slowest: depends on data | No |
Every request served by an upper layer is one your renderer doesn't handle. A good SSR deployment pushes as much traffic as possible to the top three rows and reserves the bottom row for content that is genuinely request-specific or changing.
Cache-Control in one table #
Cache-Control is the response header that tells browsers and CDNs what they may store and for how long. The directives you'll use most:
| Directive | Meaning |
|---|---|
public |
Any cache, including CDNs, may store it |
private |
Only the user's browser may store it; CDNs must not |
no-store |
Nobody stores it |
max-age=N |
Fresh for N seconds (browsers, and shared caches unless s-maxage is set) |
s-maxage=N |
Fresh for N seconds in shared caches only; overrides max-age there |
stale-while-revalidate=N |
After expiry, a cache may serve the stale copy for N more seconds while it refetches in the background (where supported) |
immutable |
The content at this URL never changes |
A useful pattern for server-rendered public pages is a short browser lifetime and a longer CDN lifetime: public, max-age=0, s-maxage=300, stale-while-revalidate=60. Browsers always check back, the CDN answers for five minutes, and after that users still get a fast stale response while the CDN refreshes it.
Setting cache headers #
Static assets #
The application builder gives JavaScript and CSS files content hashes in their names, so a changed file always has a new URL. Those files can be cached for a year. The CLI's generated server.ts already serves the browser folder with a long maxAge:
app.use(express.static(browserDistFolder, { maxAge: '1y', index: false, redirect: false }));
If a CDN or static host serves your assets instead, configure the same rule there: Cache-Control: public, max-age=31536000, immutable for hashed files. HTML, including the client-side index.html, should not get that rule, because its URL never changes between deploys.
Rendered and prerendered pages #
For per-route defaults, the headers field on server routes is the simplest place:
// app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
path: 'docs/**',
renderMode: RenderMode.Prerender,
headers: { 'Cache-Control': 'public, max-age=0, s-maxage=86400' },
},
{
path: 'products/:id',
renderMode: RenderMode.Server,
headers: { 'Cache-Control': 'public, max-age=0, s-maxage=300, stale-while-revalidate=60' },
},
{
path: 'account/**',
renderMode: RenderMode.Client,
},
{
path: '**',
renderMode: RenderMode.Server,
headers: { 'Cache-Control': 'public, max-age=0, s-maxage=60' },
},
];
When the decision depends on the request rather than the route, for example "never cache publicly if the user is signed in", handle it in server.ts, where you can inspect the request and adjust the web Response before writing it:
app.use((req, res, next) => {
angularApp
.handle(req)
.then((response) => {
if (!response) return next();
if (req.headers.cookie?.includes('session=')) {
response.headers.set('Cache-Control', 'private, no-store');
}
writeResponseToNodeResponse(response, res);
})
.catch(next);
});
Keeping personal data out of shared caches #
A CDN-cached page is served to everyone who requests that URL. If the server rendered the signed-in user's greeting into it, every visitor sees that user's name, and the transfer state (lesson 9.6) may contain their data too. The safest architecture separates the two:
| Content | Rendered where | Cached where |
|---|---|---|
| Public page body (product details, article, prices) | Server or prerender | CDN |
| User-specific bits (name, cart count, recommendations) | Browser, after hydration | Not shared |
| Fully personal pages (account, checkout) | RenderMode.Client |
Browser only (private) |
For the user-specific bits, render a neutral placeholder on the server and load the real data in the browser, for example in a regular @defer block, which renders its @placeholder on the server and fetches its content only in the browser. Vary: Cookie looks like a shortcut, but it effectively gives every user their own cache entry, so the CDN hit rate collapses.
Running on the edge with AngularAppEngine #
The Node-specific AngularNodeAppEngine from lesson 9.2 has a runtime-neutral sibling, AngularAppEngine, that works with standard web Request and Response objects. That's the shape edge and serverless platforms expect:
// server.ts for a fetch-style runtime
import { AngularAppEngine, createRequestHandler } from '@angular/ssr';
const angularApp = new AngularAppEngine(); // once per isolate, not per request
export const reqHandler = createRequestHandler(async (request: Request) => {
const response = await angularApp.handle(request);
return response ?? new Response('Not found', { status: 404 });
});
handle() serves prerendered pages, renders server routes and returns client-rendered pages based on your server routes, just as on Node. Hosting providers wrap reqHandler in their own entry point; check your provider's Angular adapter for the exact wiring.
Node server (AngularNodeAppEngine) |
Edge runtime (AngularAppEngine) |
|
|---|---|---|
| Request type | Node IncomingMessage (Express, Fastify…) |
Web Request |
| Where it runs | A region or a few regions | Many locations close to users |
Node APIs (fs, native modules) |
Available | Usually not available |
| Express middleware | Yes | No; use the platform's own primitives |
| CPU time per request | Generous | Often limited by the platform |
| Distance to your database | Usually close | Often far |
The last row decides most edge deployments. Rendering at the edge saves network distance between the user and the renderer, but if the page makes three sequential API calls back to a database in one region, each call pays that distance instead. Edge rendering wins when the page's data is cached at the edge too, or when it needs little data. Otherwise, a Node server in the same region as your API, with a CDN in front, is often faster.
The allowed-hosts check from lesson 9.2 applies to both engines: pass allowedHosts to the constructor or configure security.allowedHosts in angular.json with your production domains.
Node hosting and cold starts #
On Node, you have two shapes:
- Long-running server (container, VM, platform service):
node dist/my-app/server/server.mjs. Startup cost is paid once. Scale with more instances behind a load balancer. - Serverless functions: the platform imports
reqHandlerand invokes it per request. Idle instances are shut down, so the first request after a quiet period pays a cold start: loading the runtime, the server bundle and the route manifest before rendering begins.
To keep cold starts short:
| Tactic | Why it helps |
|---|---|
| Keep server-side dependencies lean | Less code to load before the first render |
| Create the engine at module scope, once | Initialisation isn't repeated per request |
| Avoid heavy work at import time | Top-level config fetches and large in-memory caches add to every cold start |
| Use minimum instances or provisioned concurrency where available | Keeps at least one warm instance |
| Prerender high-traffic routes | They're served as files and don't need a warm renderer |
Tactics for a lower TTFB #
Time to first byte for a server-rendered page is roughly network time plus render time, and render time is mostly waiting for data. In order of impact:
- Don't render what you can cache. Prerender static routes; put
s-maxageon public SSR routes. - Remove data waterfalls. Resolvers or components that call API B only after API A returns double the wait. Fetch independent data in parallel, or have one endpoint return what the page needs.
- Don't wait for non-critical data on the server. Content inside a regular
@deferblock isn't rendered on the server, so its data isn't fetched there. Use that for below-the-fold, user-specific or slow sections. (Blocks with hydrate triggers, from lesson 9.5, are rendered on the server, so their data counts toward TTFB.) - Put the renderer near the data, or the data near the renderer, as discussed above.
- Compress responses. Enable Brotli or gzip at the CDN or with compression middleware; server-rendered HTML with inlined transfer state compresses well.
- Keep the CDN from changing the HTML. Minification or comment stripping breaks hydration (
NG0507, lesson 9.3). Compression is safe; rewriting is not.
Measure TTFB from real users, not just from your machine, using the field-data approach from lesson 8.6, and check your CDN's cache hit ratio for HTML. A low hit ratio usually means a private header, a Set-Cookie on every response or a Vary header is preventing caching.
Gotchas #
| Symptom | Cause | Fix |
|---|---|---|
| CDN never caches HTML | Response has Set-Cookie, private, or Vary: Cookie |
Remove cookies from public responses; use s-maxage |
| Users see each other's names or carts | Personalised HTML cached publicly | Render user bits in the browser; private, no-store for signed-in responses |
| Old JavaScript after a deploy | index.html cached with a long max-age |
Long caching only for hashed assets; short or no caching for HTML |
| Edge rendering is slower than Node | Every API call travels back to one region | Cache data at the edge, or render in the API's region |
| First request after idle takes seconds | Serverless cold start | Minimum instances, leaner server bundle, prerender hot routes |
400 Bad Request behind the CDN |
CDN's host or forwarded headers not allowed | Add production hosts to allowedHosts; configure proxy header trust |
What's next #
That completes Module 9. You can now choose a render mode per route, bootstrap and debug the server application, hydrate cleanly, keep early interactions, hydrate incrementally, avoid double data fetches and deploy with caching that keeps the renderer's load under control. Module 10 turns to debugging real-world problems, starting with lesson 10.1: a catalogue of the NG0xxx runtime errors you're most likely to meet, what each one means and how to fix it.
Try it yourself #
Run curl -I against three pages of your production site: a static page, a public dynamic page and a signed-in page. Write down the Cache-Control, Vary and Set-Cookie headers for each and compare them with the tables above. Fix the one that's most wrong, then check your CDN's cache hit ratio for HTML a day later.
run_targetProbably not yet. I ran the server build locally and timed a product page: rendering itself takes about 40 ms, and the rest is three sequential API calls made by productResolver, ProductPage and ReviewsSummary, each waiting for the previous one. Moving to the edge would make those calls travel further, not shorter. First, fetch product and reviews in parallel in the resolver; second, move ReviewsSummary into a regular @defer (on viewport) block so the server doesn’t wait for it; third, add headers: { 'Cache-Control': 'public, max-age=0, s-maxage=300, stale-while-revalidate=60' } to the products/:id server route so the CDN answers most requests. Re-measure after those changes; if TTFB from distant regions is still high, the edge becomes worth evaluating.Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


