Síncrono frente a asíncrono
Ejecutar los efectos al cerrar el tramo síncrono o diferirlos a un microtask son dos decisiones de producto con consecuencias opuestas en depurabilidad, agrupamiento y ventanas de inconsistencia.
El momento en que corren los efectos es la decisión de diseño donde más divergen los motores de esta familia, y no es una cuestión de rendimiento sino de contrato con el programador. Ejecutar de forma síncrona hace que la traza de pila sea legible y que el estado del DOM esté al día en cuanto vuelve la escritura. Diferir a un microtask agrupa más y permite descartar trabajo, a costa de una ventana en la que el mundo está desactualizado.
- Comparar el contrato de un motor síncrono con el de uno asíncrono.
- Identificar la ventana de inconsistencia y quién la observa.
- Implementar ambos modos sobre la misma cola.
- Reconocer qué elige cada motor real y por qué.
Las dos políticas
Síncrono. Al terminar el tramo de escrituras, la cola se vacía en el acto, antes de que la función que escribió vuelva a su llamador.
function escribirSincrono(fuente, valor) {
fuente.valor = valor;
lote(() => marcar(fuente, SUCIO)); // el lote vacia al cerrar, ahora mismo
}
pagina.set(2);
console.log(nodo.textContent); // ya dice lo nuevo
Asíncrono. La cola se programa para vaciarse en un microtask, y la escritura vuelve inmediatamente sin haber ejecutado nada.
let programado = false;
function planificarVaciado() {
if (programado) return;
programado = true;
queueMicrotask(() => { programado = false; vaciar(Cola); });
}
pagina.set(2);
console.log(nodo.textContent); // todavia dice lo viejo
await Promise.resolve(); // o el equivalente del motor
console.log(nodo.textContent); // ahora si
Qué gana cada uno
El modo síncrono gana en tres cosas concretas.
La traza de pila es completa: cuando un efecto falla, la pila incluye la escritura que lo provocó y el manejador que la llamó. En modo asíncrono la pila empieza en el vaciado y no dice nada de la causa.
El estado está al día al volver. Puedes escribir y leer el DOM en la línea siguiente. Con efectos asíncronos hay que esperar al siguiente tick, lo que obliga a que cualquier código que necesite leer el resultado sea asíncrono también, y eso se propaga hacia arriba de forma contagiosa.
Las pruebas son sencillas. Escribir y comprobar en la línea siguiente, sin await ni utilidades para vaciar colas.
El modo asíncrono gana en otras tres.
Agrupa más. Todas las escrituras del turno actual, vengan de donde vengan, acaban en el mismo vaciado. No hace falta agrupar a mano ni acordarse de hacerlo.
Permite descartar. Si el estado cambia otra vez antes de que la cola se vacíe, el trabajo pendiente se recalcula con el valor final. Es la base de cualquier mecanismo de prioridades o de interrupción.
Se alinea con el navegador. El pintado ocurre entre tareas, así que ejecutar los efectos al final del turno y antes del pintado es exactamente el momento correcto para escribir en el DOM sin provocar recálculos de estilo intermedios.
flowchart TB S[sincrono] --> S1[traza completa] S --> S2[estado al dia al volver] S --> S3[pruebas simples] S --> S4[hay que agrupar a mano] A[asincrono] --> A1[agrupa todo el turno] A --> A2[permite descartar trabajo] A --> A3[alineado con el pintado] A --> A4[ventana de inconsistencia] style S fill:#89b4fa,color:#11111b style A fill:#cba6f7,color:#11111b style S1 fill:#a6e3a1,color:#11111b style S2 fill:#a6e3a1,color:#11111b style S3 fill:#a6e3a1,color:#11111b style S4 fill:#f9e2af,color:#11111b style A1 fill:#a6e3a1,color:#11111b style A2 fill:#a6e3a1,color:#11111b style A3 fill:#a6e3a1,color:#11111b style A4 fill:#f9e2af,color:#11111b
La ventana de inconsistencia
En modo asíncrono existe un intervalo entre la escritura y el vaciado durante el cual el mundo exterior no refleja el estado del grafo. Conviene ser preciso sobre quién lo observa y quién no.
Las derivaciones no lo observan. Un memo se resuelve al leerse, y al leerse resuelve sus fuentes. Dentro del grafo todo está siempre coherente, con independencia de la política de la cola.
Los efectos no lo observan entre ellos, porque todos corren en el mismo vaciado y ven el mismo estado.
Lo observa el código imperativo externo. Cualquier cosa que lea el DOM, el almacenamiento o cualquier salida del sistema entre la escritura y el vaciado ve lo viejo. Es exactamente el motivo por el que los frameworks asíncronos ofrecen una primitiva para esperar al siguiente vaciado.
// El patron que todo motor asincrono acaba necesitando
formulario.set(nuevo);
await siguienteVaciado(); // nextTick, tick, o el equivalente
campo.focus(); // ahora el elemento existe
El problema del modo asíncrono no es la ventana en sí, que dura microsegundos. Es que cualquier función que necesite leer el resultado tiene que volverse asíncrona, y con ella todas las que la llaman. Es el mismo fenómeno del color de las funciones que ya conoces de async y await, aplicado a la lectura del DOM. En bases de código grandes acaba siendo un coste de diseño considerable.
Qué elige cada motor y cómo implementarlo
Qué elige cada motor
Solid es síncrono por defecto: al terminar un lote los efectos corren en el acto. Tiene además createRenderEffect, que corre incluso antes, durante la fase de renderizado, para las escrituras en el DOM.
Vue es asíncrono por defecto y lo expone como una opción de cada watcher: flush: 'pre' corre antes del renderizado del componente, 'post' después, y 'sync' de forma síncrona con la escritura. Que sean tres y no dos reconoce que el pintado es un evento relevante en el orden.
Angular ejecuta los efectos a través de su planificador de detección de cambios, que es asíncrono, y ofrece afterRenderEffect para el trabajo que debe ocurrir después del renderizado.
Svelte agrupa dentro del mismo tick y ofrece $effect.pre para el trabajo que debe ocurrir antes de que se actualice el DOM, además de una utilidad para esperar al siguiente vaciado.
Preact Signals es síncrono en su effect, y el paquete de integración con el framework se encarga de alinearlo con el ciclo de renderizado.
La distribución no es casual: los motores que se usan solos como librería tienden a síncrono, y los que forman parte de un framework con ciclo de renderizado propio tienden a asíncrono, porque tienen un momento natural al que alinearse.
La forma más clara de decidir entre las dos políticas es preguntarse quién es el dueño del tiempo en tu sistema. Si tu motor reactivo es la pieza central y todo lo demás reacciona a él, el modo síncrono es el correcto: no hay nadie más con quien coordinarse, y la simplicidad de razonamiento es una ventaja neta. Si tu motor vive dentro de un sistema que ya tiene su propio ritmo —un ciclo de renderizado, un bucle de fotogramas, una detección de cambios—, entonces el modo asíncrono es el correcto, porque lo que necesitas es alinearte con ese ritmo y no imponer el tuyo. Ejecutar efectos síncronamente dentro de un sistema que tiene su propio ciclo de pintado produce el peor resultado posible: escrituras en el DOM repartidas por todo el turno, cada una potencialmente forzando un recálculo de estilo, en vez de agrupadas en el momento correcto. Y de aquí sale una recomendación concreta para quien diseñe un motor: no elijas, parametriza. La cola es la misma en los dos casos; lo único que cambia es quién llama a vaciar y cuándo. Exponer esa política como un punto de extensión cuesta cinco líneas y permite que la misma librería sirva en los dos contextos, que es exactamente lo que hace la propuesta de TC39 al dejar el scheduling deliberadamente fuera de la especificación y ofrecer solo un vigilante que notifica.
Implementar los dos sobre la misma cola
La parametrización es trivial y merece la pena tenerla desde el principio.
let vaciador = (vaciar) => vaciar(); // sincrono
function usarVaciadoAsincrono() {
let programado = false;
vaciador = (vaciar) => {
if (programado) return;
programado = true;
queueMicrotask(() => { programado = false; vaciar(); });
};
}
function lote(fn) {
if (Lote) return fn();
const cola = Lote = new Set();
try { return fn(); }
finally { Lote = null; vaciador(() => vaciarCola(cola)); }
}
Con el modo asíncrono hay que tener cuidado con un detalle: si Lote se pone a null antes del vaciado diferido, las escrituras que ocurran mientras tanto abrirán su propio lote y su propia programación. La solución habitual es mantener una cola global única en lugar de una por lote.
- Implementa la parametrización del vaciado y prueba tu motor en los dos modos.
- Escribe una señal y lee el DOM en la línea siguiente en ambos modos.
- Provoca un error dentro de un efecto y compara las dos trazas de pila.
- Mide cuántos vaciados se producen al escribir veinte señales seguidas en cada modo.