wandres.dev
ESTADO EN SSR · serializar e hidratar

Estado por petición

En el navegador, un módulo que crea un store al importarse es un patrón inofensivo: hay un proceso, un usuario y una pestaña, así que el singleton y la sesión coinciden. En el servidor, ese mismo módulo se evalúa una vez y su instancia sobrevive a miles de peticiones de miles de usuarios distintos, convirtiéndose en un canal de fuga silencioso por el que los datos de una sesión aparecen en la respuesta de otra. Esta lección explica por qué el ciclo de vida de un módulo en Node no es el ciclo de vida de una petición, cataloga las variantes del mismo pecado —store global, cliente de datos compartido, cache montada en el ámbito del módulo, variables de configuración mutadas en caliente—, y desarrolla los tres mecanismos correctos para aislar el estado por petición: la fábrica que construye una instancia nueva, el contexto que la transporta sin pasarla a mano, y el almacenamiento asíncrono que la asocia al hilo lógico de ejecución. Termina con la parte más incómoda: por qué este fallo es casi invisible en desarrollo y qué pruebas lo delatan antes de que llegue a producción.

⏱ 19 min

Hay una clase de error que no rompe nada, no lanza excepciones, no aparece en los registros y pasa todas las pruebas: simplemente le muestra a un usuario los datos de otro. Nace de una asimetría que casi nadie enuncia al aprender SSR, porque el código parece el mismo a ambos lados. En el navegador, un módulo se evalúa una vez por pestaña, y esa pestaña pertenece a una persona: si creas un store en el ámbito del módulo, tendrás exactamente un store por usuario, que es justo lo que querías. En el servidor, el mismo módulo se evalúa una vez por proceso, y ese proceso atiende a todas las personas que llegan: el mismo store recibe las escrituras de la petición de Ana mientras responde a la petición de Luis. El singleton, que en el cliente era una decisión de arquitectura razonable, en el servidor se convierte en memoria compartida entre desconocidos. Lo peor no es la fuga: es que el patrón se comporta perfectamente en desarrollo, donde solo hay un usuario, un navegador y una petición cada vez, y solo revela su naturaleza cuando el tráfico real solapa peticiones concurrentes. Esta lección va sobre reconocer el patrón a simple vista y sustituirlo por la única disciplina que funciona: un estado nuevo por cada petición, sin excepciones.

🎯 Al terminar esta lección sabrás
  • Explicar por qué el ciclo de vida de un módulo en el servidor abarca miles de peticiones y muchos usuarios.
  • Reconocer las variantes del singleton contaminado: store global, cliente compartido, cache de módulo y configuración mutada.
  • Aislar el estado con los tres mecanismos correctos: fábrica por petición, contexto de transporte y almacenamiento asíncrono.
  • Diseñar pruebas de concurrencia que delaten la fuga antes de que llegue a producción.

Un proceso, muchos usuarios

El sistema de módulos evalúa cada archivo una sola vez y guarda el resultado en cache. Todo lo que escribas en el ámbito superior de un módulo se ejecuta en la primera importación y su resultado persiste mientras el proceso viva. En el navegador esa vida dura lo que dura la pestaña. En el servidor dura lo que dura el proceso: horas, días, decenas de miles de peticiones.

De ahí se sigue la regla que gobierna todo este nivel. En el servidor, el ámbito del módulo no es el ámbito del usuario; el ámbito del usuario es la petición. Cualquier dato que dependa de quién pregunta debe nacer con la petición y morir con ella. Cualquier dato colocado por encima de esa frontera es, por definición, compartido por todos.

Hay un agravante propio de JavaScript que multiplica la superficie del problema. En un servidor con un hilo por petición, dos peticiones simultáneas serían dos pilas independientes y la contaminación exigiría memoria explícitamente compartida. En Node hay un solo hilo y un bucle de eventos, de modo que las peticiones no se ejecutan en paralelo sino intercaladas: cada espera devuelve el control al bucle, que puede reanudar la ejecución de otra petición sobre el mismo estado del módulo. No hace falta multinúcleo ni condiciones de carrera exóticas; basta una consulta a la base de datos entre la escritura y la lectura.

La sutileza que hace peligroso el asunto es que no todo lo que vive en el módulo es dañino. Una función pura, una constante de configuración, una expresión regular compilada, un pool de conexiones sin identidad de usuario: todo eso se comparte sin problema y compartirlo es exactamente lo que quieres, porque construirlo por petición sería un derroche. Lo que jamás puede compartirse es el estado que codifica la respuesta a la pregunta quién eres tú.

