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.
7.1 Qué vas a poder hacer al terminar
- Describir fase por fase qué ocurre entre la petición del navegador y el primer píxel, y señalar en qué fase está el cuello de botella de una aplicación concreta.
- Definir FCP, LCP, INP, CLS, TTFB y TBT, conocer sus umbrales y saber cuál de ellos te está costando usuarios o posiciones.
- Elegir con criterio entre CSR, SSR, SSG/prerender e hidratación incremental, y justificar la decisión en términos de coste, SEO y experiencia de usuario.
- Configurar SSR entendiendo el ciclo petición → render → HTML → envío, y escribir código que funcione igual en servidor y en navegador.
- Diagnosticar y arreglar un error de hidratación sin recurrir a
ngSkipHydrationcomo primera medida. - Evitar la doble petición HTTP con la caché de transferencia, sabiendo qué datos no deben transferirse nunca.
- Analizar un bundle, ponerle presupuestos, dividirlo por rutas y detectar la dependencia que arruina el arranque.
- Aplicar las optimizaciones de ejecución que importan:
OnPush, señales,track, virtual scroll,NgOptimizedImagey trabajo fuera de la zona. - Internacionalizar entendiendo el coste real de cada enfoque y su interacción con SSR y SEO.
- Montar medición continua: Lighthouse en CI, RUM en producción y una metodología que no consista en tocar cosas al azar.
@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
- DNS. Traducir
mi-app.coma una IP cuesta entre 20 y 150 ms si no está en caché. Cada dominio distinto que use la página (fuentes, analítica, CDN de imágenes) añade su propia resolución; por eso un<link rel="preconnect">a los dominios críticos es una de las optimizaciones más baratas que existen. - TCP. Apretón de manos de tres vías: un tiempo de ida y vuelta (RTT) completo antes de enviar un byte útil.
- TLS. Negociación de versión, cifrado y validación del certificado: uno o dos RTT más (TLS 1.3 lo reduce a uno, y a cero reutilizando sesión).
- Petición y proceso en el servidor. Aquí consumen tiempo tu backend, tu base de datos y tu SSR. Todo lo anterior más esto es el TTFB.
- Descarga del HTML. El parser trabaja en flujo: no espera al último byte, lo que le permite descubrir y empezar a descargar recursos antes.
7.2.2 Del HTML a los píxeles: DOM, CSSOM, layout, paint y composición
- 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 adocument.write. - 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.
- Árbol de render, layout y paint. El árbol de render combina DOM y CSSOM con solo lo que se muestra (
display: noneno entra;visibility: hiddensí, 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. - Composición. Las capas se combinan, si es posible en la GPU. Animar
transformyopacityse resuelve solo aquí y por eso es barato; animarwidth,topomarginobliga 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:
| Coste | De qué depende | Cómo se reduce |
|---|---|---|
| Descarga | Bytes comprimidos y latencia | Menos código, brotli, CDN, HTTP/2 o 3 |
| Parseo y compilación | Bytes sin comprimir | Menos código; la compresión no ayuda aquí |
| Ejecución | Trabajo real del arranque y del framework | Diferir lo no crítico, hacer menos al arrancar |
| Memoria | Estructuras retenidas, componentes vivos | Liberar 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étrica | Qué mide exactamente | Bueno | Mejorable | Malo |
|---|---|---|---|---|
| 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 s | 0,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 s | 1,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 s | 2,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 ms | 200 – 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,1 | 0,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 ms | 200 – 600 ms | > 600 ms |
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.
- CSR (Client-Side Rendering). El servidor envía un HTML casi vacío y un bundle. El navegador ejecuta el framework, que construye el DOM. Es el comportamiento por defecto de una SPA de Angular.
- SSR (Server-Side Rendering). En cada petición el servidor ejecuta la aplicación, produce el HTML con contenido y lo envía. El navegador pinta pronto y después «hidrata» ese HTML para hacerlo interactivo.
- SSG / prerender. El HTML se genera en tiempo de compilación, una vez, y se sirve como fichero estático desde un CDN. TTFB mínimo y coste de servidor casi nulo.
- ISR (Incremental Static Regeneration). Concepto popularizado por Next.js: se sirve el estático cacheado y se regenera en segundo plano al caducar o al invalidarse. Es SSG con revalidación. Angular no lo ofrece con ese nombre como característica de primera clase; el mismo efecto se logra combinando prerender con una CDN configurada con
stale-while-revalidate. - Hidratación parcial / incremental. En lugar de hidratar el árbol entero al arrancar, se hidrata solo lo necesario y cuando hace falta (al entrar en pantalla, al interactuar). Reduce drásticamente el JavaScript del arranque, que es la causa del TBT.
| Criterio | CSR | SSR | SSG / prerender | ISR (concepto) | Hidratación incremental |
|---|---|---|---|---|---|
| TTFB | Muy bueno (estático) | Peor: depende del render y de la BD | El mejor posible (CDN) | Muy bueno con acierto de caché | Igual que la estrategia base |
| FCP / LCP | Malos: hay que descargar y ejecutar JS antes de ver algo | Buenos: el HTML ya trae contenido | Excelentes | Excelentes | Iguales o mejores que SSR |
| SEO | Frágil: depende de que el rastreador ejecute JS y de su presupuesto | Bueno | Bueno | Bueno | Bueno |
| Coste de servidor | Casi nulo | Alto: CPU y memoria por petición | Casi nulo | Bajo o medio | Igual que la base |
| Complejidad | Baja | Alta: dos entornos, hidratación, estado, sesiones | Media: hay que enumerar rutas | Alta: invalidación de caché | Media-alta |
| Interactividad (TBT/INP) | Tardía pero de una vez | Riesgo del «valle inquietante»: se ve pero no responde | Mismo riesgo que SSR | Igual que SSG | La mejor: se hidrata lo justo |
| Datos personalizados | Natural | Natural, con cuidado con las cachés | No: el HTML es igual para todos | Limitado | Según la base |
| Casos típicos | Intranets, paneles tras login, editores | E-commerce, noticias, marketplaces | Blogs, documentación, landings | Catálogos grandes de ritmo moderado | Páginas públicas con mucha zona no visible |
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:
- Descarga de
main.js,polyfills.jsy las hojas de estilo. - 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.
- Ejecución de
bootstrapApplication()y creación del inyector raíz con todos los providers de la aplicación. - 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.
- Instanciación del componente y primera detección de cambios, que crea el DOM: aquí llega el FCP.
- 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
- La pantalla en blanco. Hasta la primera detección de cambios no hay nada que mirar: en un móvil de gama media con 4G irregular, dos o tres segundos. Un esqueleto escrito dentro de
<app-root>en elindex.html(Angular lo reemplaza al arrancar) mejora la percepción, pero no el LCP, porque un esqueleto no es contenido. - El SEO. Los rastreadores modernos ejecutan JavaScript, pero en una segunda pasada, con presupuesto limitado y sin garantías de plazo. Y las previsualizaciones de redes sociales y mensajería no ejecutan JavaScript en absoluto: leen las etiquetas
metadel HTML recibido. Si tuog:titlelo escribe Angular en ejecución, la previsualización mostrará el título genérico de la aplicación. - El coste del arranque en dispositivos lentos. El bundle que en tu portátil tarda 300 ms en compilar y ejecutar puede tardar 2.500 ms en un móvil de 120 euros. Por eso Lighthouse simula un dispositivo lento: para que dejes de mirar tu equipo.
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.
| Pieza | Qué contiene | Cuándo se descarga |
|---|---|---|
main-HASH.js | Arranque, componente raíz y todo lo importado estáticamente desde ellos, más las partes del framework que uses | Siempre, en el arranque |
polyfills-HASH.js | Zone.js (salvo en modo zoneless) y polyfills según el browserslist | Siempre |
styles-HASH.css | Estilos globales | Siempre, y bloquea el pintado |
chunk-HASH.js | Rutas diferidas, bloques @defer y cualquier import() dinámico | Bajo demanda |
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
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:
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:
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);
// 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.
| API | Estado en el servidor | Qué hacer |
|---|---|---|
window | No existe | Comprobar la plataforma o mover el código a afterNextRender |
document global | No existe como global, pero sí hay un documento emulado | Inyectar el token DOCUMENT, nunca usar el global |
localStorage, sessionStorage | No existen | Abstraer en un servicio con implementación nula en servidor; si el servidor necesita el dato, usar cookies |
navigator, matchMedia | No existen | Detección por User-Agent en servidor, o decisión aplazada al cliente |
IntersectionObserver, ResizeObserver, requestAnimationFrame | No existen o no aplican | Registrarlos solo dentro de afterNextRender |
Medidas del DOM (getBoundingClientRect) | Devuelven ceros: no hay layout | Toda lógica basada en medidas es exclusiva del cliente |
URLs relativas en HttpClient | Fallan: no hay origen | URL absoluta en el servidor, desde la configuración o desde la petición entrante |
@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);
}
}
@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.
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. 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); }
}
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));
});
}
}
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.
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:
- Manipulación directa del DOM. Un
querySelector(...).appendChild(...), uninnerHTMLa mano, o una librería de terceros (carrusel, editor de texto rico, mapa) que reordena nodos dentro de una zona gestionada por Angular. - 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. - 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». - Estructura condicional por plataforma. Un
@if (esNavegador)alrededor de un bloque de plantilla es, por definición, una diferencia entre servidor y cliente. - 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.
@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();
}
@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:
- Lee el nombre del componente del error: reduce el problema a un fichero.
- 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.htmlpara 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 etiquetascriptde tipo JSON). - 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. - 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.
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 sí 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.
<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 /> }
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.
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()),
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.
// 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;
}
}
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.
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
];
--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. Sí: 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
# 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.
{
"budgets": [
{ "type": "initial", "maximumWarning": "350kB", "maximumError": "500kB" },
{ "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" }
],
"outputHashing": "all",
"optimization": true,
"sourceMap": { "scripts": true, "hidden": true }
}
outputHashing: "all": añade un hash del contenido al nombre de cada fichero. Es lo que permite cachear los estáticos «para siempre» sin miedo a servir una versión vieja tras un despliegue.optimization: true: minificación, eliminación de código muerto y optimización de estilos. Se puede desglosar por partes (scripts,styles,fonts) cuando hace falta afinar.sourceMap.hidden: true: genera los mapas para tu herramienta de errores pero no los referencia desde el bundle, así que no se descargan ni exponen tu código.
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 rompe | Por qué | Solución |
|---|---|---|
| Efectos secundarios en el ámbito de módulo: modificar un prototipo, registrar algo global, ejecutar código al importar | No 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 queda | Importar desde la ruta concreta; reservar los barrels para la API pública de una librería |
| Módulos CommonJS | Sus exportaciones se resuelven en ejecución: no son analizables | Preferir dependencias con build ESM |
enum de TypeScript | Genera un objeto real en ejecución | Uniones de literales (capítulo 1) |
Providers en el array providers de un módulo | Si 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 grandes | Se importa el espacio de nombres completo | Importaciones con nombre de los símbolos concretos |
// 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');
}
// 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.
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.
<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> }
@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
ChangeDetectionStrategy.OnPushen 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.- Señales para el estado. Permiten a Angular saber exactamente qué vistas dependen de qué dato, en lugar de recorrer el árbol evaluando expresiones.
trackcorrecto en los@for. Con la clave adecuada Angular reordena nodos; con la equivocada destruye y recrea el DOM completo en cada cambio.- 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.
@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); }
}
@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.
@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écnica | Cuándo | Ventaja | Coste |
|---|---|---|---|
| Virtual scroll | El usuario recorre todo el conjunto y ya lo tienes en memoria | DOM constante, scroll fluido | Altura fija, el Ctrl+F del navegador no encuentra lo no renderizado, complica la impresión |
| Paginación en servidor | Conjuntos grandes con filtros y orden | Se transfiere poco y las URLs son compartibles | Un viaje de red por página |
| Infinite scroll | Consumo exploratorio: catálogos, redes sociales | Fricción mínima | El 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.
<!-- 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:
srcsetautomático a partir dewidthysizes. Con un loader de CDN de imágenes configurado (provideImgixLoader,provideCloudinaryLoadery equivalentes, o uno propio) genera además las URLs de redimensionado del proveedor.- Reserva de espacio: al exigir
widthyheight(ofill) elimina de raíz la causa más común de CLS. priority: genera un<link rel="preload">con prioridad de descarga alta, para que la imagen LCP se descubra antes.- Avisos en desarrollo: te dice en consola si una imagen es probablemente el LCP y le falta
priority, si el tamaño declarado no coincide con el real o si estás sirviendo una imagen mucho mayor de lo necesario. Es una de las herramientas de diagnóstico más útiles del framework.
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
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);
}
// 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 / directiva | Significado | Uso típico |
|---|---|---|
max-age=N | Válido N segundos en la caché del navegador | Estáticos con hash: un año |
s-maxage=N | Igual, pero solo para cachés compartidas (CDN, proxy) | HTML de SSR cacheable |
immutable | No revalides nunca, ni con recarga | Ficheros con hash en el nombre |
no-store | No lo guardes en ninguna parte | Respuestas con datos personales o de sesión |
private | Puede cachearlo el navegador, nunca la CDN | HTML de SSR con datos del usuario |
stale-while-revalidate=N | Sirve la copia caducada y revalida por detrás | Contenido tolerante a unos segundos de desfase |
ETag + If-None-Match | Validación por huella: si no cambió, 304 sin cuerpo | APIs de lectura, documentos HTML |
Vary | De qué cabeceras depende la respuesta | Obligatoria si sirves contenido distinto según Accept-Language o Accept-Encoding |
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.
{
"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).
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());
}
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.
- CDN. Reduce la latencia acercando los bytes al usuario. Para un artefacto estático es la solución completa; con SSR, sirve al menos los estáticos desde el borde y, si el HTML es cacheable, cachéalo con duración corta.
- Compresión. Brotli (
Content-Encoding: br) gana a gzip entre un 15 % y un 20 % en texto. Precomprime en el build (ficheros.bry.gzjunto al original) en lugar de comprimir en cada petición: permite el nivel máximo sin coste por petición. No comprimas lo ya comprimido (JPEG, PNG, WOFF2, vídeo). - HTTP/2 y HTTP/3. Multiplexan varias descargas en una conexión, así que la vieja práctica de concatenar todo en un fichero gigante dejó de tener sentido; HTTP/3 elimina además el bloqueo de cabeza de línea en redes con pérdida, lo que se nota mucho en móvil.
7.12 Medición y diagnóstico
| Herramienta | Qué te dice | Cuándo usarla |
|---|---|---|
| Lighthouse | Puntuación y métricas de laboratorio, más una lista de oportunidades priorizada por ahorro estimado | Primer diagnóstico y control de regresiones en CI (con Lighthouse CI y presupuestos) |
| WebPageTest | Cascada de red detallada, vídeo fotograma a fotograma y pruebas desde ubicaciones y dispositivos reales | Cuando necesitas saber qué recurso bloquea y en qué milisegundo |
| DevTools · Performance | Traza del hilo principal: tareas largas, tiempo de script, layout, paint y marcas de las métricas | Para diagnosticar TBT, INP y tirones de animación |
| DevTools · Coverage | Qué porcentaje del JS y CSS descargado se ha ejecutado de verdad | Para encontrar código muerto en el arranque |
| Angular DevTools · Profiler | Ciclos de detección de cambios, qué componente los provoca y cuánto cuesta cada uno | Cuando el problema es del framework y no de la red |
window.performance | Marcas y medidas propias, entradas de recursos, navegación y tareas largas | Para instrumentar tu propio código y medir en producción |
| RUM (campo) | Percentil 75 real de tus usuarios, segmentado por país, dispositivo y ruta | Siempre: es la única medición que representa la realidad |
// 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.
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 usuario | Solo su idioma: óptimo | El motor de traducción más el JSON de cada idioma cargado |
| Coste de ejecución | Cero: los textos ya están en el bundle | Búsqueda de claves e interpolación en cada render |
| Cambiar de idioma sin recargar | No: hay que ir a otra URL | Sí, es su gran ventaja |
| Tiempo de compilación y artefactos | Un build por idioma: CI más lento, más ficheros que desplegar | Un solo build |
| SEO | Excelente: una URL por idioma, indexable | Frágil si el idioma se decide en cliente |
| Flujo de traducción | Extracción a XLIFF o XMB, estándar de la industria | JSON propio; más informal |
| Recomendación | Sitios públicos, SEO y SSR | Aplicaciones internas con cambio de idioma en caliente |
<!-- 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>
# 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. 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.
- HTML semántico.
<header>,<nav>,<main>(uno solo),<article>,<footer>, un único<h1>y jerarquía de encabezados sin saltos.<button>para acciones y<a href>para navegación: un<div (click)>no es enfocable, no responde al teclado y no se anuncia. - Foco al navegar entre rutas. En una SPA el navegador no reinicia el foco al cambiar de vista: quien usa teclado o lector de pantalla se queda donde estaba, y no se le anuncia que ha cambiado de página. Hay que moverlo explícitamente al encabezado principal de la nueva vista.
- Anuncios para lectores de pantalla. Los cambios que no mueven el foco (resultados filtrados, «guardado correctamente», errores de validación) deben anunciarse mediante una región aria-live; el CDK ofrece un servicio para ello (
LiveAnnouncer). - Contraste, tamaños y movimiento. Objetivos táctiles suficientes, contraste mínimo de 4,5:1 en texto normal y respeto a
prefers-reduced-motion.
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.
7.15 Errores comunes y cómo solucionarlos
| Síntoma | Causa real | Solución |
|---|---|---|
ReferenceError: window is not defined | Código de navegador ejecutándose en el servidor, casi siempre en un constructor o en ngOnInit | isPlatformBrowser, afterNextRender, token DOCUMENT; abstraer el almacenamiento en un servicio |
NG05xx · hydration node mismatch | HTML inválido, DOM manipulado a mano, contenido no determinista o una extensión del navegador | Validar 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 principal | Imagen sin optimizar, sin prioridad, con lazy, o de fondo por CSS | NgOptimizedImage con priority, formatos modernos, srcset por sizes, CDN de imágenes |
| CLS alto | Imágenes o iframes sin dimensiones, banners insertados arriba, fuentes que cambian de métrica, @placeholder más pequeño que el contenido | Declarar width y height, reservar el hueco, font-display con métricas de sustitución, esqueletos de la altura real |
| El bundle crece sin control | Nadie 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 reiniciarse | Estado mutable en el ámbito de módulo compartido entre peticiones, o suscripciones no canceladas | Todo el estado dentro del inyector; cachés con límite y expiración; takeUntilDestroyed() en las suscripciones |
| Doble petición HTTP en SSR + CSR | La caché de transferencia está desactivada, la petición no es GET, o el dato no viene de HttpClient | Comprobar 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 resuelve | Mover 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.
OnPushy señales en todo el árbol, ytrackpor identidad estable.NgOptimizedImagecon dimensiones siempre yprioritysolo en la imagen LCP.- Todo el código de navegador en
afterNextRenderen 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.
ngSkipHydrationpara 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 $indexen listas que se reordenan. PreloadAllModulespor 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?
¿SSR o prerender? ¿Cómo decido?
¿Por qué mi aplicación con SSR parece más lenta que antes?
¿Puedo usar localStorage con SSR?
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?
@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 sí 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?
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?
¿Merece la pena quitar Zone.js?
¿Un service worker mejora los Core Web Vitals?
¿Cómo evito la doble petición si mis datos no vienen de HttpClient?
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?
7.18 Ejercicios
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.
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.
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 sí 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:
OnPushy señales,trackpor 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,
prioritysolo 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
- Angular · Server-side rendering — la referencia oficial de SSR, hidratación y prerender; selecciona tu versión antes de copiar cualquier API.
- Angular · Hidratación — cómo funciona, qué la rompe y el detalle de
ngSkipHydration. - Angular · Internacionalización — marcado, extracción, ICU y builds por idioma.
- web.dev · Core Web Vitals — definiciones, umbrales y guías específicas para mejorar LCP, INP y CLS.
- Lighthouse — documentación de las auditorías y de su integración en CI.
- WebPageTest — pruebas desde dispositivos y ubicaciones reales, con cascada de red y vídeo de la carga.