Angular TransferState and the HTTP Transfer Cache: Deep Dive [2026]

Link copied
Angular TransferState and the HTTP Transfer Cache: Deep Dive [2026]

Angular TransferState and the HTTP Transfer Cache: Deep Dive [2026]

Here's a pattern that shows up in almost every new SSR app. The server renders a product page, which means it calls /api/products/42 and puts the result in the HTML. The browser receives that HTML, boots Angular, the component's constructor runs again, and it calls /api/products/42 again. The data is the same, but the user may see a loading state flash over content that was already there, your API serves twice the traffic, and the page takes longer to settle. Angular has a built-in answer for HttpClient, plus a lower-level API for everything else.

This is lesson 9.6 of the Angular Tutorial. Lesson 9.5 reduced how much code runs at hydration; this lesson removes repeated data fetching. It builds on HttpClient and httpResource from Module 6. You'll learn how the HTTP transfer cache works and what it caches by default, how to tune it globally and per request, how to transfer your own values with TransferState and makeStateKey, and how resource() and httpResource() behave with SSR.

How state crosses from server to browser #

Mechanism What it transfers Effort Use for
HTTP transfer cache Responses to HttpClient requests made during server rendering None: on by default with hydration Almost all API data
resource() with id The resolved value of a resource One option Data loaded with fetch or an SDK through resource()
TransferState Any JSON-serializable value under a typed key Manual set and get Values that aren't HTTP responses: computed data, timestamps, SDK results

All three use the same channel. During rendering, Angular collects the values in a key-value store called TransferState. When the HTML is serialized, the store is written into the page as a JSON script element. In the browser, Angular reads it back before your components run.

The HTTP transfer cache #

The transfer cache is part of provideClientHydration(), so if you followed lesson 9.3 it's already on. While rendering on the server, HttpClient records the requests it makes and their responses. In the browser, during the initial render, HttpClient checks the cache before sending a request and returns the cached response if there is one. Once the application becomes stable in the browser, HttpClient stops using the cache, and every later request goes to the network as usual. The cache exists to bridge hydration, not to replace your caching strategy.

By default, a request is cached only if all of these are true:

Rule Default
Method GET or HEAD only
Auth headers Not cached if the request has Authorization, Proxy-Authorization or Cookie headers
Credentials Not cached if sent with withCredentials (or Fetch credentials modes)
Cache-Control Not cached if the request or response says no-store, no-cache or private, or the Fetch cache option is no-store or no-cache
Response headers None are stored. response.headers in the browser is empty for cached responses unless configured

These defaults are conservative on purpose. The serialized cache is part of the HTML, and if that HTML is cached by a CDN (lesson 9.7) and served to other users, anything user-specific inside it leaks. That's why authenticated and credentialed requests are excluded.

Nothing in your component changes. This works for HttpClient calls in services, resolvers and httpResource():

@Component({
  selector: 'app-product-page',
  template: `
    @if (product.hasValue()) {
      <h1>{{ product.value().name }}</h1>
      <p>{{ product.value().price | currency }}</p>
    } @else if (product.isLoading()) {
      <app-spinner />
    }
  `,
})
export class ProductPage {
  id = input.required<string>();                      // bound from the route
  product = httpResource<Product>(() => `/api/products/${this.id()}`);
}

On the server, the request runs and the HTML contains the product. In the browser, httpResource makes the same GET through HttpClient, finds the response in the transfer cache, and resolves immediately. No spinner, no second request in the network panel.

Tuning the transfer cache #

Use withHttpTransferCacheOptions() to change the defaults for the whole app:

// app.config.ts
import {
  provideClientHydration,
  withHttpTransferCacheOptions,
} from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(withFetch()),
    provideClientHydration(
      withHttpTransferCacheOptions({
        includeHeaders: ['ETag'],                          // keep this header on cached responses
        includePostRequests: true,                         // e.g. a GraphQL endpoint that only reads
        filter: (req) => !req.url.includes('/api/cart'),   // never cache the cart
      }),
    ),
  ],
};
Option Default Effect
filter (none) Function that returns false to exclude a request
includeHeaders No headers Response headers to store and restore with cached responses
includePostRequests false Also cache POST requests. Only for endpoints that don't change data
includeRequestsWithAuthHeaders false Cache requests with Authorization, Proxy-Authorization or Cookie headers
includeRequestsWithCredentials false Cache requests sent with credentials
includeNonCacheableRequests false Ignore Cache-Control directives that forbid caching

Treat the last three with care. Turning them on is safe only if the server-rendered HTML is never shared between users: no CDN or page cache in front of those routes.

Per-request control #

HttpClient accepts a transferCache option on individual requests, which overrides the global settings:

// opt one request out
this.http.get<Balance>('/api/account/balance', { transferCache: false });

// opt in with headers for one request
this.http.get<Page>('/api/pages/home', { transferCache: { includeHeaders: ['Last-Modified'] } });

Turning it off, and different origins #

To disable the HTTP transfer cache entirely, use provideClientHydration(withNoHttpTransferCache()). You rarely want this; prefer filter for the few requests that shouldn't be cached.

