Lo que aprendí del SSR en Vue (y por qué JSP ya era SSR)

Notas a partir de la charla Things I Learned from SSR (「從入門到被開除」 — del onboarding al despido, N cosas que aprendí del SSR) combinadas con una conversación propia sobre CSR vs SSR en Vue.

La base en dos párrafos

CSR (el flujo clásico de Vue 3 + Vite): el servidor manda un HTML casi vacío más el bundle de JS, y el navegador construye el DOM. SSR (típicamente Nuxt 3): el servidor ejecuta Vue, genera el HTML completo, y el navegador lo muestra al instante; luego Vue “adopta” ese DOM y le añade los event listeners — la hydration.

De la hydration sale la regla de oro del SSR: el código de servidor y cliente debe producir el mismo resultado. Date.now(), Math.random() o cualquier acceso a window son fuentes clásicas de hydration mismatch — uno de los bugs más frustrantes de debuggear.

Los bugs que solo existen en SSR

La parte más valiosa de la charla son los problemas que no existen en CSR, y todos comparten la misma causa raíz: el servidor vive indefinidamente; el navegador no.

Memory leaks y request pollution. En el browser cada navegación destruye el estado. En el servidor, la misma instancia de Node atiende miles de requests — un store definido como singleton global se comparte entre todos los usuarios: el estado se acumula o, peor, se filtra entre requests concurrentes. La solución es siempre el patrón factory: una instancia nueva por request.

// ❌ singleton global — compartido entre todos los requests
const store = { user: null }

// ✅ factory — instancia nueva por request
export function createStore() {
  return { user: null }
}

URLs relativas. En el browser fetch('/api/data') funciona porque existe window.location. En Node no hay origen implícito — axios.get('/api/posts') lanza Invalid URL. O configuras baseURL, o usas $fetch de Nuxt que lo resuelve internamente en ambos contextos.

El waterfall de awaits. Dos await secuenciales en <script setup> suman sus latencias (300ms + 200ms = 500ms); Promise.all las solapa (300ms). Y el combo useAsyncData + watch puede disparar un fetch duplicado al montar: Suspense resuelve el primero, y el watch — que acaba de montarse — lanza el segundo.

El paralelo que me interesa: JSP ya era esto

Trabajando con Spring Boot + JSP en unos proyectos y Vue en otros, este cuadro me ordenó las ideas:

JSP + jQueryNuxt SSR
¿Quién decide el orden de datos?Tú — imperativoEl framework — declarativo
¿Bloquea el render?Nunca — el HTML ya estáSí, hasta que el async setup resuelve
Render parcialGratisRequiere Suspense anidados

JSP se comporta más parecido a Nuxt SSR que a Vue CSR: el HTML llega renderizado del servidor y el JS es un añadido posterior — progressive enhancement, aunque en 2005 nadie lo llamara así. Quien viene de JSP + Spring Boot ya tiene la intuición del SSR; lo que cambia es quién orquesta (el framework en vez de ti) y el precio nuevo de la hydration.

Cuándo usar cada uno

Mi resumen operativo: app interna, dashboard o admin → CSR con Vue 3 + Vite consumiendo la API de Spring Boot. E-commerce, landing o contenido público con SEO crítico → SSR con Nuxt. Y si ya tienes JSP + Spring Boot renderizando en servidor… ya estás en la categoría SSR; piénsalo dos veces antes de migrar a CSR puro y perder eso.