Parte II · Angular

7. Rendering: CSR, SSR, hidratación y rendimiento

Entre el instante en que el usuario pulsa Intro y el instante en que ve algo útil ocurren decenas de pasos: resolución de nombres, apretón TCP, negociación TLS, parseo de HTML, construcción del CSSOM, descarga y compilación de JavaScript, arranque del framework, peticiones de datos y, por fin, pintado. Cada uno es un lugar donde perder cientos de milisegundos. Este capítulo explica el proceso completo con precisión, compara las estrategias de renderizado (CSR, SSR, SSG, hidratación incremental) y da un método reproducible para medir, diagnosticar y optimizar una aplicación Angular real.

CORE ANGULAR Tiempo de lectura: ~110 min Prerrequisitos: capítulos 2 a 6

7.1 Qué vas a poder hacer al terminar

Una advertencia sobre versiones, válida para todo el capítulo El andamiaje de SSR de Angular ha cambiado varias veces: @nguniversal/express-engine primero, @angular/ssr con CommonEngine después, y más adelante @angular/ssr/node con AngularNodeAppEngine y un fichero de rutas de servidor. Los conceptos (renderizar en el servidor, serializar, hidratar, transferir estado) son estables y son lo que este capítulo enseña. Los nombres exactos de clases, funciones y ficheros dependen de tu versión: la fuente de verdad es el server.ts que genera ng add @angular/ssr en tu proyecto y la documentación de angular.dev de la versión que indique ng version. No copies literalmente APIs de tutoriales antiguos.

7.2 Qué ocurre entre la petición del navegador y el primer píxel

Optimizar sin entender esta secuencia es como ajustar un motor sin saber en qué orden entran el aire y el combustible: puedes acertar por casualidad, pero no puedes razonar.

7.2.1 Fase de red: DNS, TCP, TLS y la primera respuesta

Analogía El viaje de una petición se parece a pedir un plato en un restaurante lleno. DNS es encontrar la dirección; TCP y TLS son entrar, esperar mesa y comprobar la reserva; el TTFB es lo que tarda la cocina en sacar el primer plato. Puedes tener la mejor cocina del mundo y servir tarde si el camino hasta la mesa es largo: un CDN no acelera la cocina, acerca el restaurante al comensal.

7.2.2 Del HTML a los píxeles: DOM, CSSOM, layout, paint y composición

  1. Construcción del DOM. El parser convierte bytes en un árbol de nodos. Es incremental, pero cualquier <script> síncrono lo detiene: hay que descargarlo, compilarlo y ejecutarlo antes de seguir, porque podría llamar a document.write.
  2. Construcción del CSSOM. Las hojas de estilo se convierten en un árbol de reglas. El CSS bloquea el renderizado: el navegador no pinta hasta tener el CSSOM completo, porque hacerlo antes provocaría un destello sin estilos.
  3. Árbol de render, layout y paint. El árbol de render combina DOM y CSSOM con solo lo que se muestra (display: none no entra; visibility: hidden sí, porque ocupa espacio). El layout calcula posición y tamaño de cada caja: es la fase más costosa y la que se dispara al insertar contenido sin reservar espacio. El paint rasteriza texto, colores, sombras, bordes e imágenes.
  4. Composición. Las capas se combinan, si es posible en la GPU. Animar transform y opacity se resuelve solo aquí y por eso es barato; animar width, top o margin obliga a repetir layout y paint en cada fotograma, y solo hay 16,6 ms por fotograma antes de que el usuario perciba tirones.

7.2.3 JavaScript y el hilo principal

El navegador tiene un hilo principal que comparte el parseo de HTML, el cálculo de estilos, el layout, el paint y la ejecución de todo tu JavaScript. No hay paralelismo: mientras tu código corre, el navegador no puede responder a un clic ni pintar un fotograma. Y el coste de un fichero JavaScript no es solo su descarga, son cuatro costes sucesivos:

CosteDe qué dependeCómo se reduce
DescargaBytes comprimidos y latenciaMenos código, brotli, CDN, HTTP/2 o 3
Parseo y compilaciónBytes sin comprimirMenos código; la compresión no ayuda aquí
EjecuciónTrabajo real del arranque y del frameworkDiferir lo no crítico, hacer menos al arrancar
MemoriaEstructuras retenidas, componentes vivosLiberar suscripciones, no cachear sin límite

De ahí la importancia de los atributos de carga: un <script> sin atributos bloquea el parser; async ejecuta en cuanto está listo, en un momento impredecible (solo para analítica y similares); defer —implícito en type="module", que es lo que usa Angular— descarga en paralelo y ejecuta al terminar el parseo, en orden.

7.2.4 La línea temporal de carga, en un diagrama

  NAVEGADOR                              RED                          SERVIDOR
     │  1) DNS   resolución del nombre            ~20-150 ms              │
     │  2) TCP   apretón de tres vías             1 RTT                   │
     │  3) TLS   certificado + cifrado            1-2 RTT (0 con reuso)   │
     │  4) GET /   ───────────────────────────────────────────────────►   │  (proceso: BD, SSR)
     │  5) ◄──────────────── primeros bytes del HTML ──────────────────   │
     ▼
  ═══╬═══════════════════════════════════════════════════════════════════════════►  tiempo
     ├──────── TTFB ────────┤
     │                      ├─ parseo HTML (streaming) ─┬─ CSSOM ─┬─ árbol de render ─┬─ layout ─┬─ paint
     │                      │                           │         │                   │          │
     │                      │   un <script> síncrono    │ el CSS  │                   │          ▼
     │                      │   DETIENE el parser       │ bloquea │                   │        ★ FCP
     │                      │                           │ el pin- │                   │   (primer texto
     │                      │                           │  tado   │                   │    o imagen)
     │                      └─ descarga JS ─ parse/compile ─ ejecución ─ bootstrap de Angular
     │                                                                    ├─ render del componente raíz
     │                                                                    ├─ HTTP de datos (aquí se decide
     │                                                                    │   a menudo el LCP real)
     │                                                                    ▼
     │                                                                  ★ LCP  (elemento visible mayor)
     ├─ tareas largas del hilo principal (>50 ms) ⇒ TBT alto ⇒ la página "no responde"
     └─ primera interacción del usuario ─────────────────────► ★ INP (latencia percibida de respuesta)

7.2.5 Las métricas que importan: Core Web Vitals

Una métrica solo es útil si mide lo que el usuario percibe. Google formalizó ese conjunto con el nombre de Core Web Vitals; son además señal de posicionamiento, aunque una señal menor que la relevancia del contenido.

MétricaQué mide exactamenteBuenoMejorableMalo
TTFB
Time To First Byte
Desde el inicio de la navegación hasta el primer byte. Incluye red, servidor y SSR. No es Core Web Vital, pero es el techo de todo lo demás.< 0,8 s0,8 – 1,8 s> 1,8 s
FCP
First Contentful Paint
Cuándo se pinta el primer contenido: texto, imagen o SVG. Responde a «¿está pasando algo?». < 1,8 s1,8 – 3,0 s> 3,0 s
LCP
Largest Contentful Paint
Cuándo se pinta el elemento de contenido mayor del área visible, normalmente la imagen principal o el bloque de texto del encabezado. Responde a «¿ya puedo leer lo que vine a leer?».< 2,5 s2,5 – 4,0 s> 4,0 s
INP
Interaction to Next Paint
Latencia de las interacciones durante toda la visita: del evento al siguiente fotograma pintado. Sustituyó a FID en 2024 porque FID solo medía el retardo de la primera interacción y era demasiado indulgente. < 200 ms200 – 500 ms> 500 ms
CLS
Cumulative Layout Shift
Suma de los desplazamientos inesperados de contenido ya visible: la sensación de que «la página se mueve mientras la leo». Es adimensional.< 0,10,1 – 0,25> 0,25
TBT
Total Blocking Time
Suma del exceso sobre 50 ms de todas las tareas largas entre FCP y el momento en que la página queda quieta. Es la métrica de laboratorio que mejor predice un INP malo.< 200 ms200 – 600 ms> 600 ms
Laboratorio frente a campo: la trampa número uno

Lighthouse en tu portátil es una medición de laboratorio: dispositivo y red simulados, una sola carga, sin extensiones ni usuario real. Sirve para comparar dos versiones de tu código en igualdad de condiciones y detectar regresiones en CI.

Lo que de verdad tienen tus usuarios es campo (RUM): percentil 75 de visitas reales, móviles de gama media, redes irregulares y bloqueadores. INP y CLS apenas se pueden medir en laboratorio porque dependen de que alguien interactúe y haga scroll. Optimiza mirando el campo; verifica en laboratorio.

7.3 Estrategias de renderizado comparadas

«Renderizar» es convertir datos y plantillas en HTML. La única pregunta relevante es dónde y cuándo se hace ese trabajo: en el navegador del usuario, en tu servidor al recibir la petición, o en tu pipeline de compilación mucho antes.

CriterioCSRSSRSSG / prerenderISR (concepto)Hidratación incremental
TTFBMuy bueno (estático)Peor: depende del render y de la BDEl mejor posible (CDN) Muy bueno con acierto de cachéIgual que la estrategia base
FCP / LCPMalos: hay que descargar y ejecutar JS antes de ver algoBuenos: el HTML ya trae contenidoExcelentesExcelentesIguales o mejores que SSR
SEOFrágil: depende de que el rastreador ejecute JS y de su presupuestoBuenoBueno BuenoBueno
Coste de servidorCasi nuloAlto: CPU y memoria por peticiónCasi nuloBajo o medioIgual que la base
ComplejidadBajaAlta: dos entornos, hidratación, estado, sesionesMedia: hay que enumerar rutasAlta: invalidación de cachéMedia-alta
Interactividad (TBT/INP)Tardía pero de una vezRiesgo del «valle inquietante»: se ve pero no respondeMismo riesgo que SSRIgual que SSGLa mejor: se hidrata lo justo
Datos personalizadosNaturalNatural, con cuidado con las cachésNo: el HTML es igual para todosLimitadoSegún la base
Casos típicosIntranets, paneles tras login, editoresE-commerce, noticias, marketplaces Blogs, documentación, landingsCatálogos grandes de ritmo moderadoPáginas públicas con mucha zona no visible
El «valle inquietante» del SSR (uncanny valley) El SSR mejora las métricas de pintado, pero introduce un problema nuevo: durante un intervalo el usuario ve una página aparentemente completa que no responde a los clics, porque el JavaScript aún no se ha descargado ni ejecutado. Es peor que un esqueleto de carga honesto, porque invita a interactuar y no cumple. Dos mitigaciones: reducir el JavaScript inicial (lo que atacan la hidratación incremental y el troceado del bundle) y reproducir los eventos ocurridos antes de la hidratación, capacidad que Angular ofrece en versiones recientes como opción de provideClientHydration(); comprueba en tu versión cómo se llama y si es estable.

7.3.1 Árbol de decisión

                        ¿El contenido es PÚBLICO y el SEO importa?
                     ┌──────── NO ──────┴────── SÍ ────────┐
                     ▼                                      ▼
        ¿Todo el mundo entra tras login?        ¿El contenido cambia por usuario
        (intranet, panel, back-office)           o en cada petición?
                     │                          ┌──── NO ───┴──── SÍ ────┐
                     ▼                          ▼                         ▼
              ┌─────────────┐          ¿Cuántas páginas son?      ┌──────────────┐
              │     CSR     │          y ¿son enumerables?        │     SSR      │
              │  + lazy     │            │              │         │ + hidratación│
              │    loading  │            ▼              ▼         │ + TransferSt.│
              └──────┬──────┘      ┌───────────┐  ┌───────────┐   └──────┬───────┘
                     │             │   SSG /   │  │ SSG parcial│         │
              ¿El arranque sigue   │ prerender │  │ + SSR para │         │
              siendo lento?        │   (CDN)   │  │  el resto  │         │
                     ▼             └─────┬─────┘  └─────┬──────┘         │
              Divide por rutas           └──────────────┴────────────────┤
              y usa @defer                                              ▼
                                    ¿Sigue habiendo TBT alto o mucha zona no visible?
                                                     ▼
                                       Hidratación incremental:  @defer (hydrate on viewport)

  REGLA DE ORO: empieza por lo más simple que cumpla los requisitos. El SSR es una decisión de
  arquitectura con coste operativo permanente, no un interruptor que se activa "por si acaso".

