Angular SSR Caching and Edge Deployment: CDN, Cache-Control and TTFB [2026]

Link copied
Angular SSR Caching and Edge Deployment: CDN, Cache-Control and TTFB [2026]

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 reqHandler and 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:

  1. Don't render what you can cache. Prerender static routes; put s-maxage on public SSR routes.
  2. 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.
  3. Don't wait for non-critical data on the server. Content inside a regular @defer block 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.)
  4. Put the renderer near the data, or the data near the renderer, as discussed above.
  5. Compress responses. Enable Brotli or gzip at the CDN or with compression middleware; server-rendered HTML with inlined transfer state compresses well.
  6. 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.

YouOur SSR product pages have a TTFB of about 900 ms. We’re thinking of moving rendering to an edge runtime to fix it. Is that the right move?
Claude · used 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

View all Angular articles →
Angular

When Angular is launched ?

Link copied Angular When Angular is launched ? February 8, 2024 · 1 min read When was Angular launched? # Angu…

Feb 8, 2024 Read →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *