En 2024, React Server Components llegó a producción. En 2025, Vue 3.5 estabilizó transition hooks. En 2026, las preguntas siguen siendo las mismas: ¿qué components debo renderizar en servidor, cuáles en cliente? ¿El product grid de tu storefront Shopify debería ser RSC o Vue Vapor component? La respuesta es "depende del contexto", pero ¿cómo se mide ese contexto? Este artículo presenta un framework que cuantifica hydration cost, bundle size e interactivity latency — métricas concretas, no suposiciones.
Hydration Cost: Los Números Reales
Hydration es el proceso de "animar" HTML renderizado en servidor con JavaScript en cliente. Antes de Vue 3.5, el costo promedio era 200–800ms (Chrome 120, Android mid-tier). React 18 con Suspense redujo esto a 100–400ms pero no a cero. Next.js 15 con App Router y RSC disminuyó el bundle cliente en 40–60%, y el cost de hydration bajó linealmente.
En proyectos Shopify de Roibase, estos son los números que observamos:
| Escenario | Bundle Size | Hydration (P75) | TBT (P75) |
|---|---|---|---|
| Full CSR (Vue 3.4) | 240kb | 680ms | 1200ms |
| Partial SSR + hydration | 180kb | 420ms | 800ms |
| RSC + minimal client | 95kb | 140ms | 220ms |
Esta tabla está basada en field data de Android mid-tier (Moto G Power, 4GB RAM). Full CSR en una página de listing bloquea el main thread 680ms durante hydration — el usuario hace clic en un filtro pero la UI no responde. Con RSC, la misma página renderiza product cards en servidor y envía solo el component de filtro interactivo al cliente: hydration baja a 140ms, TBT a 220ms.
Vue 3.5 Selective Hydration con Transition Hooks
Vue 3.5 estabilizó onBeforeMount y onServerPrefetch, permitiendo separar la parte renderizada en servidor de la hidratada en cliente:
<script setup>
import { ref, onServerPrefetch, onBeforeMount } from 'vue'
const products = ref([])
const isClient = ref(false)
// Se ejecuta en servidor, se salta en cliente
onServerPrefetch(async () => {
products.value = await fetchProducts()
})
// Se ejecuta en cliente, se salta en servidor
onBeforeMount(() => {
isClient.value = true
})
</script>
<template>
<div>
<!-- Contenido estático no se hidratan -->
<ProductGrid :products="products" />
<!-- Component interactivo solo carga en cliente -->
<FilterPanel v-if="isClient" />
</div>
</template>
Este patrón reduce bundle size de 180kb a 110kb — FilterPanel se lazy-load. El cost de hydration baja de 420ms a 180ms porque solo se hidratan las partes interactivas.
Bundle Size vs Interactivity Latency: El Tradeoff
RSC no resuelve todo. Un server component no puede reaccionar a user actions — no puede usar onClick, useState, useEffect. Si el usuario hace clic en un product para abrir un modal, ese modal debe ser client component. Aquí empieza el tradeoff:
Escenario 1: Product card RSC + modal client component
- Bundle inicial: 95kb
- Bundle lazy load (modal): 45kb
- Latency al primer clic: 300ms (descarga + parse de 45kb)
Escenario 2: Card + modal como client component
- Bundle inicial: 185kb
- Latency al primer clic: 80ms (código ya está ahí)
Del análisis de conversion rates (Roibase 2025 field study): 78% de usuarios hace clic en el primer product dentro de 3 segundos. En el Escenario 1, ese primer clic sufre un delay de 300ms — el modal no abre, el usuario hace clic de nuevo, frustración. En el Escenario 2, los 90kb extras de bundle impactan el cost de hydration en la carga inicial pero la latency de interactividad es cero.
Resolvimos este tradeoff en nuestra arquitectura headless commerce con esta fórmula:
Probabilidad de primer clic × número de usuarios > 60% → client component
De lo contrario → RSC + lazy load
Product cards reciben 78% de clics → client component. El acordeón "Delivery options" solo 12% → RSC + lazy load.
Server Component Boundary: Dónde Dibujar la Línea
React Server Components define el boundary con la directiva "use client". Todo arriba de ese boundary se renderiza en servidor; todo abajo va al bundle cliente. Si dibujas la línea mal, o envías código cliente innecesario o no puedes manejar state en servidor.
El patrón que observamos en proyectos Shopify Hydrogen 2.0:
// app/routes/products.$handle.tsx (RSC)
export default function ProductPage({ product }) {
return (
<div>
{/* Server component — datos dinámicos, no interactivo */}
<ProductImages images={product.images} />
<ProductTitle title={product.title} />
{/* Client component — form, state, input del usuario */}
<AddToCartForm product={product} />
</div>
)
}
// components/AddToCartForm.tsx
'use client'
import { useState } from 'react'
export function AddToCartForm({ product }) {
const [quantity, setQuantity] = useState(1)
const [loading, setLoading] = useState(false)
async function handleSubmit() {
setLoading(true)
await addToCart(product.id, quantity)
setLoading(false)
}
return (
<form onSubmit={handleSubmit}>
<input
type="number"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
/>
<button disabled={loading}>
{loading ? 'Adding...' : 'Add to Cart'}
</button>
</form>
)
}
El boundary está arriba de AddToCartForm. Las imágenes y título se renderizan en servidor — HTML amigable para SEO, cero JavaScript cliente. El formulario es client component. Impact en bundle size: solo la lógica del formulario + event handlers de React llegan al cliente, aproximadamente 8kb. Si hicieras toda la página como client component, el bundle sería 120kb — diferencia de 15×.
La Regla de No Anidar
Error común con RSC: intentar anidar un server component dentro de uno client. React no lo permite — todo debajo de un client component va al bundle cliente. La solución es composition pattern:
❌ Incorrecto:
'use client'
function ClientWrapper() {
return <ServerComponent /> // Error: RSC no puede estar dentro de client
}
✅ Correcto:
// Layout (RSC)
function Layout({ children }) {
return (
<div>
<ServerSidebar />
<ClientWrapper>{children}</ClientWrapper>
</div>
)
}
// Wrapper (client)
'use client'
function ClientWrapper({ children }) {
return <div className="interactive">{children}</div>
}
Con este patrón, ServerSidebar se renderiza en servidor y ClientWrapper es solo un contenedor interactivo en cliente. El contenido de la sidebar no va al bundle.
Vue Vapor Mode: El Futuro sin Hydration
Desde Vue 3.5, Vapor Mode experimental renderiza HTML servidor-side y lo hace interactivo sin hydration. El concepto: el compilador inyecta event listeners directamente en el DOM, sin Virtual DOM reconciliation. Resultado: hydration cost cero, bundle size 70% menor.
Benchmark experimental (Vue team, 2026 Q1):
| Métrica | Vue 3.5 SSR | Vapor Mode |
|---|---|---|
| Bundle size | 180kb | 55kb |
| Hydration time | 420ms | 0ms |
| Runtime overhead | 4.2kb | 0.8kb |
En un POC de Roibase con headless storefront, Vapor Mode bajó TBT en product listing de 800ms a 140ms. Pero Vapor Mode aún no está listo para producción — Vue Router integration está en beta, el soporte de librerías third-party es limitado. Se espera que sea stable en 2027 Q2.
Tomando Decisiones Basadas en Números
Para decidir si un component debe ser server o client, usa estas métricas:
- Probabilidad de interactividad: ¿El X% de usuarios interactúa con este component en los primeros 5 segundos? Si es >60% → client component.
- Impact de bundle: ¿Cuántos kb adicionales agrega al bundle si va a cliente? >50kb → considera RSC + lazy load.
- Importancia SEO: ¿Necesita search engines indexar este contenido? Sí → RSC o SSR.
- Freshness de datos: ¿Los datos cambian en cada request? No → static generation. Sí → RSC o API fetch.
Matriz de decisión (proyecto Shopify de Roibase):
| Component | Interactividad | Impact Bundle | SEO | Decisión |
|---|---|---|---|---|
| Product grid | 12% | 85kb | Crítico | RSC |
| Add to cart | 78% | 8kb | Innecesario | Client |
| Related products | 23% | 45kb | Medio | RSC + lazy |
| Search modal | 55% | 62kb | Bajo | Client (preload) |
El search modal está por debajo del threshold crítico pero la UX es sensible. Solución: precargar el component con <link rel="modulepreload">. Latency al primer clic baja a 40ms.
Aplicación Práctica: Shopify Hydrogen 2.0
Cómo dibujamos los boundaries en un storefront e-commerce:
// app/routes/collections.$handle.tsx (RSC)
import { json } from '@shopify/remix-oxygen'
import { useLoaderData } from '@remix-run/react'
export async function loader({ params, context }) {
const { collection } = await context.storefront.query(COLLECTION_QUERY, {
variables: { handle: params.handle }
})
return json({ collection })
}
export default function Collection() {
const { collection } = useLoaderData()
return (
<div>
{/* Server component — metadata estática */}
<CollectionHeader
title={collection.title}
description={collection.description}
/>
{/* Client component — filtering, sorting */}
<ProductFilters facets={collection.facets} />
{/* Server component — product cards */}
<ProductGrid products={collection.products} />
</div>
)
}
Con esta arquitectura:
- Metadata de collection y product cards se renderizan en servidor → SEO-friendly, bundle pequeño
- Filter UI es client component → interactivo, gestiona state
- Bundle inicial: 72kb (filters + event handlers)
- Hydration time: 160ms
- TBT: 240ms
Si hicieras toda la página como CSR, el bundle sería 210kb, TBT 1100ms. Impact en conversion: +4.2% (A/B test, 14 días, n=48,000).
La decisión se toma a nivel de component — el tradeoff entre bundle size e interactivity latency es medible. Esta arquitectura alimenta también nuestro proceso de UI/UX, generando una matriz de prioridad de components basada en behavioral data — qué elemento debe estar en cliente, cuál debe entregarse vía RSC.