7.3.2 Cinco decisiones concretas

Intranet de gestión

Todo el uso es tras autenticación, no hay rastreadores y los usuarios pasan horas dentro. La primera carga puede costar dos segundos si la navegación posterior es instantánea. CSR con carga diferida agresiva por rutas. Añadir SSR aquí es pagar servidores y complejidad para nada.

Ficha de producto de e-commerce

Tráfico de buscadores, conversión sensible al LCP, precio y stock cambiantes. SSR con caché corta por URL, TransferState para no repetir peticiones, imágenes con prioridad y datos estructurados. El caso canónico donde el SSR se paga solo.

Blog o documentación

Contenido que cambia al publicar e idéntico para todos. SSG / prerender en CDN: TTFB de decenas de milisegundos, coste ridículo y nada que pueda caerse un domingo por la noche.

Landing de campaña

Una página, tráfico de pago, cada décima cuesta dinero. Prerender puro, CSS crítico en línea, casi nada de JavaScript y la imagen principal con priority. Si necesita un formulario interactivo, difiérelo con @defer.

Aplicación híbrida (lo habitual en producción)

Las estrategias no son excluyentes y se eligen por ruta: portada y fichas prerenderizadas, listados con filtros por SSR, y el área privada tras login en CSR puro. Las versiones recientes de Angular formalizan esto con una configuración de modo de render por ruta; verifica el nombre en la documentación de tu versión.

7.4 CSR en Angular: cómo arranca realmente la aplicación

Sin SSR, el servidor entrega un index.html cuyo cuerpo es esencialmente <app-root></app-root> y unas referencias a scripts. Todo lo demás lo hace el navegador, y de forma estrictamente secuencial:

  1. Descarga de main.js, polyfills.js y las hojas de estilo.
  2. Parseo y compilación del JavaScript. Su coste es proporcional a los bytes sin comprimir, no a los transferidos: comprimir mejora la descarga, no la compilación.
  3. Ejecución de bootstrapApplication() y creación del inyector raíz con todos los providers de la aplicación.
  4. El router resuelve la URL, ejecuta guards y resolvers y, si la ruta es diferida, hace otro viaje de red completo para descargar su chunk antes de poder continuar.
  5. Instanciación del componente y primera detección de cambios, que crea el DOM: aquí llega el FCP.
  6. Y solo entonces salen las peticiones HTTP de datos, cuya respuesta desencadena el pintado del contenido real, que es el que suele contar como LCP.

Conviene fijarse en que la cadena tiene dos viajes de red en serie que no se solapan: primero el JavaScript, después los datos. Todo lo que hagas para acortar el primero (menos bytes, menos chunks en el camino crítico) adelanta también el segundo.

7.4.1 Los tres problemas del CSR puro

7.4.2 Qué es realmente «el bundle» y cómo se divide

El bundle no es un fichero: es el resultado de que el empaquetador (hoy esbuild en el builder moderno; antes webpack) recorra el grafo de importaciones desde los puntos de entrada y agrupe el código en chunks.

PiezaQué contieneCuándo se descarga
main-HASH.jsArranque, componente raíz y todo lo importado estáticamente desde ellos, más las partes del framework que usesSiempre, en el arranque
polyfills-HASH.jsZone.js (salvo en modo zoneless) y polyfills según el browserslist Siempre
styles-HASH.cssEstilos globalesSiempre, y bloquea el pintado
chunk-HASH.jsRutas diferidas, bloques @defer y cualquier import() dinámicoBajo demanda
El «initial bundle» es la única cifra que hay que vigilar a diario Sumar todo el JavaScript del proyecto no dice nada útil: una aplicación de 8 MB con 300 kB iniciales arranca más rápido que una de 2 MB monolíticos. Lo que importa es el peso inicial: lo que hay que descargar, compilar y ejecutar antes de pintar la primera ruta. Como orden de magnitud para una aplicación de negocio: por debajo de 200 kB comprimidos es excelente, hasta 350 kB aceptable, y por encima de 500 kB hay que justificar cada kilobyte. Son objetivos de ingeniería, no límites del framework.

7.5 SSR en Angular: renderizar en el servidor

7.5.1 El concepto, que es lo que no cambia

Angular puede ejecutarse fuera del navegador porque su capa de renderizado está abstraída: el framework no llama a document.createElement, sino a un renderer. En el servidor, ese renderer escribe sobre una implementación de DOM en memoria. El paquete @angular/ssr aporta esa infraestructura y la integración con un servidor Node, habitualmente Express. El ciclo de una petición es siempre el mismo, con independencia de la versión:

  ┌──────────┐                                                        ┌──────────────────┐
  │ NAVEGADOR│                                                        │ SERVIDOR (Node)  │
  └────┬─────┘   GET /productos/42                                    └────────┬─────────┘
       ├──────────────────────────────────────────────────────────────────────►│ 1. el handler recibe la URL
       │                                                                       │ 2. crea una instancia NUEVA
       │                                                                       │    de la app y un inyector
       │                                                                       │    NUEVO
       │                                                                       │ 3. el Router navega a la ruta
       │                                                                       │ 4. resolvers y componentes
       │                                                                       │    lanzan sus peticiones
       │                                                                       │ 5. ESPERA a que la app esté
       │                                                                       │    "estable" (sin tareas
       │                                                                       │    pendientes)
       │                                                                       │ 6. serializa el DOM a HTML
       │  ◄─── HTML COMPLETO + estado transferido + referencia a main.js ───────│ 7. incrusta el estado en un
       │                                                                       │    script de tipo JSON
       ▼                                                                       │ 8. destruye la instancia
   9. pinta el HTML recibido  ★ FCP y LCP muy tempranos
  10. descarga y ejecuta el bundle
  11. bootstrap del cliente con HIDRATACIÓN: reutiliza el DOM existente en lugar de recrearlo
  12. lee el estado transferido: NO repite las peticiones del paso 4
  13. la aplicación queda interactiva  ★ el "valle inquietante" termina aquí

7.5.2 El andamiaje: lo que sí depende de la versión

Lee esto antes de copiar cualquier server.ts de internet

Cronología aproximada, para que sepas reconocer qué estás leyendo: Angular Universal (paquete @nguniversal/express-engine, con ServerModule y ngExpressEngine), hoy descatalogado; el paquete @angular/ssr integrado en el CLI con ng add @angular/ssr, donde un servidor Express llama a un motor de render pasándole URL, documento y providers; y el andamiaje más reciente, con un motor específico para Node que recibe un objeto Request y devuelve una Response, más un fichero de rutas de servidor donde se declara el modo de render de cada ruta.

Cómo comprobar qué tienes tú, en 30 segundos: ejecuta ng version, mira la versión de @angular/ssr en package.json y, sobre todo, lee el server.ts que generó tu propio CLI: es la documentación definitiva de tu proyecto. Contrasta después con angular.dev en tu versión. Lo que sigue son esquemas conceptuales comentados, no APIs para copiar y pegar.

En el andamiaje intermedio, el server.ts creaba un motor de render explícito y lo invocaba a mano desde el handler de Express:

server.ts · ESQUEMA con motor de render explícito (generación intermedia)
const app = express();
const engine = new CommonEngine();

// Los estáticos se sirven directamente, ANTES del handler de render.
app.get('*.*', express.static(browserDistFolder, { maxAge: '1y' }));

app.get('**', (req, res, next) => {
  const { protocol, originalUrl, baseUrl, headers } = req;
  engine.render({
      bootstrap,                       // arranque de la app en modo servidor
      documentFilePath: indexHtml,     // plantilla HTML de partida
      url: `${protocol}://${headers.host}${originalUrl}`,
      publicPath: browserDistFolder,
      providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }],
    })
    .then((html) => res.send(html))
    .catch(next);   // nunca te comas el error: un 500 silencioso es peor que uno registrado
});

El andamiaje reciente sustituye ese trabajo manual por un motor específico para Node que habla el lenguaje estándar de la web, Request y Response:

server.ts · ESQUEMA del andamiaje moderno basado en Request/Response
import {
  AngularNodeAppEngine, createNodeRequestHandler, isMainModule, writeResponseToNodeResponse,
} from '@angular/ssr/node';

const app = express();
const angularApp = new AngularNodeAppEngine();
app.use(express.static(browserDistFolder, { maxAge: '1y', index: false }));

app.use('/**', (req, res, next) => {
  angularApp.handle(req)
    // Si el motor devuelve null, la ruta no la gestiona Angular (por ejemplo una
    // API): se delega en el siguiente middleware.
    .then((response) => (response ? writeResponseToNodeResponse(response, res) : next()))
    .catch(next);
});

// Permite ejecutarlo directamente en desarrollo y a la vez exportarlo como
// handler para plataformas serverless.
if (isMainModule(import.meta.url)) app.listen(Number(process.env['PORT'] ?? 4000));
export const reqHandler = createNodeRequestHandler(app);
app.routes.server.ts · ESQUEMA de modo de render por ruta
// Capacidad de versiones recientes. El nombre del proveedor ha variado
// (provideServerRouting(...) frente a provideServerRendering(withRoutes(...))):
// consulta la documentación de TU versión.
import { RenderMode, ServerRoute } from '@angular/ssr';

export const serverRoutes: ServerRoute[] = [
  { path: '', renderMode: RenderMode.Prerender },              // portada fija: HTML de compilación
  { path: 'productos/:id', renderMode: RenderMode.Server },    // catálogo cambiante: render por petición
  { path: 'admin/**', renderMode: RenderMode.Client },         // área privada: nunca en servidor
];

7.5.3 Qué APIs del navegador NO existen en el servidor

Node no es un navegador: no hay ventana, ni pantalla, ni usuario. Si tu código toca una de estas APIs durante la construcción o la primera detección de cambios, el render lanzará ReferenceError: window is not defined y la petición acabará en un 500.

APIEstado en el servidorQué hacer
windowNo existeComprobar la plataforma o mover el código a afterNextRender
document globalNo existe como global, pero hay un documento emuladoInyectar el token DOCUMENT, nunca usar el global
localStorage, sessionStorageNo existenAbstraer en un servicio con implementación nula en servidor; si el servidor necesita el dato, usar cookies
navigator, matchMediaNo existenDetección por User-Agent en servidor, o decisión aplazada al cliente
IntersectionObserver, ResizeObserver, requestAnimationFrameNo existen o no aplicanRegistrarlos solo dentro de afterNextRender
Medidas del DOM (getBoundingClientRect)Devuelven ceros: no hay layoutToda lógica basada en medidas es exclusiva del cliente
URLs relativas en HttpClientFallan: no hay origenURL absoluta en el servidor, desde la configuración o desde la petición entrante
tema.service.tsINCORRECTO
@Injectable({ providedIn: 'root' })
export class TemaService {
  // Se evalúa al construir el servicio, también en el
  // servidor: ReferenceError → 500 en TODA la aplicación.
  readonly modo = signal(localStorage.getItem('tema') ?? 'claro');

  // Mismo problema: el error salta al leer innerWidth.
  readonly esMovil = signal(window.innerWidth < 768);

  aplicar(modo: string) {
    document.body.classList.toggle('oscuro', modo === 'oscuro');
    localStorage.setItem('tema', modo);
  }
}
tema.service.tsCORRECTO
@Injectable({ providedIn: 'root' })
export class TemaService {
  private readonly doc = inject(DOCUMENT);   // emulado en servidor
  private readonly esNavegador = isPlatformBrowser(inject(PLATFORM_ID));

