Actores en el navegador y más allá: XState, workers y actores duraderos
El bucle de eventos del navegador lleva décadas siendo un runtime de actores sin que nadie lo llamara así: un planificador de un solo hilo, una cola de tareas que hace de buzón y una semántica de ejecución hasta el final que es exactamente el turno. Esta lección parte de ahí para leer XState v5 como encarnación del modelo, cruza la frontera del hilo con workers y clonado estructurado señalando dónde la memoria compartida reabre la puerta a las carreras, y termina en los actores duraderos de la nube: los Durable Objects con sus puertas de entrada que restauran la atomicidad del turno sobre una espera, y el actor virtual de Orleans que elimina el ciclo de vida al precio de no poder razonar sobre él.
Hay una ironía histórica en que la comunidad de JavaScript haya descubierto el modelo de actores como una novedad importada, porque el entorno en el que trabaja es, desde su primer día, un runtime de actores encubierto. Un hilo único, una cola de tareas que se atiende de una en una y una regla de ejecución hasta el final que impide que dos manejadores se solapen: eso es un buzón y eso es un turno, aunque la especificación no use esas palabras. Reconocer la equivalencia cambia la lectura de todo lo demás. XState v5 deja de parecer una librería con vocabulario exótico y se convierte en la formalización de algo que ya estaba ahí, y los workers, los Durable Objects y los granos de Orleans dejan de ser tecnologías inconexas para revelarse como el mismo modelo estirado sobre fronteras cada vez más caras de cruzar.
- Reconocer el bucle de eventos como planificador de actores con turno y buzón.
- Leer las API de XState v5 como la encarnación de dirección, envío y creación.
- Cruzar la frontera del hilo con aislamiento intacto y saber qué lo rompe.
- Comparar el actor duradero de la nube con el actor virtual y sus renuncias.
El bucle de eventos ya era un runtime de actores
La correspondencia es literal, no metafórica. La cola de tareas es el buzón. Encolar una tarea es enviar un mensaje. La regla de ejecución hasta el final —una tarea no se interrumpe a la mitad— es la atomicidad del turno, y es exactamente lo que garantiza que ningún otro manejador observe un estado intermedio. Lo que el entorno no trae es la otra mitad del modelo: no hay direcciones, no hay una frontera que impida que dos manejadores toquen el mismo objeto, y por tanto el aislamiento hay que imponerlo por disciplina. Un runtime de actores en el navegador no añade concurrencia, añade fronteras a una concurrencia que ya existía.
| Concepto del modelo | En el bucle de eventos | En XState v5 |
|---|---|---|
| Buzón | cola de tareas del hilo | cola interna del actor |
| Turno | tarea que corre hasta el final | macropaso completo del evento |
| Dirección | inexistente | referencia al actor |
| Envío | encolar una tarea | envío al destino |
| Creación | inexistente | creación de un hijo |
| Aislamiento | por disciplina | por frontera de la máquina |
La fila del aislamiento es la que justifica que exista una librería. Sin frontera, dos manejadores pueden mutar el mismo objeto en turnos distintos y producir estados que nadie diseñó, que es la versión suave y muy común de la carrera clásica: no hay corrupción de memoria, pero sí una secuencia de escrituras que ninguna especificación contemplaba. La máquina restituye la frontera declarando qué eventos acepta cada estado y prohibiendo estructuralmente que el estado se toque fuera de una transición.
XState v5: dirección, sistema y lógicas de actor
En la versión cinco la unidad central dejó de ser la máquina y pasó a ser el actor, y esa reorganización tiene consecuencias visibles en cada API. Una referencia de actor es la dirección de Hewitt: opaca, sin acceso al estado interno, solo apta para enviar. Crear un hijo es el segundo verbo. Y el reparto de lógicas —promesa, retrollamada, observable, transición, máquina— dice algo importante: no todo actor tiene que ser una máquina de estados, porque el modelo nunca lo exigió. Una promesa envuelta es un actor de un solo mensaje de salida y ciclo de vida corto, y eso basta para que participe del sistema con las mismas reglas que los demás.
import { setup, assign, sendTo, fromCallback } from 'xstate'
const puente = fromCallback<{ type: string }>(({ sendBack, receive }) => {
const worker = new Worker(new URL('./calculo.ts', import.meta.url), { type: 'module' })
worker.onmessage = (ev) => sendBack(ev.data) // el buzon del padre
receive((evento) => worker.postMessage(evento)) // el buzon del worker
return () => worker.terminate() // fin de vida del actor
})
export const panel = setup({
types: {
context: {} as { resultado: number | null },
events: {} as { type: 'CALCULAR' } | { type: 'RESULTADO'; valor: number },
},
actors: { puente },
}).createMachine({
initial: 'listo',
context: { resultado: null },
invoke: { id: 'motor', src: 'puente', systemId: 'motor' },
states: {
listo: {
on: {
CALCULAR: { actions: sendTo('motor', { type: 'CALCULAR' }) },
RESULTADO: { actions: assign({ resultado: ({ event }) => event.valor }) },
},
},
},
})
Registrar un actor con un identificador de sistema permite que cualquier otro lo alcance por nombre sin haber recibido nunca su dirección. Es enormemente práctico para servicios de sesión, de autenticación o de telemetría, y viola la ley de localidad de la lección primera: convierte una capacidad delegada en autoridad ambiental. Úsalo para el puñado de servicios verdaderamente globales y no para ahorrarte pasar una referencia entre padre e hijo, porque cada nombre global es una arista del grafo de comunicación que ya no se puede leer en el código.
La fricción real llega cuando se intenta cobrar la transparencia de ubicación que el modelo promete. Una referencia de actor en XState es un objeto de JavaScript vivo en la memoria del hilo: no se serializa, no viaja y no significa nada al otro lado de una frontera. Para que un actor pueda mudarse hacen falta tres cosas que el runtime no da hechas: direcciones que sean datos y no punteros, un transporte que las resuelva, y eventos que sean serializables de principio a fin. La tercera es la que más disciplina exige y la que más se agradece después, porque obliga a que los eventos sean datos planos en lugar de objetos con métodos o cierres, y esa restricción es justo lo que permite registrarlos, reproducirlos y auditarlos.
Cruzar el hilo: workers, clonado y la puerta abierta
Un worker es el actor mejor implementado que ofrece la plataforma: aislamiento total de memoria, una cola de mensajes propia, y un envío que copia el contenido en lugar de compartirlo. El clonado estructurado no es un detalle de rendimiento sino el mecanismo que hace cierto el aislamiento: al copiar, garantiza que emisor y receptor no puedan observar la misma celda de memoria jamás. Los objetos transferibles añaden un matiz elegante: en vez de copiar, mueven la propiedad y dejan inutilizable el original en el emisor, que es una transferencia lineal de propiedad con el mismo espíritu que el sistema de tipos de Rust y con el mismo efecto de no dejar dos dueños vivos.
flowchart LR UI[hilo principal como actor] -- postMessage con copia --> W1[worker de calculo] W1 -- resultado --> UI UI -- postMessage --> W2[worker de sincronizacion] W2 -- eventos --> UI SAB[memoria compartida] -. reabre carreras reales .-> W1 SAB -. reabre carreras reales .-> UI style UI fill:#89b4fa,color:#11111b style SAB fill:#f38ba8,color:#11111b
La única puerta que reabre las carreras verdaderas es la memoria compartida entre hilos, y conviene entender que no es una versión suave del problema: dos hilos escribiendo el mismo búfer sin sincronizar producen carreras de datos con toda su fenomenología clásica, incluidas las reordenaciones del compilador y del procesador que las operaciones atómicas existen para domar. La regla de diseño es simple de enunciar y difícil de sostener bajo presión de rendimiento: usa memoria compartida solo dentro de una frontera bien delimitada, con operaciones atómicas explícitas, y nunca la expongas como si fuera parte del protocolo de mensajes.
Actores duraderos: la nube redescubre el buzón
Cuando el actor tiene que sobrevivir al proceso, aparecen dos familias con filosofías opuestas y vale la pena verlas juntas.
| Rasgo | Actor en proceso | Worker | Objeto duradero | Grano virtual |
|---|---|---|---|---|
| Identidad | referencia local | referencia al worker | identificador global derivable de un nombre | tipo más clave de dominio |
| Existencia | mientras el padre viva | mientras no se termine | se crea al primer mensaje | conceptualmente siempre existe |
| Estado | en memoria | en memoria | almacenamiento colocado y transaccional | persistencia por proveedor |
| Concurrencia | un turno | un turno | un turno con puerta de entrada | un turno, reentrante si se pide |
| Fallo | lo decide el padre | lo decide quien lo creó | se reactiva en otra máquina | se reactiva sin que nadie lo pida |
Los objetos duraderos aportan una idea que cierra el círculo con la lección del buzón. Su puerta de entrada encola los mensajes que llegan mientras una operación de almacenamiento está en vuelo, de modo que el turno vuelve a ser atómico incluso atravesando una espera: la plataforma resuelve por ti el problema del turno partido en vez de pedirte disciplina. A cambio, cada objeto es una instancia única en todo el mundo para su identificador, lo que lo convierte a la vez en una frontera de consistencia perfecta y en un punto de serialización cuyo rendimiento máximo es el de un solo turno detrás de otro.
// Un actor duradero: identidad derivable, estado colocado, turno protegido.
export class Sala {
private estado: DurableObjectState
constructor(estado: DurableObjectState) {
this.estado = estado
}
async fetch(peticion: Request): Promise<Response> {
const evento = await peticion.json()
// La lectura y la escritura ocurren dentro del mismo turno logico:
// los mensajes que lleguen mientras tanto quedan encolados por la plataforma.
const votos = (await this.estado.storage.get<number>('votos')) ?? 0
await this.estado.storage.put('votos', votos + 1)
return Response.json({ votos: votos + 1, evento })
}
}
// La direccion se calcula, no se consulta a ningun directorio.
const referencia = (env: Env, nombre: string) =>
env.SALAS.get(env.SALAS.idFromName(nombre))
Compara ese fragmento con el manejador partido de la lección anterior y verás la diferencia de fondo: aquí también hay una espera en medio del turno, pero la plataforma retiene los mensajes entrantes mientras dura, de modo que la atomicidad que en el navegador tenías que defender con disciplina la sostiene el runtime. Es la misma garantía del modelo, comprada con infraestructura en lugar de con convenciones, y por eso un objeto duradero admite código secuencial ingenuo sin volverse incorrecto.
El actor virtual elige el camino contrario. En ese modelo un grano no se crea ni se destruye: existe siempre por definición, y el runtime lo activa cuando llega un mensaje y lo desactiva cuando lleva tiempo ocioso. Desaparecen el ciclo de vida, el registro de direcciones y la pregunta de si el destinatario está vivo, y el programador escribe como si todas las entidades del dominio estuvieran instanciadas a la vez. El precio es coherente con la ganancia: si nadie crea ni supervisa, tampoco hay un padre con contexto para decidir la reparación, y la recuperación se reduce a reactivar en otro nodo. Se gana simplicidad de programación y se pierde la topología de confianza que hacía tan expresivo el árbol de supervisión.
La decisión entre actor en memoria, worker, objeto duradero o grano no es de moda ni de escala prevista, sino de qué frontera necesitas que el mensaje atraviese. Si la frontera es solo el aislamiento lógico, un actor en proceso basta y todo lo demás es coste. Si es el hilo, necesitas un worker y tus eventos deben ser clonables. Si es el proceso o la región, necesitas identidad estable y estado persistente. Cada frontera adicional encarece el envío en órdenes de magnitud y obliga a rediseñar el protocolo para que tolere pérdida y duplicado, así que atravesar una que no hacía falta es de las decisiones más caras que se pueden tomar temprano.
El modelo de Hewitt promete que un actor puede vivir en cualquier lugar sin que su código cambie, y esa promesa es cierta en el sentido en que la formuló y engañosa en el sentido en que la lee un equipo con prisa. Lo que la transparencia de ubicación garantiza es que la semántica del envío no cambia: sigue siendo asíncrono, sigue sin devolver nada, sigue sin bloquear. Lo que no garantiza —y no puede— es que las probabilidades cambien igual de poco, y las probabilidades son el noventa por ciento del diseño de un sistema real. Cruzar un hilo multiplica por mil la latencia del envío y obliga a que todo dato sea copiable; cruzar una red la multiplica por un millón e introduce la posibilidad de que el mensaje no llegue, llegue dos veces, llegue tarde o llegue cuando el destinatario ya se ha reactivado en otra máquina con otro estado. Un protocolo que funcionaba en memoria porque el emisor podía asumir respuesta inmediata se desmorona en cuanto atraviesa la primera de esas fronteras, y el error no estará en la infraestructura sino en la conversación que se diseñó suponiendo un mundo sin pérdida. La consecuencia metodológica es exigente y libera mucho una vez asumida: diseña el protocolo con las hipótesis de la frontera más lejana que vayas a cruzar en la vida del sistema, aunque hoy todo corra en un solo hilo. Que los eventos sean datos planos, que las peticiones lleven correlación, que los efectos sean idempotentes, que ningún actor asuma que su interlocutor sigue siendo el mismo del mensaje anterior. Ese conjunto de disciplinas no cuesta casi nada en local y es exactamente la diferencia entre un sistema que se distribuye moviendo un actor de sitio y uno que hay que reescribir el día que crece. La transparencia de ubicación no se hereda del modelo: se gana escribiendo protocolos que ya eran honestos cuando nadie los obligaba a serlo.
- Escribe la tabla de correspondencia entre bucle de eventos y modelo de actores con ejemplos tomados de tu propia aplicación.
- Convierte un cálculo pesado de tu interfaz en un worker envuelto como lógica de actor y comprueba que la interfaz nunca se congela.
- Audita tus eventos y elimina de ellos toda función, clase o referencia no serializable hasta que todos sobrevivan a un clonado estructurado.
- Sustituye un acceso por identificador de sistema por el paso explícito de la referencia y valora si el código quedó mejor o peor.
- Diseña la identidad de un actor duradero para una entidad tuya y decide si debe derivarse de un nombre de dominio o de un identificador opaco.
- Enumera qué supuestos de tu protocolo actual se rompen si el destinatario se reactiva en otra máquina entre dos mensajes tuyos.