Server-Side Rendering en Angular: cómo pasé de 55 a 90+ en performance con SSR

Ingeniero mecatrónico y estudiante MISO (Maestría en Ingeniería de Software) de la Universidad de los Andes. Actualmente trabajo como arquitecto desarrollador de software. Me apasiona el fútbol, la programación y el café por las mañanas antes de iniciar la chamba.
La transformación: duplicar el performance score de tu app Angular con SSR
Cuando los usuarios cargan tu aplicación web, cada milisegundo cuenta. La diferencia entre una aplicación que carga en 1 segundo versus 3 segundos puede significar la diferencia entre un usuario satisfecho y uno que abandona tu sitio. En este artículo, te contaré cómo implementamos Server-Side Rendering (SSR) en una aplicación Angular empresarial y logramos mejorar el score de Lighthouse de 55 a más de 90, reduciendo el tiempo de carga en más de 2 segundos y mejorando significativamente la experiencia del usuario.
El problema: una aplicación Angular lenta
Nuestra aplicación Angular, un sistema de gestión de medidas para el mercado energético, enfrentaba problemas de rendimiento serios:
Performance score de Lighthouse: 55/100
Largest Contentful Paint (LCP): ~3.5 segundos
First Contentful Paint (FCP): ~2.1 segundos
Render-blocking resources: 750ms
Total download size: ~2MB sin comprimir
Los usuarios reportaban que la página se sentía "lenta", especialmente en la primera carga. Los bots de búsqueda tenían dificultad para indexar el contenido dinámico. Era hora de tomar acción.
¿Qué es Server-Side Rendering y por qué lo necesitas?
El problema del Client-Side Rendering tradicional
En una aplicación Angular tradicional (Client-Side Rendering), el flujo es así:
El usuario solicita la página
El servidor envía un HTML casi vacío con un
<app-root></app-root>El navegador descarga JavaScript (a veces varios MB)
JavaScript se ejecuta y renderiza todo el contenido
Finalmente, el usuario ve algo
El resultado: Una pantalla en blanco durante varios segundos mientras se descarga y ejecuta JavaScript. Los motores de búsqueda ven un HTML vacío.
La solución: Server-Side Rendering
Con SSR, el flujo cambia radicalmente:
El usuario solicita la página
El servidor ejecuta Angular en Node.js
Angular genera HTML completo con todo el contenido
El servidor envía HTML pre-renderizado al navegador
El usuario ve contenido inmediatamente
JavaScript se descarga en segundo plano y "activa" la aplicación (hydration)
El resultado: El usuario ve contenido en menos de 1 segundo. Los motores de búsqueda indexan contenido completo. Win-win.
Implementación paso a paso: de cero a SSR optimizado
Paso 1: configurar Angular SSR
Angular 19 hace que configurar SSR sea más fácil que nunca. Primero, necesitas tener los paquetes correctos:
npm install @angular/platform-server @angular/ssr express compression
npm install --save-dev @types/express @types/compression
Paso 2: configuración de angular.json
La magia comienza en tu angular.json. Aquí está la configuración clave:
{
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"browser": "src/main.ts",
"server": "src/main.server.ts",
"outputMode": "server",
"ssr": {
"entry": "src/server.ts"
},
"optimization": {
"scripts": true,
"styles": {
"minify": true,
"inlineCritical": true // 🔥 Esta es la clave
}
}
}
}
}
}
La optimización clave: inlineCritical: true. Esto hace que Angular automáticamente:
Identifique el CSS necesario para el primer render (above-the-fold)
Inyecte ese CSS directamente en el
<head>Cargue el resto del CSS de forma asíncrona (no bloqueante)
Resultado: Eliminamos 750ms de render-blocking resources. Nuestro CSS de 626KB ya no bloquea el render inicial.
Paso 3: el módulo del servidor
Crea src/app/app.module.server.ts:
import { NgModule } from '@angular/core';
import { ServerModule } from '@angular/platform-server';
import { provideServerRouting } from '@angular/ssr';
import { AppComponent } from './app.component';
import { AppModule } from './app.module';
import { serverRoutes } from './app.routes.server';
@NgModule({
imports: [AppModule, ServerModule],
providers: [provideServerRouting(serverRoutes)],
bootstrap: [AppComponent],
})
export class AppServerModule {}
Y habilita la hidratación en tu app.module.ts:
import { BrowserModule, provideClientHydration } from '@angular/platform-browser';
@NgModule({
imports: [BrowserModule],
providers: [
provideClientHydration(), // 🔥 Esencial para SSR
// ... otros providers
],
})
export class AppModule {}
¿Qué es la hidratación? Es el proceso donde Angular "activa" el HTML pre-renderizado del servidor en lugar de destruirlo y recrearlo desde cero. Esto evita el temido "flash" donde la página parpadea al cargar.
Paso 4: el servidor Express con optimizaciones
Aquí es donde ocurre la verdadera magia. Nuestro src/server.ts no solo sirve la aplicación, sino que la optimiza en tiempo real:
import {
AngularNodeAppEngine,
createNodeRequestHandler,
isMainModule,
writeResponseToNodeResponse,
} from '@angular/ssr/node';
import express from 'express';
import compression from 'compression';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const browserDistFolder = resolve(serverDistFolder, '../browser');
const app = express();
const angularApp = new AngularNodeAppEngine();
// 🔥 Optimización #1: Compresión Gzip/Brotli
app.use(compression());
// 🔥 Optimización #2: Cache headers para archivos estáticos
app.use(
express.static(browserDistFolder, {
maxAge: '1y',
index: false,
redirect: false,
setHeaders: (res, path) => {
const staticFileRegex = /\.(js|css|woff2?|ttf|eot|svg|webp|png|jpg|jpeg)$/;
if (staticFileRegex.test(path)) {
// Archivos con hash son inmutables - cache por 1 año
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
}
},
})
);
// 🔥 Optimización #3: Inyección dinámica de resource hints
app.use('/**', async (req, res, next) => {
try {
const response = await angularApp.handle(req);
if (!response) {
return next();
}
if (response.status >= 200 && response.status < 300) {
const html = await response.text();
const preloadLinks = [];
// Preload de imagen LCP (Largest Contentful Paint)
// Este fue un caso en específico de nuestra aplicación, en el aplicativo existe una page de inicio con un banner.
if (req.url === '/inicio' || req.url === '/' || req.url.startsWith('/inicio')) {
preloadLinks.push(
`<link rel="preload" as="image" href="assets/img/banner-1200w.webp"
imagesrcset="assets/img/banner-400w.webp 400w, assets/img/banner-800w.webp 800w,
assets/img/banner-1200w.webp 1200w, assets/img/banner-1600w.webp 1600w"
imagesizes="(max-width: 768px) 330px, (max-width: 1024px) 800px, 1200px"
fetchpriority="high">`
);
}
// Preload de JavaScript crítico
const mainScriptRegex = /<script[^>]+src="([^"]*main[^"]*\.js)"[^>]*>/;
const mainScriptMatch = mainScriptRegex.exec(html);
if (mainScriptMatch) {
preloadLinks.push(`<link rel="modulepreload" href="${mainScriptMatch[1]}">`);
}
// Preload de polyfills
const polyfillsRegex = /<script[^>]+src="([^"]*polyfills[^"]*\.js)"[^>]*>/;
const polyfillsMatch = polyfillsRegex.exec(html);
if (polyfillsMatch) {
preloadLinks.push(`<link rel="modulepreload" href="${polyfillsMatch[1]}">`);
}
// Inyectar todos los preload links antes de </head>
if (preloadLinks.length > 0) {
const modifiedHtml = html.replace(
'</head>',
` ${preloadLinks.join('\n ')}\n</head>`
);
const modifiedResponse = new Response(modifiedHtml, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
return writeResponseToNodeResponse(modifiedResponse, res);
}
}
return writeResponseToNodeResponse(response, res);
} catch (error) {
next(error);
}
});
if (isMainModule(import.meta.url)) {
const port = process.env['PORT'] || 4200;
app.listen(port, () => {
console.log(`Node Express server listening on http://localhost:${port}`);
});
}
export const reqHandler = createNodeRequestHandler(app);
Esto puede variar de acuerdo a las necesidades de cada proyecto, pero al final el objetivo es optimizar todo el JavaScript y CSS posible.
Vamos a desglosar cada optimización:
Optimización #1: compresión automática
app.use(compression());
Esto habilita compresión Gzip/Brotli para todas las respuestas. Resultados reales:
HTML: ~100KB → ~25KB (75% reducción)
CSS: 626KB → ~125KB (80% reducción)
JavaScript: ~60-70% reducción promedio
Total ahorro de bandwidth: ~1.4MB por carga inicial
Optimización #2: cache inteligente
setHeaders: (res, path) => {
const staticFileRegex = /\.(js|css|woff2?|ttf|eot|svg|webp|png|jpg|jpeg)$/;
if (staticFileRegex.test(path)) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
}
}
Los archivos con hash en su nombre (como main.a8b9c7d6.js) nunca cambian. Si cambia el contenido, cambia el hash y el nombre del archivo. Por lo tanto:
Cache por 1 año (31536000 segundos)
Header
immutable= el navegador nunca valida si el archivo cambióSegunda visita = carga instantánea desde cache local
Optimización #3: preload dinámico de recursos críticos
Esta es la optimización más sofisticada. El servidor:
Detecta la ruta actual (ej:
/inicio)Inyecta preload hints específicos para esa ruta
Para
/inicio: Precarga la imagen del banner (que es el LCP)Para todas las rutas: Precarga JavaScript crítico (main.js, polyfills.js)
¿Por qué esto es importante?
Normalmente, el navegador descubre recursos en este orden:
HTML (0ms)
CSS (después de parsear HTML - ~100ms)
Imágenes en CSS (después de parsear CSS - ~500ms)
JavaScript (después de parsear HTML - ~100ms)
Con preload hints inyectados en el servidor:
HTML (0ms)
Todos los recursos críticos en paralelo (0ms - el navegador los ve inmediatamente en el HTML)
Resultado: Ahorramos ~400-500ms en descubrimiento de recursos.
Paso 5: optimización de SEO
El SEO no es solo sobre SSR - también necesitas meta tags correctos. Aquí está nuestro index.html optimizado:
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>*Titulo del aplicativo*</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- SEO Meta Tags -->
<meta
name="description"
content="*El contenido de tu sitio*."
/>
<meta
name="keywords"
content="*Palabras clave de tu sitio web*"
/>
<meta name="author" content="XM" />
<meta name="robots" content="index, follow" />
<!-- Open Graph para redes sociales -->
<meta property="og:title" content="*Contenido de tu sitio para redes sociales*" />
<meta
property="og:description"
content="*Descripción completa de tu sitio*"
/>
<meta property="og:type" content="website" />
<meta property="og:locale" content="es_CO" />
<!-- Structured Data (JSON-LD) -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "*Nombre de la aplicación*",
"description": "*Descripción de la aplicación*",
"applicationCategory": "BusinessApplication",
"operatingSystem": "Web",
"author": {
"@type": "Organization",
"name": "*Nombre de la aplicación*"
}
}
</script>
</head>
<body>
<app-root></app-root>
</body>
</html>
Structured Data (JSON-LD) le dice a Google exactamente qué es tu aplicación. Esto puede resultar en rich snippets en resultados de búsqueda.
Paso 6: comandos NPM optimizados
Configuramos scripts NPM específicos para SSR:
{
"scripts": {
"start": "ng serve",
"build": "ng build",
"serve:ssr": "npm run build && npm run serve:ssr:app",
"serve:ssr:app": "node dist/app/server/server.mjs",
"build:develop": "ng build --configuration develop",
"serve:ssr:develop": "npm run build:develop && npm run serve:ssr:app"
}
}
Importante: Durante desarrollo diario, usa npm start (sin SSR) para hot reload rápido. Solo usa npm run serve:ssr cuando necesites:
Probar performance real
Verificar SEO
Testing antes de deployment
Resultados: los números no mienten
Después de implementar todas estas optimizaciones, los resultados fueron dramáticos:
Lighthouse scores
| Métrica | Antes | Después | Mejora |
| Performance | 55 | 90+ | +64% |
| SEO | N/A | 100 | 100% |
| First Contentful Paint | 2.1s | 0.8s | 62% más rápido |
| Largest Contentful Paint | 3.5s | 1.4s | 60% más rápido |
| Total Blocking Time | 450ms | 120ms | 73% reducción |
| Cumulative Layout Shift | 0.15 | 0.02 | 87% mejora |
Transfer size
| Recurso | Antes | Después | Ahorro |
| HTML | ~100KB | ~25KB | 75% |
| CSS | 626KB | ~125KB | 80% |
| JavaScript | ~800KB | ~280KB | 65% |
| Total Primera Carga | ~2MB | ~600KB | 70% |
Experiencia del usuario real
Lo más importante: Los usuarios sienten la diferencia.
Antes: Pantalla en blanco por 2-3 segundos → contenido
Después: Contenido visible en <1 segundo → interactivo en 1-2 segundos
Desafíos y lecciones aprendidas
1. No todo funciona en el servidor
Algunos problemas que enfrentamos:
window is not defined: Código que accede a window, document, o localStorage falla en Node.js.
Solución:
import { isPlatformBrowser } from '@angular/common';
import { PLATFORM_ID, Inject } from '@angular/core';
constructor(@Inject(PLATFORM_ID) private platformId: Object) {}
ngOnInit() {
if (isPlatformBrowser(this.platformId)) {
// Código que usa window/document/localStorage
const data = localStorage.getItem('key');
}
}
2. El build es más lento
Con SSR, el build toma ~30-60 segundos en lugar de ~10-20 segundos.
Nuestra solución:
Desarrollo diario:
npm start(sin SSR, hot reload rápido)Testing/deployment:
npm run serve:ssr(con SSR, build completo)
3. HTTP calls duplicados
Sin optimización, las llamadas HTTP se ejecutan tanto en el servidor como en el cliente.
Solución: Transfer State API
import { TransferState, makeStateKey } from '@angular/platform-browser';
const DATA_KEY = makeStateKey<any>('dataKey');
// En el servidor: Guarda datos
this.transferState.set(DATA_KEY, data);
// En el cliente: Recupera datos guardados
const cachedData = this.transferState.get(DATA_KEY, null);
if (cachedData) {
return of(cachedData); // No hace HTTP call
}
4. MSAL y authentication
Azure MSAL (Microsoft Authentication Library) no funciona en el servidor porque depende de window y cookies.
Nuestra solución:
if (isPlatformBrowser(this.platformId)) {
// Inicializar MSAL solo en el navegador
this.msalService.initialize();
}
Mejores prácticas y recomendaciones
Después de este viaje, aquí están nuestras recomendaciones:
1. Prioriza el LCP
El Largest Contentful Paint es la métrica más importante para usuarios. Enfoca tus esfuerzos en:
Identificar el elemento LCP (DevTools → Performance → Experience)
Preload de imágenes/recursos críticos para ese elemento
Optimizar el servidor para responder rápido (<200ms ideal)
2. Critical CSS inlining es no-negociable
inlineCritical: true debe estar habilitado en producción. Esta single optimización eliminó 750ms de render-blocking en nuestro caso.
3. Compresión es gratis
app.use(compression()) es literalmente una línea de código para 70% de ahorro en bandwidth. No hay excusa para no usarlo.
4. Cache agresivo para archivos con hash
Archivos con hash son inmutables por definición. Configura:
Cache-Control: public, max-age=31536000, immutable
No max-age=3600 o max-age=86400. Un año completo.
5. Mide, optimiza, mide de nuevo
No asumas. Mide todo:
Lighthouse antes y después
WebPageTest para waterfall de recursos
Chrome DevTools Performance para análisis detallado
6. SSR no es todo o nada
Puedes implementar SSR incrementalmente:
Fase 1: SSR básico sin optimizaciones
Fase 2: Critical CSS inlining
Fase 3: Compresión
Fase 4: Preload hints dinámicos
Fase 5: Transfer State para APIs
Cada fase aporta valor independiente.
Conclusión: ¿vale la pena SSR?
Después de semanas de trabajo implementando SSR, la respuesta es un rotundo sí - pero con matices:
SSR es esencial si:
Necesitas SEO (contenido público)
El performance es crítico para el negocio
Tienes recursos para mantenerlo
SSR puede no valer la pena si:
Tu aplicación está detrás de login (no necesita SEO)
Es una aplicación interna con usuarios en buenas conexiones
Tu equipo no tiene experiencia con Node.js
En nuestro caso, el impacto fue transformador:
+64% en Lighthouse Performance (55 → 90+)
60% más rápido Largest Contentful Paint
70% menos bandwidth (ahorro de costos)
100/100 SEO score
Pero más importante que los números: Los usuarios están felices. Y al final del día, eso es lo que importa.
Recursos y referencias
Sobre el autor: Este artículo documenta la implementación real de SSR en una aplicación empresarial Angular para el mercado energético colombiano. Todo el código mostrado está en producción sirviendo a usuarios reales.
¿Preguntas o comentarios? Me encantaría escuchar sobre tus experiencias con SSR. ¿Has implementado SSR en tu aplicación? ¿Qué desafíos encontraste? Déjalo en los comentarios abajo.