  // En servidor se parte de un valor determinista. Si el tema debe
  // verse bien ya en el primer pintado, guárdalo en una COOKIE:
  // el servidor sí puede leerla de la petición entrante.
  readonly modo = signal(
    this.esNavegador ? localStorage.getItem('tema') ?? 'claro' : 'claro',
  );

  aplicar(modo: string) {
    this.doc.body.classList.toggle('oscuro', modo === 'oscuro');
    if (this.esNavegador) localStorage.setItem('tema', modo);
  }
}

7.5.4 afterNextRender, afterRender y el patrón correcto

isPlatformBrowser funciona, pero es un condicional que hay que repetir y que no dice cuándo se ejecuta el código. Angular ofrece funciones de ciclo de vida que resuelven el problema de raíz: solo se ejecutan en el navegador y solo después de que el DOM esté renderizado. afterNextRender(fn) se ejecuta una vez tras el siguiente renderizado, y es el lugar natural para inicializar una librería de terceros que necesita un elemento del DOM, medir, registrar observadores o leer localStorage. afterRender(fn) se ejecuta tras cada renderizado y es fácil convertirla en un bucle de trabajo constante: úsala con prudencia. Ambas aceptan fases (lectura temprana, escritura, lectura) para agrupar el trabajo y evitar el layout thrashing, ese leer-escribir-leer-escribir que fuerza al navegador a recalcular el layout una y otra vez. Los nombres de las fases y las firmas se han refinado entre versiones; consulta la documentación de la tuya.

El patrón que resulta de todo esto es muy reconocible: en el constructor, un afterNextRender(() => ...) que dentro hace un import() dinámico de la librería pesada y la inicializa sobre el elemento obtenido con viewChild.required(). Así se cumplen tres cosas a la vez: el código no se ejecuta en el servidor, el DOM ya existe cuando se ejecuta, y la librería no entra en el bundle inicial.

7.5.5 Por qué setTimeout y setInterval pueden colgar la respuesta

Antes de serializar el HTML, el servidor tiene que decidir cuándo ha terminado la aplicación. Angular lo hace esperando a que quede estable: sin peticiones HTTP pendientes y sin tareas asíncronas registradas. Ese mecanismo es justo lo que hace útil al SSR —gracias a él el HTML ya contiene los datos del resolver— pero tiene una consecuencia directa.

Un temporizador pendiente retrasa o rompe el render Un setInterval nunca termina, así que la aplicación nunca alcanza la estabilidad. Un setTimeout(fn, 5000) hace esperar cinco segundos al servidor antes de responder. Angular avisa cuando la aplicación sigue inestable tras unos segundos (busca el error de «aplicación inestable» en la lista de errores de tu versión) y acaba serializando o fallando. Síntomas reconocibles: el TTFB de producción se clava en un número redondo (5 s, 10 s) o la respuesta llega vacía tras un tiempo largo. Y no siempre es tu código: puede ser una librería con polling interno.
reloj.component.tsINCORRECTO
export class RelojComponent implements OnInit, OnDestroy {
  hora = signal('');
  private id?: ReturnType<typeof setInterval>;

  ngOnInit() {
    // En el servidor la app NUNCA queda estable:
    // el render se bloquea hasta el tiempo límite.
    this.id = setInterval(
      () => this.hora.set(new Date().toLocaleTimeString()), 1000);
  }

  ngOnDestroy() { clearInterval(this.id); }
}
reloj.component.tsCORRECTO
export class RelojComponent {
  hora = signal('');
  private readonly destroyRef = inject(DestroyRef);

  constructor() {
    // Solo en el navegador, y ya con el DOM renderizado.
    afterNextRender(() => {
      const id = setInterval(
        () => this.hora.set(new Date().toLocaleTimeString()), 1000);
      // Limpieza explícita: sin esto el intervalo sobrevive
      // al componente (fuga de memoria clásica).
      this.destroyRef.onDestroy(() => clearInterval(id));
    });
  }
}
Fuga de memoria en SSR: el estado global compartido entre peticiones El bundle de servidor se carga una vez por proceso y se reutiliza en todas las peticiones. Los servicios providedIn: 'root' son seguros porque hay un inyector nuevo por petición, pero cualquier variable declarada en el ámbito de módulo es compartida por todos los usuarios. Las consecuencias van del error grave al agujero de seguridad: un caché a nivel de módulo que crece hasta agotar la memoria del contenedor, o —mucho peor— un let usuarioActual de módulo que hace que el usuario B vea los datos del usuario A. Regla: en el código de servidor, ningún estado mutable fuera del inyector.

7.6 Hidratación

7.6.1 Qué es y qué problema resuelve

Hidratación es el proceso por el cual la aplicación que arranca en el navegador adopta el DOM que ya envió el servidor: asocia cada componente con los nodos existentes, restaura su estado interno y engancha los escuchadores de eventos.

Sin hidratación (el comportamiento histórico de Angular Universal, llamado destructive hydration) ocurría algo mucho peor de lo que parece: el cliente borraba todo el contenido de <app-root> y lo reconstruía desde cero. El usuario veía la página, la página desaparecía y volvía a aparecer: parpadeo característico, CLS terrible, peticiones repetidas y todo el trabajo del servidor a la basura. Con hidratación, el DOM se reutiliza, no hay parpadeo y el CLS de ese tramo es cero.

app.config.ts · activar hidratación
export const appConfig: ApplicationConfig = {
  providers: [
    // withFetch() es recomendable con SSR: usa la API fetch en lugar de
    // XMLHttpRequest y encaja mejor con el entorno del servidor.
    provideHttpClient(withFetch()),
    // Activa la hidratación. Trae además la caché de transferencia de
    // HttpClient activada por defecto (ver 7.7). Las versiones recientes admiten
    // capacidades opcionales como argumentos (reproducción de eventos previos a la
    // hidratación, hidratación incremental, ajuste de la caché de transferencia):
    // los nombres exactos y su estado —estable o vista previa— dependen de la
    // versión, así que compruébalos antes de usarlos.
    provideClientHydration(),
  ],
};

7.6.2 Qué rompe la hidratación

La hidratación se apoya en una premisa: el DOM que el cliente espera encontrar debe coincidir con el que envió el servidor. Todo lo que rompa esa coincidencia produce un error. Las causas, por frecuencia:

  1. Manipulación directa del DOM. Un querySelector(...).appendChild(...), un innerHTML a mano, o una librería de terceros (carrusel, editor de texto rico, mapa) que reordena nodos dentro de una zona gestionada por Angular.
  2. HTML inválido que el navegador «corrige». La causa más difícil de ver, porque el culpable es el navegador: si escribes un <div> dentro de un <p>, una <table> sin <tbody> o anidas <a> dentro de <a>, el parser reestructura el árbol según la especificación. El servidor serializó una cosa y el navegador construyó otra.
  3. Contenido no determinista. Math.random(), Date.now(), crypto.randomUUID() o cualquier valor dependiente del entorno: el servidor pinta «14:32:07» y el cliente calcula «14:32:09».
  4. Estructura condicional por plataforma. Un @if (esNavegador) alrededor de un bloque de plantilla es, por definición, una diferencia entre servidor y cliente.
  5. Extensiones del navegador. Traductores automáticos, bloqueadores y gestores de contraseñas modifican el DOM antes de que tu JavaScript se ejecute. Es la causa de una fracción irreductible de los errores de hidratación en producción que no podrás reproducir en tu equipo. Angular es tolerante en muchos de estos casos, pero conviene saberlo antes de perder un día.
tarjeta.component.tsINCORRECTO
@Component({
  selector: 'app-tarjeta',
  template: `
    <p>
      <!-- HTML inválido: el navegador CIERRA el p
           antes del div, así que servidor y cliente
           construyen árboles distintos. -->
      <div class="cuerpo">{{ texto }}</div>
    </p>
    <!-- No determinista: dos valores distintos -->
    <span>{{ idAleatorio }}</span>
    <time>{{ ahora }}</time>
  `,
})
export class TarjetaComponent {
  texto = 'Hola';
  idAleatorio = Math.random().toString(36).slice(2);
  ahora = new Date().toLocaleTimeString();
}
tarjeta.component.tsCORRECTO
@Component({
  selector: 'app-tarjeta',
  template: `
    <!-- HTML válido: div contenedor, p con texto -->
    <div class="cuerpo"><p>{{ texto }}</p></div>
    <!-- Determinista: el id viene de los datos -->
    <span>{{ id }}</span>
    <!-- La hora se rellena solo en cliente DESPUÉS
         de hidratar: el servidor serializa cadena
         vacía y no hay desajuste estructural. -->
    <time>{{ hora() }}</time>
  `,
})
export class TarjetaComponent {
  texto = 'Hola';
  readonly id = inject(DATOS).id;
  readonly hora = signal('');
  constructor() {
    afterNextRender(() => this.hora.set(new Date().toLocaleTimeString()));
  }
}

7.6.3 Diagnosticar un «hydration node mismatch»

Cuando algo no coincide, Angular emite en la consola un error de la familia NG05xx (nodo que no coincide, nodo ausente, hermanos ausentes, ausencia de información serializada...) con el componente afectado y una representación del nodo esperado y del encontrado. Método de diagnóstico, en este orden:

  1. Lee el nombre del componente del error: reduce el problema a un fichero.
  2. Compara el HTML de verdad. No mires el inspector de elementos, que muestra el DOM ya modificado: usa «ver código fuente» o curl -s https://mi-app.com/ruta > salida.html para obtener el HTML tal cual salió del servidor. Con ese fichero puedes comprobar de paso si el contenido está realmente ahí o solo aparece tras hidratar, y si se está transfiriendo el estado (busca la etiqueta script de tipo JSON).
  3. Valida ese HTML. Un validador detecta en segundos el <div> dentro del <p> que llevas dos horas buscando. Si está bien formado, busca no determinismo: Math.random, fechas, window, condicionales de plataforma.
  4. Prueba en incógnito sin extensiones. Si el error desaparece, el culpable era una extensión. Y si necesitas confirmar qué componente falla, aísla con ngSkipHydration: si al marcarlo el error desaparece, has encontrado al responsable, y ahora toca arreglarlo de verdad y quitar el atributo.

7.6.4 ngSkipHydration: la vía de escape, con condiciones

El atributo ngSkipHydration le dice a Angular: «no intentes hidratar este subárbol; bórralo y recréalo en el cliente». Es una renuncia local a la hidratación, y su caso legítimo es un componente que envuelve una librería que manipula el DOM por su cuenta (mapas, editores WYSIWYG, carruseles), porque ese DOM no lo controla Angular. Se aplica como atributo sobre el componente (<app-mapa-leaflet ngSkipHydration />) o sobre un elemento nativo que lo envuelva.

Qué pierdes al usarlo Ese subárbol vuelve al comportamiento destructivo: se borra y se reconstruye, con su parpadeo, su CLS y su posible repetición de peticiones. Y si lo pones en el componente raíz has desactivado la hidratación de toda la aplicación: estarías pagando el coste del SSR sin obtener su beneficio principal. Úsalo con el ámbito más pequeño posible, con un comentario que explique por qué, y como decisión consciente, nunca para silenciar un error que no te apetece investigar.

7.6.5 Hidratación incremental con @defer

La hidratación clásica es «todo o nada»: al arrancar se hidrata el árbol completo, incluidos el pie de página, los bloques que el usuario no ha visto y el formulario de comentarios que quizá no toque nunca. Ese trabajo es precisamente el que infla el TBT. La hidratación incremental combina el bloque @defer (capítulo 5) con la hidratación: el servidor renderiza el contenido del bloque —está en el HTML, se ve y es indexable— pero su JavaScript no se descarga ni se ejecuta hasta que se cumple el desencadenante.

producto.component.html · hidratación incremental
<app-ficha-producto [producto]="producto()" />   <!-- crítico: se hidrata al arrancar -->