flowchart TD
M[modulo evaluado una vez al arrancar el proceso] --> S[store creado en el ambito del modulo]
A[peticion de Ana] --> S
B[peticion de Luis] --> S
S --> R1[respuesta a Ana con datos de Luis]
S --> R2[respuesta a Luis con datos de Ana]
style S fill:#f38ba8,color:#11111b
style R1 fill:#f38ba8,color:#11111b
style R2 fill:#f38ba8,color:#11111b

El pecado mortal y sus disfraces

La forma pura del error cabe en cuatro líneas y su aspecto es tan familiar que resulta difícil sospechar de ella.

// PECADO MORTAL: una unica instancia para todo el proceso
export const store = crearStore({ usuario: null, carrito: [] })

export async function cargarUsuario(id: string) {
  store.set({ usuario: await buscarUsuario(id) }) // pisa al usuario anterior
}

Lo grave no es solo que la segunda petición sobrescriba a la primera. Es que entre el momento en que la petición de Ana escribe y el momento en que renderiza su HTML puede intercalarse cualquier operación asíncrona de la petición de Luis, porque el bucle de eventos cede el control en cada espera. La ventana de contaminación no es teórica: es exactamente el tiempo que tardan tus consultas a la base de datos.

El patrón se disfraza de varias formas, y conviene tener el catálogo a mano porque las últimas dos rara vez se reconocen como el mismo error.

🧺

Store global

Un store de estado creado al importar el módulo. Es la forma más obvia y la que la mayoría detecta, aunque solo después de haber leído sobre el problema.

🗄️

Cliente de datos compartido

Una instancia única de cliente de consultas o de cache. Comparte la cache entre usuarios, de modo que la respuesta privada de uno queda disponible para la clave de otro.

🧠

Memoización de módulo

Un mapa declarado arriba para no repetir trabajo. Funciona hasta que la clave depende del usuario y el valor guardado pertenece a la persona equivocada.

⚙️

Configuración mutada

Un objeto de configuración importado y modificado en caliente durante una petición: idioma, zona horaria, banderas de funcionalidad. La mutación persiste para todos los que vengan después.

⚠️
La entrega de la respuesta no marca el final de la petición

Un error habitual es asumir que en cuanto se envía el HTML el estado deja de importar. No es así: las operaciones asíncronas que quedaron pendientes —un registro que se escribe después, una revalidación en segundo plano, una promesa no esperada— siguen ejecutándose y siguen viendo el estado del módulo, que para entonces ya pertenece a otra persona. La duración real de una petición no es la de su respuesta, sino la de la última tarea que arrancó.

Tres mecanismos para aislar por petición

La solución conceptual es única —una instancia nueva por petición— y admite tres implementaciones que se combinan según la profundidad a la que haya que llevar la instancia.

La primera es la fábrica. En lugar de exportar la instancia, se exporta la función que la construye, y el manejador de la petición la llama al principio de cada una. Es la base de todo lo demás y, por sí sola, resuelve la mayoría de los casos.

// CORRECTO: se exporta la fabrica, nunca la instancia
export const crearStorePeticion = () =>
  crearStore({ usuario: null, carrito: [] })

export async function manejar(peticion: Request) {
  const store = crearStorePeticion() // una instancia nueva, propia y aislada
  return renderizar(peticion, store)
}

La segunda es el contexto, para cuando la instancia debe llegar a componentes profundos sin atravesar toda la jerarquía como parámetro. El principio no cambia: el proveedor recibe la instancia creada por la fábrica en el arranque de la petición, y solo el subárbol de esa petición la ve.

La tercera es el almacenamiento local asíncrono, disponible en Node, que asocia un contenedor de datos al hilo lógico de ejecución y lo propaga automáticamente a través de las esperas. Es la única solución cuando la instancia debe ser accesible desde código profundo que no recibe parámetros ni vive dentro de un árbol de componentes, como una capa de acceso a datos o un registrador que quiere etiquetar cada línea con el identificador de la petición.

// Almacenamiento asincrono: la instancia viaja con el flujo de ejecucion
import { AsyncLocalStorage } from 'node:async_hooks'

const contexto = new AsyncLocalStorage<Store>()
export const storeActual = () => contexto.getStore()!

export const manejar = (peticion: Request) =>
  contexto.run(crearStorePeticion(), () => renderizar(peticion))
💡
Distingue lo caro y compartible de lo barato y privado

No conviertas la regla en superstición: no todo debe crearse por petición. Un pool de conexiones, un cliente HTTP sin credenciales de usuario o una plantilla compilada son caros de construir, no tienen identidad y deben vivir en el módulo. El criterio no es el coste sino la identidad: si el objeto guarda o puede llegar a guardar algo que responde a la pregunta de quién es el usuario actual, es por petición; si no, es del proceso. Cuando dudes, mira si existe algún camino por el que un dato del usuario pueda quedar dentro.