A common deployment detail breaks cache hits silently: the server calls your API on an internal address (http://api.internal:8080) while the browser calls the public one (https://api.example.com). The URLs differ, so the browser never finds the cached entry. Map the origins with HTTP_TRANSFER_CACHE_ORIGIN_MAP, provided only in the server config:

// app.config.server.ts
import { HTTP_TRANSFER_CACHE_ORIGIN_MAP } from '@angular/common/http';

const serverConfig: ApplicationConfig = {
  providers: [
    provideServerRendering(withRoutes(serverRoutes)),
    {
      provide: HTTP_TRANSFER_CACHE_ORIGIN_MAP,
      useValue: { 'http://api.internal:8080': 'https://api.example.com' },
    },
  ],
};

resource() and httpResource() with SSR #

The two resource APIs reach the transfer state differently:

API How it avoids a double fetch What you do
httpResource() Uses HttpClient, so the HTTP transfer cache applies Nothing; the defaults and options above apply
resource() with a custom loader Not covered by the HTTP cache (the loader may use fetch, an SDK, anything) Give it an id

When you set an id on a resource(), Angular stores its resolved value in TransferState on the server and initializes the resource in the 'resolved' state in the browser, without running the loader for that initial value:

@Component({ /* ... */ })
export class StoreHours {
  private cms = inject(CmsClient);
  storeId = input.required<string>();

  hours = resource({
    params: () => ({ id: this.storeId() }),
    loader: ({ params }) => this.cms.getStoreHours(params.id),   // SDK call, not HttpClient
    id: 'store-hours',
  });
}

The id must be unique in the application and identical on the server and in the browser. And the same warning as above applies: the value is serialized into the HTML, so don't set an id on resources that load user-specific data if the page can be cached or shared.

Manual TransferState for everything else #

For values that don't come from an HTTP call or a resource, use TransferState directly. Create a typed key with makeStateKey, write the value on the server, and read it in the browser:

import { Injectable, PLATFORM_ID, TransferState, inject, makeStateKey } from '@angular/core';
import { isPlatformServer } from '@angular/common';

const RENDERED_AT = makeStateKey<string>('rendered-at');

@Injectable({ providedIn: 'root' })
export class RenderClock {
  private state = inject(TransferState);
  private isServer = isPlatformServer(inject(PLATFORM_ID));

  /** The same timestamp on server and browser, so the template hydrates cleanly. */
  renderedAt(): string {
    if (this.isServer) {
      const now = new Date().toISOString();
      this.state.set(RENDERED_AT, now);
      return now;
    }
    return this.state.get(RENDERED_AT, new Date().toISOString());
  }
}

This also solves the non-deterministic value problem from lesson 9.3: the browser renders the server's timestamp instead of computing a different one.

The TransferState API is small:

Member Purpose
get(key, defaultValue) Read a value, or the default if the key is missing
set(key, value) Store a value (on the server, to be serialized)
hasKey(key) Check whether a key exists
remove(key) Delete a key, for example after reading it once
onSerialize(key, callback) Compute a value lazily, at serialization time
isEmpty Whether the store has any entries

Values go through JSON. Primitives, arrays and plain objects survive; Date objects become strings, and Map, Set and class instances lose their type. Store plain data and rebuild richer types after reading.

Keeping the payload small #

Every cached response and every TransferState entry is inlined into the HTML. A page that renders five items from a 2 MB API response ships all 2 MB as JSON in the page, which slows down both download and parsing. Fetch only what the page needs (pagination, field selection on the API), use filter to keep large or irrelevant responses out of the cache, and check the size of the transfer-state script in the page source of your heaviest routes.

Gotchas #

Symptom Cause Fix
Same request fires on server and in browser Request has an Authorization header or withCredentials (excluded by default) Accept it for personal data, or opt in only if HTML is never shared
Cache hits never happen in production Server and browser use different API origins Provide HTTP_TRANSFER_CACHE_ORIGIN_MAP in the server config
Cached response has no headers in the browser Headers aren't stored by default includeHeaders globally or per request
resource() loader runs twice Custom loader isn't covered by the HTTP cache Add a unique id to the resource
One user's data appears in another's page Per-user responses transferred into HTML that a CDN cached Exclude those requests; don't cache personalised HTML publicly
HTML is several megabytes Large API responses inlined as transfer state Request less data; filter big responses out

What's next #

Lesson 9.7 closes Module 9 with deployment: caching rendered pages at the CDN with Cache-Control, running Angular on edge runtimes with AngularAppEngine, Node hosting and cold starts, and practical tactics for a lower time to first byte.

Try it yourself #

Open a server-rendered page of your app with the network panel filtered to Fetch/XHR and "Preserve log" on. Every API request you see during the initial load is one the transfer cache didn't serve. For each, work out why from the rules table, then fix one with an origin map, a filter change or a resource id.

YouOur SSR pages still call /api/catalog twice: once on the server and again in the browser. We have provideClientHydration(). The server calls the API through http://catalog-svc:3000 and the browser uses https://shop.example.com/api. Why isn’t the transfer cache working?
Claude · used search_documentationThe cache is keyed by request URL, and your two URLs have different origins, so the browser never finds the server’s entry. Provide HTTP_TRANSFER_CACHE_ORIGIN_MAP in app.config.server.ts only, mapping the server origin to the browser origin: { 'http://catalog-svc:3000': 'https://shop.example.com' }. If the paths also differ (/catalog on the internal service versus /api/catalog publicly), the origin map alone won’t help; expose the same path on the internal service, or route server-side calls through the same /api prefix, so only the origin differs. Also check the request doesn’t carry a Cookie or Authorization header, because those requests are excluded from the cache by default.

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.

1 comment

1 thought on “Angular TransferState and the HTTP Transfer Cache: Deep Dive [2026]”

  1. Pingback: Why Your Angular HTTP Call Fires Twice [2026]

Leave a Comment

Your email stays private. Required fields are marked *

1 thought on “Angular TransferState and the HTTP Transfer Cache: Deep Dive [2026]”

  1. Pingback: Why Your Angular HTTP Call Fires Twice [2026]

Leave a Comment

Your email stays private. Required fields are marked *