<!-- El servidor renderiza estos bloques (SEO y LCP intactos), pero su JavaScript
     espera a que el usuario se acerque o interactúe. -->
@defer (hydrate on viewport) {
  <app-opiniones [productoId]="producto().id" />
}
@defer (hydrate on interaction) {
  <app-calculadora-envio />
} @placeholder {
  <button type="button">Calcular gastos de envío</button>
}

<!-- "hydrate never": se renderiza en el servidor y NUNCA recibe JavaScript.
     Ideal para un pie de página puramente estático. -->
@defer (hydrate never) { <app-pie-de-pagina /> }
Verifica la versión antes de apoyarte en esto

La hidratación incremental es una capacidad reciente: apareció primero como vista previa para desarrolladores y requiere activarla explícitamente en provideClientHydration() además de usar la sintaxis hydrate on ... dentro de @defer. El conjunto de desencadenantes admitidos (viewport, interaction, hover, immediate, timer, never, when) y el nombre de la función de configuración pueden variar. Antes de diseñar tu arquitectura alrededor de esto: comprueba tu versión, lee su documentación y verifica el resultado en el panel de red (los chunks no deben descargarse hasta que se dispare el desencadenante).

Diferencia clave frente al @defer normal: sin hydrate, el bloque muestra el @placeholder hasta cargarse y en SSR su contenido no llega al HTML. Con hydrate on ..., el contenido real sí se renderiza en el servidor. Son dos herramientas distintas: una ahorra HTML, la otra ahorra JavaScript.

7.7 TransferState: no pedir dos veces lo mismo

Sin ninguna medida adicional, un SSR hace el trabajo dos veces: el servidor pide /api/productos/42, renderiza y serializa; el cliente hidrata y vuelve a pedir exactamente lo mismo. El resultado es el doble de carga en el backend y en la base de datos, un parpadeo si los datos cambiaron entre ambas peticiones, y un LCP que se retrasa hasta que llega la segunda respuesta. La solución es el estado transferido: el servidor guarda lo que obtuvo, Angular lo serializa dentro del HTML en una etiqueta <script type="application/json">, y el cliente lo lee en lugar de volver a pedirlo.

7.7.1 La forma automática: la caché de transferencia de HttpClient

Esto es lo primero que hay que saber, porque ahorra escribir código: al activar provideClientHydration(), Angular activa por defecto una caché de transferencia para HttpClient. Las peticiones GET y HEAD hechas durante el render del servidor se guardan automáticamente y el cliente las sirve desde esa caché en lugar de ir a la red, sin tocar los servicios.

app.config.ts · ajustar la caché de transferencia
provideClientHydration(
  withHttpTransferCacheOptions({
    // Excluye del HTML todo lo que no deba viajar al navegador. La firma exacta de
    // 'filter' y el conjunto de opciones dependen de la versión: compruébalas.
    filter: (req) => !req.url.includes('/api/privado/'),

    // Por defecto NO se transfieren las peticiones con cabecera de autorización,
    // precisamente para no filtrar datos por usuario.
    includeRequestsWithAuthHeaders: false,

    // Las POST tampoco por defecto; activarlo solo tiene sentido para
    // "consultas" implementadas como POST.
    includePostRequests: false,
  }),
),
// Alternativa: desactivarla del todo si tus datos son siempre personalizados.
// provideClientHydration(withNoHttpTransferCache()),
Lo que NUNCA debe transferirse

El estado transferido es texto plano dentro del HTML: cualquiera que haga «ver código fuente» lo lee, y además queda cacheado en proxies y CDNs si el HTML se cachea. Nunca transfieras tokens de sesión, credenciales, claves de API (ni las «públicas» que en realidad no lo son), datos personales de otros usuarios, resultados de consultas administrativas ni nada obtenido con una cabecera de autorización.

Corolario operativo: si una página se renderiza en servidor con datos específicos del usuario, su HTML no se puede cachear en la CDN. Un fallo de Cache-Control ahí no es un problema de rendimiento, es una filtración de datos de un usuario a otro. Esa es la razón principal para dejar el área privada en CSR.

7.7.2 La forma manual: TransferState explícito

La caché automática cubre HttpClient. Cuando el dato no viene de una petición HTTP —lo lee el servidor de una cookie, de una cabecera, de una variable de entorno o de un cálculo caro— hay que transferirlo a mano.

configuracion.service.ts · ejemplo completo
// Clave TIPADA: si cambias la interfaz, el compilador avisa en los dos lados.
const CLAVE: StateKey<ConfigPublica> = makeStateKey<ConfigPublica>('config.publica');

@Injectable({ providedIn: 'root' })
export class ConfiguracionService {
  private readonly estado = inject(TransferState);
  private readonly esServidor = isPlatformServer(inject(PLATFORM_ID));

  async cargar(): Promise<ConfigPublica> {
    if (this.estado.hasKey(CLAVE)) {                 // CLIENTE: ya lo trajo el servidor
      const cacheado = this.estado.get(CLAVE, null as never);
      // Consumir y BORRAR: si no lo eliminas, las navegaciones posteriores dentro de
      // la SPA seguirán viendo los datos del primer render, que pueden tener horas.
      this.estado.remove(CLAVE);
      return cacheado;
    }
    // SERVIDOR: calcular de verdad y dejarlo en el HTML. Aquí podrías leer cabeceras
    // de la petición entrante (país detectado por la CDN, idioma preferido) inyectando
    // el token con el que tu andamiaje expone la Request.
    const config = await this.calcularConfig();
    if (this.esServidor) this.estado.set(CLAVE, config);   // solo el servidor escribe
    return config;
  }
}
Tres errores frecuentes con TransferState Transferir demasiado: cada byte del estado es un byte del HTML que bloquea el pintado; transfiere lo que necesita la primera vista, no el catálogo entero. No borrar la clave tras leerla: cualquier recarga lógica del componente seguirá viendo los datos del primer render, que pueden tener horas si el HTML está cacheado. Escribir desde el cliente: no falla, pero no sirve para nada, porque la serialización ya ocurrió.

7.8 Prerender y SSG

El prerenderizado es SSR ejecutado una sola vez, en el pipeline de compilación. El resultado es un árbol de ficheros HTML que se sube a cualquier CDN. Es la estrategia con mejor relación entre rendimiento y coste operativo, y también la más limitada: el HTML es idéntico para todos y solo cambia cuando vuelves a compilar y desplegar.

Las rutas sin parámetros son triviales: se descubren desde la configuración del router. El problema son las paramétricas: para prerenderizar /blog/:slug hay que enumerar los valores. Angular lo resuelve pidiéndote una función que devuelva la lista de parámetros y que se ejecuta en tiempo de compilación.

app.routes.server.ts · enumerar parámetros para prerender
export const serverRoutes: ServerRoute[] = [
  { path: '', renderMode: RenderMode.Prerender },
  {
    path: 'blog/:slug',
    renderMode: RenderMode.Prerender,
    // Se ejecuta en TIEMPO DE COMPILACIÓN. Si la API no está disponible durante el
    // build, la compilación falla: es un acoplamiento real entre tu pipeline de
    // despliegue y tu backend. Tenlo en cuenta.
    async getPrerenderParams() {
      const posts: Array<{ slug: string }> =
        await fetch('https://api.mi-app.com/posts?campos=slug').then((r) => r.json());
      return posts.map((p) => ({ slug: p.slug }));   // un objeto = una página
    },
  },
  // Catálogo de 500.000 fichas: prerenderizarlas todas no es viable por tiempo de
  // build ni por tamaño del artefacto. Mezcla estrategias.
  { path: 'productos/:id', renderMode: RenderMode.Server },
  { path: '**', renderMode: RenderMode.Prerender },   // página de error: estática
];
Cómo se activa en tu versión Históricamente el prerenderizado se lanzaba con una opción del comando de build (--prerender) o con un fichero de texto con la lista de rutas; en versiones recientes se declara con el modo de render por ruta más una opción de salida que indica si el artefacto es estático o necesita un servidor Node. Consulta la sección de prerenderizado de tu versión y comprueba el resultado mirando el árbol de dist/: si ves un fichero HTML por ruta, funciona.

Cuándo es la mejor opción. : documentación, blogs, landings, páginas legales, catálogos de pocos miles de fichas estables, cualquier contenido igual para todos. No: precios que cambian cada minuto, stock, contenido personalizado, resultados de búsqueda, nada tras un login. Punto intermedio muy rentable: prerenderizar el armazón de la página (encabezado, textos, imágenes, datos estructurados) y cargar por HTTP tras la hidratación solo la parte volátil, como el precio o el stock. El LCP y el SEO se benefician del estático y el dato fresco llega un instante después.

Despliegue en CDN. El artefacto se sube tal cual y solo hay que acertar con dos familias de cabeceras. Los ficheros con hash en el nombre (JavaScript, CSS, imágenes procesadas) cambian de nombre cuando cambia su contenido, así que se sirven con Cache-Control: public, max-age=31536000, immutable. Los documentos HTML no son inmutables, porque su nombre no cambia al desplegar: van con public, max-age=0, must-revalidate, o con s-maxage más stale-while-revalidate si prefieres servir del borde mientras la CDN revalida por detrás.

7.9 Optimización del bundle

7.9.1 Medir antes de tocar

terminal · analizar el bundle
# 1) Build de producción con estadísticas: sin esto no hay nada que analizar.
ng build --configuration production --stats-json
# 2) Visualizar (builder basado en esbuild, el actual):
npx esbuild-visualizer --metadata dist/mi-app/stats.json --open
# 3) Atribuir cada byte a su fichero fuente a partir de los source maps:
ng build --configuration production --source-map
npx source-map-explorer dist/mi-app/browser/main-*.js
# 4) Proyectos que siguen con webpack: npx webpack-bundle-analyzer dist/mi-app/stats.json
# 5) Peso REAL en la red (comprimido), que es el que ve el usuario:
find dist/mi-app/browser -name '*.js' -exec sh -c \
  'printf "%8d  %s\n" $(brotli -c "$1" | wc -c) "$1"' _ {} \; | sort -rn

Qué buscar en el mapa de tamaños, por orden de rentabilidad: una dependencia enorme que no esperabas; la misma librería duplicada en dos chunks; código del área de administración dentro del chunk inicial (síntoma de un import mal puesto); ficheros de locale o de zonas horarias que no usas; iconos importados en bloque.

7.9.2 Presupuestos: convertir el rendimiento en un test

Un presupuesto hace que el build falle al superar un límite. Es la única forma probada de que el peso no crezca sin control: sin presupuesto, cada semana alguien añade cinco kilobytes y nadie lo nota hasta que la aplicación tarda seis segundos en arrancar.

angular.json · presupuestos y opciones de producción
{
  "budgets": [
    { "type": "initial",           "maximumWarning": "350kB", "maximumError": "500kB" },
    { "type": "anyComponentStyle", "maximumWarning": "4kB",   "maximumError": "8kB" }
  ],
  "outputHashing": "all",
  "optimization": true,
  "sourceMap": { "scripts": true, "hidden": true }
}

7.9.3 Tree shaking: qué es y qué lo rompe

El tree shaking es la eliminación de exportaciones que nadie usa. Depende de que el empaquetador pueda demostrar, analizando el código de forma estática, que quitar algo no cambia el comportamiento. Todo lo que impida esa demostración lo desactiva:

