Skip to main content

Command Palette

Search for a command to run...

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

Updated
11 min readView as Markdown
Server-Side Rendering en Angular: cómo pasé de 55 a 90+ en performance con SSR
L

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í:

  1. El usuario solicita la página

  2. El servidor envía un HTML casi vacío con un <app-root></app-root>

  3. El navegador descarga JavaScript (a veces varios MB)

  4. JavaScript se ejecuta y renderiza todo el contenido

  5. 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:

  1. El usuario solicita la página

  2. El servidor ejecuta Angular en Node.js

  3. Angular genera HTML completo con todo el contenido

  4. El servidor envía HTML pre-renderizado al navegador

  5. El usuario ve contenido inmediatamente

  6. 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:

  1. Identifique el CSS necesario para el primer render (above-the-fold)

  2. Inyecte ese CSS directamente en el <head>

  3. 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:

  1. Detecta la ruta actual (ej: /inicio)

  2. Inyecta preload hints específicos para esa ruta

  3. Para /inicio: Precarga la imagen del banner (que es el LCP)

  4. 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:

  1. HTML (0ms)

  2. CSS (después de parsear HTML - ~100ms)

  3. Imágenes en CSS (después de parsear CSS - ~500ms)

  4. JavaScript (después de parsear HTML - ~100ms)

Con preload hints inyectados en el servidor:

  1. HTML (0ms)

  2. 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étricaAntesDespuésMejora
Performance5590++64%
SEON/A100100%
First Contentful Paint2.1s0.8s62% más rápido
Largest Contentful Paint3.5s1.4s60% más rápido
Total Blocking Time450ms120ms73% reducción
Cumulative Layout Shift0.150.0287% mejora

Transfer size

RecursoAntesDespuésAhorro
HTML~100KB~25KB75%
CSS626KB~125KB80%
JavaScript~800KB~280KB65%
Total Primera Carga~2MB~600KB70%

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 - 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.