Por qué no lo verás en desarrollo

Este fallo tiene una propiedad estadística cruel: su probabilidad de manifestarse crece con la concurrencia, y en desarrollo la concurrencia es uno. Un solo navegador, un solo usuario, peticiones secuenciales y recargas del módulo entre cambios de código. El singleton se comporta impecablemente hasta que dos peticiones de usuarios distintos se solapan en el tiempo, algo que solo empieza a ocurrir cuando el sistema ya está sirviendo a gente real.

El entorno de desarrollo añade además un camuflaje adicional: el recargado en caliente reevalúa los módulos con frecuencia, así que el estado del singleton se limpia solo cada pocos segundos. Esa limpieza gratuita hace que el patrón parezca correcto justo donde más se le observa, y desaparece por completo en producción, donde el módulo se evalúa una vez y el estado se acumula durante días.

Detectarlo exige provocar deliberadamente lo que desarrollo nunca produce. Lanza dos peticiones concurrentes con sesiones distintas y una espera artificial en medio de la primera para forzar el solapamiento; si la respuesta de una contiene un rastro de la otra, la fuga está confirmada. Complementa esa prueba con una revisión estructural: busca en tu código toda exportación cuyo valor sea un objeto mutable creado al evaluar el módulo, y justifica una por una por qué puede compartirse.

// Prueba de solapamiento: dos sesiones distintas, una espera en medio
const [ra, rb] = await Promise.all([
  pedir('/perfil', { sesion: 'ana', retraso: 200 }),
  pedir('/perfil', { sesion: 'luis' }),
])

expect(ra).not.toContain('luis') // falla si el estado esta compartido
expect(rb).not.toContain('ana')

Una defensa complementaria y muy barata es hacer que la fuga sea imposible de ignorar en lugar de esperar a detectarla: marca cada instancia con el identificador de la petición que la creó y afirma, en el punto de renderizado, que la instancia que estás usando es la de la petición en curso. Convierte un fallo silencioso de datos en un error ruidoso que aparece la primera vez que se produce.

El singleton no es un patron: es una suposicion sobre cuantos observadores hay

Merece la pena detenerse en por qué este error es tan universal entre quienes vienen del frontend, porque la respuesta no es descuido sino un modelo mental heredado que dejó de aplicar sin avisar. Todo lo que aprendiste sobre estado global se formuló bajo un supuesto tácito y jamás enunciado: hay un usuario. Bajo ese supuesto, global y del usuario son sinónimos, y la fuente única de verdad puede vivir tranquilamente en el módulo porque el módulo y la sesión tienen exactamente la misma vida. El servidor no cambia la sintaxis ni las herramientas, y por eso el error resulta invisible: cambia el supuesto. En el servidor, global significa de todos, y esas dos palabras que en el cliente eran intercambiables aquí se vuelven opuestas. De ahí que la disciplina correcta no consista en memorizar qué librerías exigen una fábrica, sino en reaprender a leer el alcance de una declaración preguntando siempre cuántos observadores lo comparten. Un módulo evaluado por proceso tiene tantos observadores como usuarios simultáneos; una instancia creada por petición tiene exactamente uno. Y una vez que interiorizas esa pregunta, el catálogo de disfraces —store, cliente de datos, memoización, configuración— deja de ser una lista que memorizar y se convierte en la aplicación mecánica de un único criterio. Es también la razón profunda de que este sea el error más grave del nivel: los demás producen un parpadeo, una petición duplicada o un aviso en consola. Este produce una filtración de datos entre usuarios, que no es un bug de rendimiento sino un incidente de seguridad, y además uno que ninguna prueba unitaria escrita con un solo usuario podrá encontrar jamás.

⚔️ Provoca la fuga y ciérrala
  1. Crea a propósito un store en el ámbito de un módulo del servidor y escribe en él el identificador del usuario de cada petición.
  2. Lanza dos peticiones concurrentes con sesiones distintas, insertando una espera artificial en medio de la primera, y comprueba que la respuesta de una contiene datos de la otra.
  3. Sustituye la instancia exportada por una fábrica llamada al inicio de cada petición y repite la prueba hasta que el solapamiento sea inocuo.
  4. Lleva la instancia a un componente profundo mediante un proveedor de contexto, sin pasarla como parámetro por toda la jerarquía.
  5. Implementa la variante con almacenamiento local asíncrono para que una capa de acceso a datos obtenga la instancia sin recibirla, y comprueba que sobrevive a varias esperas encadenadas.
  6. Audita todas las exportaciones mutables creadas al evaluar módulos de servidor y escribe, para cada una, por qué es segura compartirla o cómo la has aislado.