Qué lo rompePor quéSolución
Efectos secundarios en el ámbito de módulo: modificar un prototipo, registrar algo global, ejecutar código al importarNo se puede saber si al eliminar el módulo se pierde un efecto necesario "sideEffects": false en el package.json de tus librerías; no ejecutar código al importar
Barrel files (index.ts que reexporta un directorio)Importar una cosa arrastra el grafo entero; con un solo módulo con efectos, todo se quedaImportar desde la ruta concreta; reservar los barrels para la API pública de una librería
Módulos CommonJSSus exportaciones se resuelven en ejecución: no son analizablesPreferir dependencias con build ESM
enum de TypeScriptGenera un objeto real en ejecuciónUniones de literales (capítulo 1)
Providers en el array providers de un móduloSi el módulo se importa, sus providers existen aunque nadie los inyecte@Injectable({ providedIn: 'root' }): solo entra en el bundle si alguien lo inyecta de verdad
import * as X de librerías grandesSe importa el espacio de nombres completo Importaciones con nombre de los símbolos concretos
informe.component.tsINCORRECTO
// moment: ~65 kB comprimidos + TODOS los locales,
// no es tree-shakeable y su API es mutable.
import * as moment from 'moment';
// lodash completo: ~25 kB para usar dos funciones.
import _ from 'lodash';
// Barrel de toda la carpeta: arrastra el grafo entero.
import { formatearEuros, Producto } from '../shared';
// Todos los locales "por si acaso": cientos de kB.
import '@angular/common/locales/global/fr';
import '@angular/common/locales/global/de';

export class InformeComponent {
  fecha = moment().format('DD/MM/YYYY');
  agrupado = _.groupBy(this.items, 'estado');
}
informe.component.tsCORRECTO
// date-fns: importaciones puntuales, ~2 kB por función.
// (A futuro, la API nativa Temporal, cuando esté sin
//  polyfill en tus navegadores objetivo.)
import { format } from 'date-fns';
import { es } from 'date-fns/locale';
// Solo la función que necesito, de su propio módulo.
import groupBy from 'lodash-es/groupBy';
// Rutas concretas: el empaquetador ve qué uso.
import { formatearEuros } from '../shared/formato/euros';
import type { Producto } from '../shared/modelos/producto';

export class InformeComponent {
  fecha = format(new Date(), 'dd/MM/yyyy', { locale: es });
  agrupado = groupBy(this.items, 'estado');
}

7.9.4 Dividir el bundle: rutas, @defer e import()

Dividir por rutas es la primera y más rentable división, con una excepción importante: la ruta inicial conviene que esté en el chunk principal, porque diferirla añade un viaje de red completo antes del primer pintado.

app.routes.ts · división por rutas
export const routes: Routes = [
  // Ruta inicial: importación ESTÁTICA. Es la única que se paga siempre, y
  // diferirla solo añadiría latencia antes del primer pintado.
  { path: '', component: PortadaComponent },

  // Una ruta, un chunk: loadComponent con import() dinámico.
  { path: 'productos/:id', loadComponent: () => import('./producto/producto.component')
      .then((m) => m.ProductoComponent) },

  // Un área completa con sus subrutas en un único chunk: loadChildren. Interesa
  // cuando el usuario que entra en 'admin' va a visitar varias de sus pantallas.
  { path: 'admin', loadChildren: () => import('./admin/admin.routes')
      .then((m) => m.adminRoutes), canMatch: [esAdministrador] },
];
// Truco de rendimiento y de seguridad a la vez: con canMatch, si el guard rechaza,
// el chunk NI SE DESCARGA. Con canActivate se descargaría antes de comprobarlo.
panel.component.html · @defer con desencadenantes
<app-resumen [datos]="resumen()" />   <!-- lo que se ve al entrar: sin diferir -->

<!-- La librería de gráficas no entra en el bundle inicial: se descarga cuando el
     bloque se acerca al área visible. -->
@defer (on viewport) {
  <app-grafica-ventas />
} @placeholder (minimum 300ms) {
  <div class="esqueleto" style="height:320px"></div>
} @loading (after 150ms; minimum 400ms) {
  <app-spinner />
} @error {
  <p>No se pudo cargar la gráfica. <button (click)="reintentar()">Reintentar</button></p>
}

<!-- Precargar sin renderizar: al pasar el ratón se descarga el chunk, así que
     cuando el usuario hace clic ya está listo. -->
@defer (on interaction; prefetch on hover) {
  <app-editor-avanzado />
} @placeholder { <button type="button">Abrir el editor</button> }
El @placeholder debe ocupar el mismo espacio que el contenido real Si el hueco reservado mide 40 píxeles y el contenido que llega mide 320, todo lo de abajo salta y acabas de generar el CLS que intentabas evitar. Reserva altura explícita en los esqueletos, y no difieras nunca un bloque por encima del pliegue: retrasarías tu propio LCP.

7.9.5 Polyfills y navegadores objetivo

El CLI decide qué transformaciones y polyfills aplicar a partir del browserslist del proyecto. Uno demasiado generoso —heredado de una plantilla antigua, o con ie 11 todavía dentro— obliga a compilar a una sintaxis más vieja y a incluir polyfills que ningún usuario tuyo necesita: más bytes y código más lento. Alinéalo con tu analítica real (por ejemplo last 2 Chrome versions, last 2 Firefox versions, last 2 Safari versions, not dead), comprueba el efecto con npx browserslist y compara el tamaño de polyfills antes y después.

Dos menciones aparte: Zone.js pesa unos 12 kB comprimidos y tiene un coste de ejecución permanente porque parchea todas las APIs asíncronas del navegador, de modo que el modo zoneless (capítulo 4) permite eliminarlo cuando toda la aplicación es reactiva con señales; y si tienes un polyfill de @angular/localize en el arranque, comprueba que no debería estar solo en los polyfills de desarrollo.

7.10 Optimización en tiempo de ejecución

Reducir el bundle mejora el arranque (FCP, LCP, TBT). Lo que sigue mejora la respuesta de la aplicación una vez cargada, es decir, el INP y la sensación de fluidez.

7.10.1 Detección de cambios: OnPush, señales y track

La jerarquía de decisiones, en orden de impacto
  1. ChangeDetectionStrategy.OnPush en todos los componentes. Sin excepciones: un solo componente con la estrategia por defecto en medio del árbol obliga a comprobar todo su subárbol en cada ciclo.
  2. Señales para el estado. Permiten a Angular saber exactamente qué vistas dependen de qué dato, en lugar de recorrer el árbol evaluando expresiones.
  3. track correcto en los @for. Con la clave adecuada Angular reordena nodos; con la equivocada destruye y recrea el DOM completo en cada cambio.
  4. Nada de funciones ni pipes impuros en las plantillas, porque se evalúan en cada ciclo de detección, por cada elemento de la lista.
