~8 min de lecture
Angular SSR : 4 erreurs qui font fetcher ton API deux fois (et coûtent 800ms à ton TTI)
Tu as activé le SSR. Tu vois ton terminal logger GET /api/products. Tu ouvres ton DevTools, onglet Network, et là, surprise : GET /api/products à nouveau, côté browser. Bravo, tu viens de doubler ton coût d'API, ta latence et ton bundle d'hydration pour rien.
Le but du SSR, c'est que ton client récupère le HTML avec les données dedans, et que l'hydration consomme ces données plutôt que de relancer la requête. Pourtant, sur 9 projets Angular SSR sur 10 que j'audite, le double-fetch est là, silencieux, qui mange entre 200 et 800 ms de TTI selon la latence backend.
On va voir les 4 erreurs qui causent ça en Angular 17+ (et comment elles se règlent en 5 lignes la plupart du temps).
Le problème en 30 secondes
// products-list.ts
import { ChangeDetectionStrategy, Component, inject, resource } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
interface Product {
id: string;
name: string;
}
@Component({
selector: 'app-products-list',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (products.value(); as items) {
@for (item of items; track item.id) {
<article>{{ item.name }}</article>
}
}
`,
})
export class ProductsList {
private readonly http = inject(HttpClient);
protected readonly products = resource({
loader: () => firstValueFrom(this.http.get<Product[]>('/api/products')),
});
}
Cible : Angular 19+ pour resource(). Sur Angular 17/18, remplace par un signal plus une promesse résolue dans un APP_INITIALIZER ou un resolver de route, on y revient en erreur 3.
Tu fais pnpm run build puis pnpm run start:ssg. Tu charges la page. Ton terminal SSR logge le fetch. Ton client le refetch. Double coût.
Pourquoi ? Parce que tu n'as pas activé withHttpTransferCacheOptions, ou tu l'as activé mais tu as touché à 3 défauts qui font tout sauter. On y va.
Erreur 1 : tu ne provides pas withHttpTransferCacheOptions
provideClientHydration() à lui seul ne cache pas les requêtes HTTP. Tu dois ajouter explicitement withHttpTransferCacheOptions(). Sans ça, le TransferState ne capture que ce que toi ou Angular y mettent manuellement (zéro, dans la plupart des apps).
Before (silently broken)
// app.config.ts
import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
import { provideClientHydration } from '@angular/platform-browser';
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(),
provideHttpClient(),
provideClientHydration(),
],
};
Là, tes GET passent côté serveur, puis repassent côté client. Aucun cache. Pour vérifier : colle un console.log dans un interceptor, tu vas voir deux passages, un en Node, un en browser.
After
import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
import {
provideClientHydration,
withEventReplay,
withHttpTransferCacheOptions,
} from '@angular/platform-browser';
import { provideHttpClient, withFetch } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(),
provideHttpClient(withFetch()),
provideClientHydration(
withEventReplay(),
withHttpTransferCacheOptions({
includePostRequests: false,
includeRequestsWithAuthHeaders: false,
filter: () => true,
}),
),
],
};
Attention au mythe le plus répandu : withFetch() n'est PAS ce qui active le cache de transfert. Le cache est un interceptor HTTP (transferCacheInterceptorFn), agnostique du transport : il lit le TransferState que ton HttpClient tourne sur fetch ou sur XMLHttpRequest. Ce qui débloque le cache, c'est withHttpTransferCacheOptions(), point. withFetch() reste recommandé en SSR (Angular logge même un warning si tu l'oublies, parce que l'implémentation fetch évite un shim XMLHttpRequest côté Node), mais ce n'est pas la cause de ton double-fetch. La cause, c'est l'option de cache absente.
Mesure pratique sur une liste de 12 produits avec un payload de 18 KB et une latence backend de 220 ms : le TTI passe de 320 ms à 90 ms. C'est la requête réseau qui disparait.
Erreur 2 : ton API renvoie un header Cache-Control: no-store
withHttpTransferCacheOptions respecte le header Cache-Control. Si ton backend renvoie no-store, private ou no-cache (sur la requête comme sur la réponse), Angular skip le cache et te re-fetch côté client. C'est conforme à la sémantique HTTP, mais tu perds le bénéfice du SSR.
Premier réflexe tentant : forcer le cache via un filter. Ça ne marche pas. Dans le code d'Angular, le Cache-Control et ton filter sont en ET logique : le filter ne peut qu'EXCLURE des requêtes, jamais réintégrer une réponse que le Cache-Control interdit. Ton filter: () => true ne renverse rien.
// Ne contourne PAS un Cache-Control: no-store. Le filter ne sert qu'à restreindre.
withHttpTransferCacheOptions({
filter: (req) => req.url.startsWith('/api/public/'),
})
Le filter sert donc à l'inverse de ce que tu crois : restreindre le périmètre cachable (sécurité), pas l'élargir. La vraie correction est côté backend : sur tes endpoints publics, renvoie un Cache-Control cachable (public, max-age=...) au lieu de no-store. Tu gardes une seule source de vérité sur ce qui est cachable, et tu n'exposes pas par accident des données privées dans le HTML transféré, qui finit en clair dans la page. Données utilisateur, panier, profil : laisse leur Cache-Control: private, c'est exactement ce qui les protège.
Erreur 3 : tu utilises resource() sans id (et tu crois que ça suffit)
D'abord, tuons un mythe tenace : un subscribe dans ngOnInit n'est PAS jeté en SSR. Depuis Angular 16, HttpClient enregistre une PendingTask pour chaque requête en vol, et le serveur attend la stabilité de l'app (donc la fin de tes requêtes) avant de sérialiser le DOM. Ton HTML SSR contient bien la donnée.
Ce code rend correctement côté serveur
import {
ChangeDetectionStrategy,
Component,
OnInit,
inject,
signal,
} from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'app-products-list',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@for (item of items(); track item.id) {
<article>{{ item.name }}</article>
}
`,
})
export class ProductsList implements OnInit {
private readonly http = inject(HttpClient);
protected readonly items = signal<Product[]>([]);
ngOnInit(): void {
this.http.get<Product[]>('/api/products').subscribe((data) => this.items.set(data));
}
}
Le vrai sujet n'est pas le rendu serveur, c'est comment le CLIENT évite de refetch à l'hydration. Et là, trois patterns re-exécutent leur chargement côté browser, et ne comptent QUE sur le cache HTTP (erreur 1) pour court-circuiter le réseau :
- un
subscribedansngOnInit(lengOnInitre-fire à l'hydration), - un resolver de route (re-joué à la navigation initiale côté client),
resource({ loader })SANSid.
Le piège resource() est le plus sournois, parce que tout le monde croit qu'il sérialise sa donnée automatiquement. Il ne le fait PAS par défaut. Son transfer state n'est actif que si tu lui passes un id stable, identique côté serveur et client.
Sans id : le loader re-tourne côté client
protected readonly products = resource({
loader: () => firstValueFrom(this.http.get<Product[]>('/api/products')),
});
Côté client, ce resource() redémarre en loading, relance le loader, donc rappelle /api/products. Sans withHttpTransferCacheOptions, c'est un vrai round-trip réseau. Double-fetch.
Avec id : le résultat est transféré, le loader est sauté
protected readonly products = resource({
id: 'products-list',
loader: () => firstValueFrom(this.http.get<Product[]>('/api/products')),
});
Avec un id, le serveur sérialise le résultat du resource() dans le TransferState, et côté client le resource() lit cette valeur et passe directement en resolved sans exécuter le loader. Zéro requête, même si ton cache HTTP est mal réglé. C'est le seul pattern qui ne dépend pas de withHttpTransferCacheOptions.
Alternative : route resolver
Si tu préfères passer par le router (utile quand plusieurs composants partagent la donnée d'une route) :
// products.routes.ts
import { Routes } from '@angular/router';
import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
export const productsRoutes: Routes = [
{
path: '',
loadComponent: () => import('./products-list').then((m) => m.ProductsList),
resolve: {
products: () => inject(HttpClient).get<Product[]>('/api/products'),
},
},
];
Le resolver bloque la sérialisation SSR jusqu'à la résolution de l'observable, mais il se re-joue à l'hydration : comme ngOnInit, il a besoin de withHttpTransferCacheOptions pour éviter le round-trip réseau côté client. Sur Angular 17/18, où resource() n'existe pas, c'est le pattern de référence.
Erreur 4 : tu hash mal tes requêtes (POST, query params, Authorization)
Le cache de transfert hash chaque requête sur URL plus method plus body plus paramètres plus certains headers. Trois cas qui te font passer à côté.
Cas A : POST qui renvoie de la data (pseudo-GET)
Certaines API utilisent POST pour les recherches complexes (filtres dans le body, payload trop grand pour une query string). Par défaut, includePostRequests: false. Active-le pour ces endpoints précis :
withHttpTransferCacheOptions({
includePostRequests: true,
filter: (req) =>
req.method === 'GET' || (req.method === 'POST' && req.url.includes('/search')),
})
Active-le globalement et tu vas commencer à cacher des POST de mutation, ce qui est une bombe. Cible strictement.
Cas B : Authorization header
Si ton SSR appelle l'API avec un Bearer token (cookie repassé côté Node, token mTLS, etc.), le client n'a pas ce header au moment du hash. Hash différent côté serveur et client, cache miss, refetch.
Deux options. La rapide :
withHttpTransferCacheOptions({
includeRequestsWithAuthHeaders: true,
})
La propre : un interceptor qui strip l'Authorization côté SSR et passe par un cookie côté gateway. Le hash devient stable, le cache marche à coup sûr, et tu ne fais pas fuiter le token dans le HTML transféré.
Cas C : query param qui bouge à chaque requête
Si tu appends un ?t=${Date.now()} ou un nonce CSRF à chaque appel, ton hash diffère entre serveur et client par construction. Vire le param dynamique du URL et passe-le en header non hashé, ou utilise un interceptor qui le strip avant transfer.
Comment vérifier que ça marche
Un seul test à faire : ouvre le DevTools, onglet Network, recharge la page en hard reload (Ctrl + Shift + R). Tes appels /api/* qui apparaissaient dans la liste ne doivent plus y être. À la place, regarde le HTML source : tu dois voir un <script id="ng-state" type="application/json"> qui contient ton payload sérialisé.
Si tu ne le vois pas, c'est :
withHttpTransferCacheOptions()absent deprovideClientHydration().- Le
Cache-Controlde la réponse (no-store,private,no-cache) qui bloque le stockage. - Un hash de requête instable (Authorization, POST non activé, query param dynamique).
Et pour un resource(), vérifie qu'il a bien un id : sans id, son loader re-tourne côté client et tu dépends entièrement du cache HTTP.
Side effect bonus côté Lighthouse : sur une route lourde en données, tu remontes ton score performance de 8 à 12 points, parce que tu vires un round-trip réseau du chemin critique avant le First Contentful Paint.
Récap actionnable
| Tu vois | Tu corriges |
|---|---|
| Double fetch dans Network | withHttpTransferCacheOptions() (et withFetch() recommandé) |
resource() qui refetch à l'hydration |
ajoute un id stable au resource() |
| Cache miss sur endpoint avec Bearer | includeRequestsWithAuthHeaders: true ou strip côté SSR |
| Cache-Control no-store côté API | renvoie un Cache-Control cachable côté backend (le filter ne l'override pas) |
| POST de recherche refetch | includePostRequests: true plus filter ciblé |
Le réflexe à graver : sur un nouveau projet Angular SSR, tu colles ça dans app.config.ts avant d'écrire ton premier composant.
provideHttpClient(withFetch()),
provideClientHydration(
withEventReplay(),
withHttpTransferCacheOptions({
includePostRequests: false,
includeRequestsWithAuthHeaders: false,
}),
),
Sans ça, ton SSR est joli mais inutile (HTML pré-rendu jeté à l'hydration). Avec ça, tu paies un fetch là où tu en payais deux, ton TTI s'allège d'autant, et ton backend te remercie de diviser sa charge par deux sur les routes SSR.