lista.component.tsINCORRECTO
@Component({
  selector: 'app-lista',
  // Sin OnPush: se comprueba en CADA ciclo global
  template: `
    <!-- track por índice: al insertar al principio
         TODAS las filas cambian de índice y se
         destruye y recrea el DOM entero -->
    @for (t of tareas; track $index) {
      <!-- Funciones en la plantilla: se ejecutan en
           cada ciclo, por cada fila. 1.000 filas ×
           20 ciclos = 20.000 llamadas -->
      <div [class.urgente]="esUrgente(t)">
        {{ formatear(t.fecha) }} — {{ t.titulo }}
      </div>
    }
    <!-- Pipe impuro: se reevalúa siempre -->
    <p>Total: {{ tareas | filtrarPendientes | contar }}</p>
  `,
})
export class ListaComponent {
  @Input() tareas: Tarea[] = [];
  esUrgente(t: Tarea) { return t.prioridad > 8 && !t.completada; }
  formatear(f: Date) { return new Intl.DateTimeFormat('es-ES').format(f); }
}
lista.component.tsCORRECTO
@Component({
  selector: 'app-lista',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <!-- track por identidad estable: Angular reordena
         los nodos existentes en lugar de recrearlos -->
    @for (t of vista(); track t.id) {
      <!-- Solo lectura de valores ya calculados:
           coste cero en la plantilla -->
      <div [class.urgente]="t.urgente">
        {{ t.fechaTexto }} — {{ t.titulo }}
      </div>
    }
    <p>Total: {{ pendientes() }}</p>
  `,
})
export class ListaComponent {
  readonly tareas = input.required<Tarea[]>();
  // El formateador se crea UNA vez, no una por fila.
  private readonly fmt = new Intl.DateTimeFormat('es-ES');
  // computed: se recalcula solo si cambia la entrada, y queda memoizado.
  readonly vista = computed(() => this.tareas().map((t) => ({
    ...t,
    urgente: t.prioridad > 8 && !t.completada,
    fechaTexto: this.fmt.format(t.fecha),
  })));
  readonly pendientes = computed(() => this.tareas().filter((t) => !t.completada).length);
}

7.10.2 Listas largas: virtual scroll, paginación e infinite scroll

Ninguna optimización de detección de cambios salva a una tabla de 10.000 filas, porque el problema es el número de nodos del DOM. La solución es no crearlos: el virtual scroll renderiza solo las filas visibles más un margen y reutiliza esos nodos al desplazarse.

tabla.component.ts · virtual scroll del CDK
@Component({
  selector: 'app-tabla-tareas',
  imports: [ScrollingModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
  // itemSize es la ALTURA EN PÍXELES de cada fila y debe ser exacta: si no coincide,
  // la barra de desplazamiento miente y el scroll da saltos.
  template: `
    <cdk-virtual-scroll-viewport itemSize="48" class="viewport">
      <div *cdkVirtualFor="let t of tareas(); templateCacheSize: 20" class="fila">
        {{ t.id }} — {{ t.titulo }}
      </div>
    </cdk-virtual-scroll-viewport>
  `,
  styles: `
    .viewport { height: 600px; }   /* altura FIJA: obligatoria */
    .fila     { height: 48px; }    /* debe coincidir con itemSize */
  `,
})
export class TablaTareasComponent { readonly tareas = input.required<Tarea[]>(); }

// Si las filas tienen alturas distintas, itemSize fijo no sirve: usa una estrategia
// de tamaño automático o rediseña para que todas midan lo mismo.
TécnicaCuándoVentajaCoste
Virtual scrollEl usuario recorre todo el conjunto y ya lo tienes en memoriaDOM constante, scroll fluidoAltura fija, el Ctrl+F del navegador no encuentra lo no renderizado, complica la impresión
Paginación en servidorConjuntos grandes con filtros y ordenSe transfiere poco y las URLs son compartiblesUn viaje de red por página
Infinite scrollConsumo exploratorio: catálogos, redes socialesFricción mínimaEl DOM crece indefinidamente si no se recicla, el pie se vuelve inalcanzable y hay que restaurar la posición al volver

7.10.3 Imágenes: NgOptimizedImage, el LCP y el CLS

En la mayoría de las páginas públicas el elemento LCP es una imagen, y la mayoría de los CLS altos vienen de imágenes sin dimensiones declaradas. Los cuatro errores clásicos son: no poner width y height (el navegador no reserva espacio y todo salta cuando la imagen llega); poner loading="lazy" en la imagen principal, que es retrasar el LCP a propósito; servir una única resolución, de modo que un móvil de 360 píxeles descarga la imagen de 2.400; y usar una imagen de fondo por CSS, que el navegador no descubre hasta tener el CSSOM.

heroe.component.htmlCORRECTO
<!-- ngSrc en lugar de src. width y height son OBLIGATORIOS: definen la
     relación de aspecto y eliminan el CLS. priority marca la imagen LCP:
     añade un preload con prioridad alta y desactiva el lazy loading. -->
<img ngSrc="/img/heroe.jpg" width="1200" height="630" priority
     alt="Portada del catálogo de primavera">

<!-- Por debajo del pliegue: sin priority. La directiva aplica lazy. -->
<img ngSrc="/img/producto-12.jpg" width="400" height="400" alt="Zapato de piel marrón">

<!-- sizes activa el srcset automático: el navegador elige la resolución
     adecuada al hueco real que ocupa la imagen. -->
<img ngSrc="/img/banner.jpg" width="1600" height="500"
     sizes="(max-width: 768px) 100vw, 60vw" alt="Rebajas de temporada">

<!-- Contenedor de tamaño desconocido: fill + CSS (position relativa). -->
<div class="marco"><img ngSrc="/img/fondo.jpg" fill alt=""></div>

La directiva NgOptimizedImage aporta cuatro cosas:

Fuentes web. Tres medidas y un matiz: <link rel="preconnect"> al origen de las fuentes para ahorrar DNS, TCP y TLS del camino crítico; <link rel="preload" as="font" type="font/woff2" crossorigin> solo para una o dos fuentes realmente críticas, porque precargar seis compite con el HTML y empeora el LCP; y font-display: swap en el @font-face, que pinta ya con la fuente del sistema y sustituye al cargar, evitando el texto invisible a cambio de un reflow. Si ese reflow te preocupa más que la coherencia visual, font-display: optional es más estricto: descarta la fuente en esta visita si no llega rápido. Las métricas de sustitución y size-adjust reducen el salto entre ambas tipografías.

7.10.4 Liberar el hilo principal: zona, requestIdleCallback y workers

metricas.service.ts · trabajo que no debe despertar a Angular
seguirScroll(elemento: HTMLElement) {
  // Dentro de la zona, CADA evento de scroll dispararía un ciclo completo de
  // detección de cambios: decenas por segundo.
  this.zone.runOutsideAngular(() => {
    elemento.addEventListener('scroll', () => {
      this.acumular(elemento.scrollTop);       // cálculo puro, sin tocar la vista
    }, { passive: true });
  });
}
// Cuando SÍ hay que reflejar algo en la vista, se vuelve a entrar en la zona
// de forma explícita y controlada.
actualizarContador(valor: number) { this.zone.run(() => this.contador.set(valor)); }

registrarNoCritico(datos: Registro[]) {
  // requestIdleCallback ejecuta el trabajo cuando el navegador está libre, sin
  // competir con el pintado ni con las interacciones. No existe en todos los
  // navegadores ni en el servidor: comprueba antes de usarlo.
  const enviar = () => this.enviarLote(datos);
  if (typeof requestIdleCallback === 'function') requestIdleCallback(enviar, { timeout: 3000 });
  else setTimeout(enviar, 1000);
}
informe.component.ts · Web Worker para trabajo de CPU
// El CLI genera el worker y ajusta la compilación:  ng generate web-worker calculo
// Un cálculo de 800 ms en el hilo principal congela la interfaz: no responde a clics
// ni pinta fotogramas. En un worker la interfaz sigue viva, porque es OTRO hilo.
afterNextRender(() => {
  const worker = new Worker(new URL('./calculo.worker', import.meta.url));
  worker.onmessage = ({ data }) => {
    // El worker no tiene DOM ni zona de Angular: actualizar una señal desde
    // aquí es la forma limpia de volver.
    this.resultado.set(data);
    worker.terminate();
  };
  // Los datos se COPIAN al enviarlos (clonado estructurado). Para volúmenes grandes,
  // usa un ArrayBuffer transferible y evita la copia.
  worker.postMessage(this.filas());
});
// Candidatos ideales: agregaciones sobre miles de filas, generación de PDF o Excel en
// cliente, procesado de imágenes, cifrado, parseo de CSV grandes. NO lo son: el DOM.

7.11 Caché y red

7.11.1 Caché HTTP: Cache-Control y ETag

Cabecera / directivaSignificadoUso típico
max-age=NVálido N segundos en la caché del navegadorEstáticos con hash: un año
s-maxage=NIgual, pero solo para cachés compartidas (CDN, proxy)HTML de SSR cacheable
immutableNo revalides nunca, ni con recargaFicheros con hash en el nombre
no-storeNo lo guardes en ninguna parteRespuestas con datos personales o de sesión
privatePuede cachearlo el navegador, nunca la CDNHTML de SSR con datos del usuario
stale-while-revalidate=NSirve la copia caducada y revalida por detrásContenido tolerante a unos segundos de desfase
ETag + If-None-MatchValidación por huella: si no cambió, 304 sin cuerpo APIs de lectura, documentos HTML
VaryDe qué cabeceras depende la respuestaObligatoria si sirves contenido distinto según Accept-Language o Accept-Encoding
El error de caché que se convierte en incidente de seguridad Un HTML renderizado en servidor con el nombre del usuario dentro y una cabecera Cache-Control: public, max-age=600 hace que la CDN sirva la página de Ana a Bernardo durante diez minutos. Regla mecánica: si la respuesta contiene algo específico de un usuario, o es private, o es no-store. Y añade siempre Vary con las cabeceras que influyan en el contenido.

7.11.2 Service Worker con @angular/pwa

ng add @angular/pwa añade @angular/service-worker, crea ngsw-config.json, registra el service worker y genera el manifiesto. Detalle importante: el service worker solo se activa en builds de producción, así que para probarlo en local hay que compilar y servir el resultado con un servidor estático, no con ng serve.

ngsw-config.json · estrategias de caché
{
  "index": "/index.html",
  "assetGroups": [
    { "name": "app", "installMode": "prefetch",
      "resources": { "files": ["/favicon.ico", "/index.html", "/*.css", "/*.js"] } },
    { "name": "assets", "installMode": "lazy", "updateMode": "prefetch",
      "resources": { "files": ["/assets/**", "/media/**"] } }
  ],
  "dataGroups": [
    { "name": "api-catalogo", "urls": ["/api/productos", "/api/categorias"],
      "cacheConfig": { "strategy": "freshness", "maxSize": 100, "maxAge": "1h", "timeout": "3s" } },
    { "name": "api-estaticos", "urls": ["/api/paises", "/api/config"],
      "cacheConfig": { "strategy": "performance", "maxSize": 20, "maxAge": "7d" } }
  ]
}

installMode: prefetch descarga el recurso al instalar el service worker y es lo adecuado para lo imprescindible del arranque; lazy cachea solo lo que se vaya pidiendo, para catálogos de imágenes o recursos opcionales. En los datos, strategy: freshness intenta la red primero y recurre a la caché si falla o vence el timeout (datos que deben estar al día), mientras que performance sirve de la caché si la tiene (datos que apenas cambian).

actualizacion.service.ts · el patrón «nueva versión disponible»
iniciar() {
  if (!this.updates.isEnabled) return;   // desarrollo o navegador sin soporte
  // 1) Buscar actualizaciones periódicamente, pero SOLO cuando la aplicación ya
  //    está estable: comprobar durante el arranque compite con la carga inicial.
  const estable = this.appRef.isStable.pipe(first((s) => s === true));
  concat(estable, interval(6 * 60 * 60 * 1000))
    .subscribe(() => void this.updates.checkForUpdate());
  // 2) Cuando hay una versión lista, PREGUNTAR: recargar sin avisar puede hacer
  //    perder al usuario un formulario a medio rellenar.
  this.updates.versionUpdates
    .pipe(filter((e): e is VersionReadyEvent => e.type === 'VERSION_READY'))
    .subscribe(() => {
      if (confirm('Hay una nueva versión disponible. ¿Recargar ahora?')) {
        // activateUpdate() y DESPUÉS recargar, en ese orden.
        void this.updates.activateUpdate().then(() => location.reload());
      }
    });
  // 3) Estado irrecuperable: caché corrompida o ficheros que ya no existen (típico
  //    si borras un despliegue antiguo). Solo cabe recargar por completo.
  this.updates.unrecoverable.subscribe(() => location.reload());
}
El riesgo del «despliegue roto» con service worker Un cliente con la versión antigua en caché puede pedir un chunk diferido que ya no existe en el servidor porque lo has sobrescrito: el resultado es un error de carga de chunk en mitad de una navegación. Dos medidas: conserva los artefactos de despliegues anteriores unos días en tu almacenamiento estático, y gestiona el fallo de carga de chunk recargando la página como último recurso.

7.11.3 Precarga de rutas, CDN y compresión

Precarga de rutas. provideRouter(routes, withPreloading(PreloadAllModules)) descarga todos los chunks diferidos en cuanto la aplicación arranca: razonable en una intranet con buena red, malo en móvil con datos limitados, porque gasta datos en lo que nadie va a usar. Casi siempre es mejor una implementación propia de PreloadingStrategy que precargue solo las rutas marcadas con un data: { precargar: true }, respete saveData y las conexiones lentas, y espere a que la aplicación esté estable para no competir con la carga inicial. El ejercicio 7.9 y su solución al final del capítulo desarrollan esa estrategia completa.

7.12 Medición y diagnóstico

HerramientaQué te diceCuándo usarla
LighthousePuntuación y métricas de laboratorio, más una lista de oportunidades priorizada por ahorro estimadoPrimer diagnóstico y control de regresiones en CI (con Lighthouse CI y presupuestos)
WebPageTestCascada de red detallada, vídeo fotograma a fotograma y pruebas desde ubicaciones y dispositivos realesCuando necesitas saber qué recurso bloquea y en qué milisegundo
DevTools · PerformanceTraza del hilo principal: tareas largas, tiempo de script, layout, paint y marcas de las métricasPara diagnosticar TBT, INP y tirones de animación
DevTools · CoverageQué porcentaje del JS y CSS descargado se ha ejecutado de verdadPara encontrar código muerto en el arranque
Angular DevTools · ProfilerCiclos de detección de cambios, qué componente los provoca y cuánto cuesta cada unoCuando el problema es del framework y no de la red
window.performanceMarcas y medidas propias, entradas de recursos, navegación y tareas largasPara instrumentar tu propio código y medir en producción
RUM (campo)Percentil 75 real de tus usuarios, segmentado por país, dispositivo y rutaSiempre: es la única medición que representa la realidad
rum.ts · enviar métricas reales a tu backend
// La librería oficial 'web-vitals' resuelve las particularidades de cada métrica
// (atribución, cambios de pestaña, descarga de la página).
import { onCLS, onINP, onLCP, onTTFB } from 'web-vitals';

function enviar({ name, value, rating, id }: Metrica) {
  const cuerpo = JSON.stringify({ name, value, rating, id, ruta: location.pathname });
  // sendBeacon sobrevive a la descarga de la página, a diferencia de fetch.
  navigator.sendBeacon?.('/api/rum', cuerpo)
    ?? fetch('/api/rum', { body: cuerpo, method: 'POST', keepalive: true });
}
afterNextRender(() => { onLCP(enviar); onCLS(enviar); onINP(enviar); onTTFB(enviar); });
// Y marcas propias con la API performance para medir lo que a ti te importa:
performance.mark('catalogo:inicio');
performance.measure('catalogo:carga', 'catalogo:inicio');

Cómo interpretar una traza de Performance. Abre la pestaña, activa la limitación de CPU (4× o 6×) y de red, y recarga con grabación. Después lee de arriba abajo: la franja de fotogramas y las capturas de pantalla te sitúan visualmente el FCP y el LCP, y las marcas verticales te dan las métricas. En el hilo principal busca los bloques anchos, esas tareas de más de 50 ms marcadas con un triángulo rojo; haz clic en la más ancha y mira el árbol de llamadas. Si el tiempo se va en Evaluate Script, el problema es el tamaño del bundle; si se va en Recalculate Style o Layout, es CSS o inserción de nodos sin reservar espacio; y si aparecen muchos ciclos cortos y repetidos de detección de cambios, el problema es de reactividad y toca el Profiler de Angular DevTools.

Metodología: medir → hipótesis → cambio → medir Optimizar sin medir es superstición. El ciclo profesional es: 1) mide en campo y decide qué métrica está mal y para qué usuarios; 2) reproduce el problema en laboratorio con la limitación adecuada; 3) formula una hipótesis concreta («el LCP es la imagen del héroe, que se descubre tarde»); 4) haz un solo cambio; 5) vuelve a medir en las mismas condiciones y compara. Dos reglas que ahorran semanas: un cambio a la vez, porque con tres simultáneos no sabrás cuál funcionó ni cuál empeoró las cosas; y guarda las cifras junto al código (un fichero de resultados, un comentario en el pull request), porque en tres meses nadie recordará que aquello se hizo por algo.

7.13 Internacionalización (i18n)

Hay dos familias de soluciones y la elección afecta al rendimiento, al despliegue y al SEO. @angular/localize, la solución oficial, extrae los textos y genera un build completo por idioma: los mensajes se sustituyen en tiempo de compilación. Las librerías en tiempo de ejecución (@ngx-translate/core, Transloco y similares) cargan ficheros JSON de traducción y resuelven las claves mientras la aplicación corre.

Criterio@angular/localize (compilación)Librería en tiempo de ejecución
Peso enviado al usuarioSolo su idioma: óptimoEl motor de traducción más el JSON de cada idioma cargado
Coste de ejecuciónCero: los textos ya están en el bundleBúsqueda de claves e interpolación en cada render
Cambiar de idioma sin recargarNo: hay que ir a otra URLSí, es su gran ventaja
Tiempo de compilación y artefactosUn build por idioma: CI más lento, más ficheros que desplegarUn solo build
SEOExcelente: una URL por idioma, indexableFrágil si el idioma se decide en cliente
Flujo de traducciónExtracción a XLIFF o XMB, estándar de la industriaJSON propio; más informal
RecomendaciónSitios públicos, SEO y SSRAplicaciones internas con cambio de idioma en caliente
Marcado, extracción y build con @angular/localize
<!-- 1) Marcar. El significado|descripción ayuda al traductor a desambiguar. -->
<h1 i18n="cabecera|Título de la página de catálogo">Catálogo de productos</h1>
<!-- Atributos: se marcan con i18n-nombreDelAtributo -->
<img [ngSrc]="foto" i18n-alt alt="Foto del producto" width="200" height="200">
<!-- 2) Pluralización con ICU: NO concatenes cadenas, porque las reglas de
     plural varían por idioma (el árabe tiene seis categorías). -->
<p i18n>{ total, plural,
    =0    {No hay productos}
    one   {Hay un producto}
    other {Hay {{ total }} productos}
} </p>

<!-- 3) Selección por valor discreto -->
<span i18n>{ estado, select, activo {Activo} inactivo {Inactivo} otro {Desconocido} } </span>
terminal y angular.json · extracción y builds por idioma
# Extrae los mensajes marcados a un fichero XLIFF
ng extract-i18n --output-path src/locale --format xlf2
# Se traduce messages.xlf a messages.en.xlf, messages.fr.xlf... y se declara
# cada idioma en el proyecto, dentro de la sección "i18n":
#   "sourceLocale": "es",
#   "locales": { "en": "src/locale/messages.en.xlf", "fr": "src/locale/messages.fr.xlf" }
# y en las opciones de build:  "localize": true
# Genera un directorio por idioma: dist/mi-app/es/, /en/, /fr/
ng build --configuration production

Formatos por locale. Las tuberías date, number, currency y percent usan el locale activo, que se resuelve a partir del token LOCALE_ID. Con builds por idioma, el CLI lo configura solo. Si sirves un único build y decides el locale en ejecución, tendrás que registrar los datos del locale con registerLocaleData() y proporcionar LOCALE_ID; recuerda que cada locale registrado son kilobytes añadidos al bundle, así que registra solo los que uses. Dirección RTL: para árabe o hebreo hay que poner dir="rtl" en el <html> del build correspondiente y escribir el CSS con propiedades lógicas (margin-inline-start en lugar de margin-left, padding-inline, inset-inline-end) en lugar de duplicar hojas de estilo.

i18n, SSR y SEO: los tres puntos donde se rompe Uno: con builds por idioma necesitas un proceso de servidor (o un directorio de la CDN) por idioma y un enrutado por prefijo de ruta, subdominio o dominio; planifícalo antes de escribir el primer i18n. Dos: el servidor debe elegir el idioma de forma determinista —de la URL, y como último recurso de Accept-Language— porque si lo decide el cliente tras hidratar, el rastreador solo verá el idioma por defecto. Tres: declara siempre las alternativas con etiquetas hreflang recíprocas (incluida x-default) y con <html lang> correcto; sin eso Google tratará tus versiones como contenido duplicado.

7.14 Accesibilidad y SEO

Accesibilidad y posicionamiento comparten la misma raíz: ambos dependen de que el significado del contenido esté en el HTML y no solo en su apariencia. Un lector de pantalla y un rastreador son, a efectos prácticos, dos usuarios que no ven la página.

app.component.ts · foco, anuncio y metadatos por ruta
export class AppComponent {
  private readonly router = inject(Router);
  private readonly title = inject(Title);
  private readonly meta = inject(Meta);
  private readonly announcer = inject(LiveAnnouncer);
  private readonly doc = inject(DOCUMENT);

  constructor() {
    this.router.events.pipe(filter((e) => e instanceof NavigationEnd), takeUntilDestroyed())
      .subscribe(() => {
        const datos = this.rutaActiva();
        // 1) Título y etiquetas. En SSR esto acaba en el HTML que ven los
        //    rastreadores y las previsualizaciones de redes sociales.
        this.title.setTitle(`${datos.titulo} · Mi tienda`);
        this.meta.updateTag({ name: 'description', content: datos.descripcion });
        this.meta.updateTag({ property: 'og:title', content: datos.titulo });
        // 2) Canonical: evita indexar la misma página varias veces por
        //    culpa de los parámetros de campaña.
        this.doc.querySelector("link[rel='canonical']")
          ?.setAttribute('href', `https://mi-tienda.com${location.pathname}`);
        // 3) Foco y anuncio: imprescindibles para teclado y lector de pantalla.
        this.doc.querySelector<HTMLElement>('main h1')?.focus();
        this.announcer.announce(`${datos.titulo}. Página cargada.`, 'polite');
      });
  }
}

Datos estructurados. Un bloque JSON-LD (Product, Article, BreadcrumbList, Organization) es lo que habilita los resultados enriquecidos: precio y valoración en la propia página de resultados. Debe estar en el HTML del servidor y describir exactamente lo que el usuario ve; inventar valoraciones que no existen es motivo de penalización. Completa el conjunto con un sitemap.xml generado en el despliegue (con las mismas URLs que prerenderizas), un robots.txt coherente y etiquetas Open Graph y Twitter Card para las previsualizaciones.

Por qué el SSR ayuda al SEO pero no lo garantiza El SSR resuelve un problema: que el contenido esté en el HTML sin depender de que alguien ejecute JavaScript. Eso es necesario, no suficiente. Seguirás necesitando contenido que responda a una intención de búsqueda, títulos y descripciones únicos por página, URLs estables y semánticas, enlazado interno, canonicals correctos, un sitemap actualizado y, sí, buenos Core Web Vitals. Un SSR impecable con contenido duplicado y títulos genéricos no posiciona. Y al revés: un CSR con contenido excelente puede posicionar bien en Google, aunque perderá las previsualizaciones en redes sociales y quedará a merced del presupuesto de rastreo.

7.15 Errores comunes y cómo solucionarlos

SíntomaCausa realSolución
ReferenceError: window is not definedCódigo de navegador ejecutándose en el servidor, casi siempre en un constructor o en ngOnInitisPlatformBrowser, afterNextRender, token DOCUMENT; abstraer el almacenamiento en un servicio
NG05xx · hydration node mismatchHTML inválido, DOM manipulado a mano, contenido no determinista o una extensión del navegadorValidar el HTML del servidor, quitar el no determinismo, mover el trabajo a afterNextRender; ngSkipHydration solo como último recurso y con ámbito mínimo
LCP alto por la imagen principalImagen sin optimizar, sin prioridad, con lazy, o de fondo por CSS NgOptimizedImage con priority, formatos modernos, srcset por sizes, CDN de imágenes
CLS altoImágenes o iframes sin dimensiones, banners insertados arriba, fuentes que cambian de métrica, @placeholder más pequeño que el contenidoDeclarar width y height, reservar el hueco, font-display con métricas de sustitución, esqueletos de la altura real
El bundle crece sin controlNadie mide, no hay presupuestos y las dependencias entran sin revisión Presupuestos que rompan el build, análisis del bundle en cada pull request, revisión explícita de cada dependencia nueva
Fuga de memoria en SSR: el proceso crece hasta reiniciarseEstado mutable en el ámbito de módulo compartido entre peticiones, o suscripciones no canceladasTodo el estado dentro del inyector; cachés con límite y expiración; takeUntilDestroyed() en las suscripciones
Doble petición HTTP en SSR + CSRLa caché de transferencia está desactivada, la petición no es GET, o el dato no viene de HttpClientComprobar que provideClientHydration() está activo, ajustar withHttpTransferCacheOptions, o transferir a mano con TransferState
El TTFB de producción se clava en un número redondo (5 s, 10 s)La aplicación no alcanza la estabilidad: un setInterval, un polling de una librería o una petición que nunca resuelveMover los temporizadores a afterNextRender, poner tiempo límite a las peticiones del servidor

7.16 Buenas y malas prácticas

Haz esto

  • Mide en campo antes de optimizar y guarda las cifras junto al código.
  • Elige la estrategia de renderizado por ruta, no para toda la aplicación.
  • Presupuestos de bundle que rompan el build. Es el único control que sobrevive a las prisas.
  • OnPush y señales en todo el árbol, y track por identidad estable.
  • NgOptimizedImage con dimensiones siempre y priority solo en la imagen LCP.
  • Todo el código de navegador en afterNextRender en lugar de sembrar comprobaciones de plataforma.
  • Filtra lo que se transfiere y trata el estado transferido como contenido público.
  • Difiere lo que está por debajo del pliegue, con un hueco reservado de la altura real.
  • Un cambio a la vez, midiendo antes y después en las mismas condiciones.

Evita esto

  • Activar SSR «porque es mejor». Es coste operativo permanente: necesita una razón concreta.
  • ngSkipHydration para silenciar errores sin haber diagnosticado la causa.
  • Temporizadores y polling en el arranque: impiden que el render del servidor termine.
  • Estado mutable en el ámbito de módulo del código de servidor: fugas y filtraciones entre usuarios.
  • Cachear en CDN un HTML con datos de usuario. Es una filtración, no una optimización.
  • Funciones y pipes impuros en las plantillas, o track $index en listas que se reordenan.
  • PreloadAllModules por defecto en aplicaciones públicas con tráfico móvil.
  • Añadir dependencias sin mirar su peso ni buscar una alternativa más ligera.
  • Optimizar por la puntuación de Lighthouse en lugar de por la experiencia real de tus usuarios.

7.17 Preguntas frecuentes

¿Necesito SSR para que Google indexe mi aplicación Angular?
No necesariamente: Google ejecuta JavaScript y puede indexar una SPA. Pero lo hace en una segunda pasada, con presupuesto de rastreo limitado y sin garantías de plazo, así que el contenido nuevo tarda más en aparecer. Además, otros rastreadores y todas las previsualizaciones de redes sociales y mensajería no ejecutan JavaScript. Si el tráfico orgánico o el compartido en redes importa para el negocio, el SSR o el prerender dejan de ser opcionales. Si tu aplicación vive tras un login, no aportan nada.
¿SSR o prerender? ¿Cómo decido?
Con una sola pregunta: ¿el HTML es igual para todos los visitantes y cambia solo cuando publicas? Si la respuesta es sí, prerender: mejor TTFB, coste casi nulo y nada que mantener en producción. Si el contenido depende de la petición (precio, stock, resultados de búsqueda, idioma negociado), SSR. Y no es una decisión única: se toma por ruta, y lo habitual en producción es mezclar ambas.
¿Por qué mi aplicación con SSR parece más lenta que antes?
Hay tres causas habituales. Primera: el TTFB ha empeorado porque ahora hay render y consultas a la base de datos antes de responder, y si el servidor está saturado eso anula la ganancia. Segunda: no has activado la hidratación, así que el cliente borra y recrea el DOM, con parpadeo y peticiones duplicadas. Tercera: el bundle sigue siendo enorme, de modo que el contenido se ve pronto pero no responde durante segundos y el usuario percibe algo peor que antes. Mide TTFB, LCP y TBT por separado: te dirán cuál de las tres es.
¿Puedo usar localStorage con SSR?
En el navegador sí, en el servidor no existe. La solución no es rodear cada acceso con un if, sino aislarlo en un servicio de almacenamiento con una implementación nula en servidor, y leerlo desde afterNextRender. Y hay un matiz importante: si el dato afecta a lo que se pinta (el tema, el idioma, el país), localStorage es el sitio equivocado, porque el servidor no puede leerlo y tendrás un parpadeo o un desajuste de hidratación. Guárdalo en una cookie: eso el servidor sí lo recibe.
¿La hidratación incremental sustituye al @defer normal?
No, resuelven problemas distintos. @defer sin hydrate evita renderizar y enviar el contenido: en SSR el bloque no llega al HTML, así que ahorra bytes de HTML pero no es indexable ni cuenta para el LCP. Con hydrate on ... el servidor renderiza el contenido —visible e indexable— y lo que se aplaza es la descarga y ejecución del JavaScript. Para contenido que quieres que se vea e indexe pero que no necesita ser interactivo de inmediato, la hidratación incremental es lo correcto.
¿Por qué Math.random() rompe la hidratación y qué hago si necesito un identificador único?
Porque el servidor y el cliente ejecutan el mismo código en momentos distintos y obtienen valores distintos: el HTML serializado deja de coincidir con lo que el cliente espera. Si necesitas un identificador para asociar una etiqueta con su campo, no lo generes al azar: usa un valor derivado de los datos (el id de la entidad) o el contador de identificadores estables que Angular ofrece para este propósito, que es determinista en ambos entornos.
¿Cuánto debería pesar mi bundle inicial?
Lo que puedas justificar. Como referencia práctica para una aplicación de negocio: por debajo de 200 kB comprimidos es excelente, hasta 350 kB es razonable, y por encima de 500 kB conviene revisar dependencia por dependencia. Pero la cifra absoluta importa menos que la tendencia y el contexto: lo que de verdad hay que evitar es que crezca sin que nadie se dé cuenta, y eso solo lo garantiza un presupuesto que rompa el build.
¿Merece la pena quitar Zone.js?
Si tu aplicación ya es reactiva con señales, sí: ahorras unos 12 kB comprimidos y, más importante, eliminas el coste permanente de tener parcheadas todas las APIs asíncronas del navegador, lo que se traduce en menos ciclos de detección de cambios innecesarios. Pero no es un interruptor: hay que revisar las librerías de terceros que dependen de la zona y asegurarse de que todo el estado que afecta a la vista pasa por señales. Es un proyecto de migración (capítulo 4), no una optimización de tarde.
¿Un service worker mejora los Core Web Vitals?
En la primera visita no: el service worker aún no está instalado y, si lo instalas de forma agresiva, incluso compite por ancho de banda. Donde brilla es en las visitas siguientes, en las que puede servir el armazón desde la caché con un TTFB prácticamente nulo, y en el funcionamiento sin conexión. A cambio añade una clase entera de problemas operativos: versiones antiguas pegadas en la caché, chunks que ya no existen y usuarios que no ven tu último despliegue. No lo añadas sin un plan de actualización.
¿Cómo evito la doble petición si mis datos no vienen de HttpClient?
La caché de transferencia automática solo intercepta HttpClient. Si el dato lo obtienes con fetch directo, de una cookie, de una cabecera de la petición o de un cálculo caro en el servidor, tienes que transferirlo a mano con TransferState: el servidor lo escribe con set(), el cliente comprueba hasKey(), lo lee y —importante— lo elimina con remove() para que las navegaciones posteriores obtengan datos frescos.
¿Por qué mi INP es malo si mi LCP es excelente?
Porque miden cosas distintas: el LCP mide cuándo se ve el contenido y el INP cuándo la página responde. Un SSR o un prerender pintan rápido y no arreglan el INP en absoluto. Las causas típicas son tareas largas en el hilo principal (hidratación de un árbol enorme, ciclos de detección de cambios costosos, manejadores de eventos que hacen trabajo pesado en línea) y listas con miles de nodos. El camino es el Profiler de Angular DevTools más la traza de Performance: busca las tareas de más de 50 ms y reparte ese trabajo o quítalo del hilo principal.

7.18 Ejercicios

Nivel 1 · básico

7.1 Ejecuta Lighthouse en modo móvil sobre una aplicación Angular tuya y anota TTFB, FCP, LCP, TBT y CLS. Identifica el elemento LCP concreto con el panel de Performance y explica en tres frases por qué es ese y no otro.

7.2 Genera un build de producción con --stats-json, visualízalo y elabora la lista de las cinco dependencias más pesadas del chunk inicial. Para cada una, propón una alternativa o justifica por qué debe quedarse.

7.3 Añade presupuestos a angular.json con un límite un 10 % por encima de tu peso actual. Comprueba que el build falla al importar deliberadamente una librería pesada.

Nivel 2 · intermedio

7.4 Añade SSR a una aplicación existente con ng add @angular/ssr. Documenta en un fichero NOTAS.md qué versión de @angular/ssr se ha instalado, qué ficheros ha generado y qué API usa el server.ts. Compara con lo descrito en 7.5.2.

7.5 Provoca un error de hidratación a propósito de tres formas distintas (HTML inválido, contenido no determinista y manipulación directa del DOM). Anota el código de error de cada caso y arréglalos sin usar ngSkipHydration.

7.6 Demuestra la doble petición: registra en el backend cuántas veces se pide un endpoint durante una carga con SSR. Actívala y desactívala con withNoHttpTransferCache() y contrasta las cifras.

7.7 Convierte una tabla de 5.000 filas al virtual scroll del CDK. Mide antes y después el número de nodos del DOM y el tiempo del ciclo de detección de cambios con el Profiler de Angular DevTools.

7.8 Optimiza la imagen principal de una página con NgOptimizedImage: dimensiones, priority y sizes. Mide el LCP y el CLS antes y después.

Nivel 3 · avanzado

7.9 Implementa una estrategia de precarga propia que solo precargue rutas marcadas, respete saveData y espere a que la aplicación esté estable. Verifica en el panel de red que los chunks llegan cuando esperas.

7.10 Aplica hidratación incremental a una página con tres bloques por debajo del pliegue. Demuestra con el panel de red que su JavaScript no se descarga hasta el desencadenante, y con «ver código fuente» que el contenido está en el HTML.

7.11 Monta RUM: envía LCP, CLS e INP reales a un endpoint propio, agrégalos por percentil 75 y ruta, y compara el resultado con lo que decía Lighthouse. Explica las diferencias.

7.12 Internacionaliza una aplicación con @angular/localize a dos idiomas, con al menos un plural ICU, y despliega los dos builds bajo prefijos de ruta con hreflang recíprocos. Comprueba con curl que cada URL devuelve el idioma correcto sin ejecutar JavaScript.

Solución comentada del ejercicio 7.5 (errores de hidratación)

Los tres casos y su arreglo correcto:

// CASO 1 · HTML inválido. El navegador cierra el <p> antes del <div>, así que el
// árbol que construye no es el que serializó el servidor.
//   MAL:   <p><div>{{ texto }}</div></p>
//   BIEN:  <div><p>{{ texto }}</p></div>
// Diagnóstico: pasa el resultado de `curl` por un validador de HTML.
// CASO 2 · Contenido no determinista. El valor se calcula UNA vez en el servidor y
// viaja como dato, o se rellena en el cliente DESPUÉS de hidratar:
//   MAL:   sello = Date.now();
export class SelloComponent {
  readonly sello = signal('');
  constructor() { afterNextRender(() => this.sello.set(new Date().toISOString())); }
}
// CASO 3 · Manipulación directa del DOM: el componente inserta nodos que el cliente
// no espera. La solución es expresar el contenido en la PLANTILLA, que es lo que
// Angular serializa y sabe hidratar.
//   MAL:   ngOnInit() { this.doc.querySelector('.caja')!.innerHTML = '<b>hola</b>'; }
//   BIEN:  <div class="caja"><b>{{ texto }}</b></div>
// Si el DOM lo controla una librería externa que no puedes cambiar, ENTONCES (y solo
// entonces) el subárbol se marca con ngSkipHydration.

Nota sobre el método: el error indica el componente, pero no siempre la causa. El orden que funciona es validar el HTML del servidor, buscar no determinismo y, por último, probar en incógnito sin extensiones para descartar que el culpable sea un traductor o un bloqueador.

Solución comentada del ejercicio 7.9 (precarga selectiva)

Tres requisitos y cómo se cumple cada uno:

@Injectable({ providedIn: 'root' })
export class PrecargaInteligente implements PreloadingStrategy {
  private readonly appRef = inject(ApplicationRef);

  preload(route: Route, cargar: () => Observable<unknown>): Observable<unknown> {
    // (1) Solo rutas marcadas: la decisión vive junto a la ruta, no aquí.
    if (!route.data?.['precargar']) return EMPTY;
    // (2) Respeto por la conexión del usuario. La API Network Information no existe
    //     en todos los navegadores: se comprueba antes de usarla.
    const con = (navigator as Navigator & {
      connection?: { effectiveType?: string; saveData?: boolean };
    }).connection;
    if (con?.saveData || /2g/.test(con?.effectiveType ?? '')) return EMPTY;
    // (3) Esperar a que la app esté estable ANTES de gastar ancho de banda: precargar
    //     durante el arranque compite con la carga inicial y empeora justo la
    //     métrica que intentas mejorar.
    return this.appRef.isStable.pipe(first((e) => e === true), switchMap(() => cargar()));
  }
}
// provideRouter(routes, withPreloading(PrecargaInteligente))

Verificación: en el panel de red, filtrando por JS, los chunks marcados deben aparecer después de que la página quede quieta, nunca antes del LCP. Si aparecen antes, la espera de estabilidad no está funcionando y estás perjudicando el arranque.

7.19 Resumen del capítulo

  • El primer píxel es el final de una cadena larga: DNS, TCP, TLS, TTFB, parseo, CSSOM, árbol de render, layout, paint y composición. Saber en qué eslabón estás perdiendo tiempo es la mitad del trabajo.
  • Seis métricas y sus umbrales: TTFB (< 0,8 s), FCP (< 1,8 s), LCP (< 2,5 s), INP (< 200 ms), CLS (< 0,1) y TBT (< 200 ms). Optimiza mirando el campo y verifica en laboratorio.
  • La estrategia de renderizado se elige por ruta: CSR para lo privado, SSR para lo público y cambiante, prerender para lo público y estable, e hidratación incremental cuando sobra JavaScript en el arranque.
  • El SSR mejora el pintado y crea un problema nuevo: el intervalo en que la página se ve pero no responde. Se ataca reduciendo el JavaScript inicial, no renderizando más.
  • La hidratación exige coincidencia exacta entre el HTML del servidor y el que el cliente espera. HTML inválido, no determinismo y manipulación directa del DOM son las tres causas de casi todos los fallos.
  • El estado transferido es contenido público. Evita la doble petición, pero nunca debe llevar datos sensibles, y un HTML con datos de usuario no se cachea en la CDN.
  • El presupuesto de bundle es un test. Sin un límite que rompa el build, el peso inicial crece siempre.
  • En ejecución mandan cuatro cosas: OnPush y señales, track por identidad estable, no crear nodos que no se ven, y no bloquear el hilo principal.
  • Las imágenes deciden el LCP y el CLS en la mayoría de las páginas públicas: dimensiones siempre, priority solo en la imagen principal.
  • Cuando algo depende de la versión de Angular, verifícalo en el andamiaje que genera tu CLI y en la documentación de tu versión, en lugar de fiarte de un tutorial.
  • Metodología por encima de trucos: medir, formular una hipótesis, hacer un cambio y volver a medir.

7.20 Recursos adicionales

Siguiente paso Ya sabes construir aplicaciones Angular rápidas y medir si de verdad lo son. El capítulo 8 cierra la Parte II con lo que garantiza que sigan funcionando mañana: testing, depuración y calidad de código.