wandres.dev
EL PROBLEMA DE SINCRONIZAR · glitches y consistencia

Propagación glitch-free: el algoritmo push-pull

Los sistemas de señales de 2026 garantizan consistencia sin ordenar el grafo a mano: usan un algoritmo push-pull en dos fases. Al escribir, una fase de empuje solo marca de sucio el cono afectado; al leer, una fase de arrastre recalcula bajo demanda, validando primero las dependencias —de modo que el orden topológico emerge de la recursión— y cortando la propagación cuando un valor no cambia. Esta lección reconstruye ese algoritmo, lo contrasta con el pub/sub ingenuo y lo sitúa en el ecosistema que va de la propuesta de Signals de TC39 a Angular, Solid y Vue.

⏱ 18 min

Hemos reunido las piezas: los glitches nacen del orden, el diamante es su forma mínima, el orden topológico los cura y el batching agrupa la propagación en el tiempo. Falta ensamblarlas en un algoritmo real, el que late bajo cada librería de señales seria de 2026. Ese algoritmo tiene un nombre —push-pull— y una idea rectora sorprendente: no calcula el orden topológico, deja que emerja. En lugar de ordenar el grafo por adelantado, invierte el control: al escribir solo marca, y al leer recalcula hacia atrás por las dependencias, de modo que el orden correcto aparece solo, como efecto del recorrido. Entender push-pull es entender por qué las señales modernas son perezosas, mínimas y glitch-free a la vez.

🎯 Al terminar esta lección sabrás
  • Describir el pub/sub ingenuo y enumerar sus tres fallos: glitches, redundancia y falta de corte.
  • Reconstruir el algoritmo push-pull en dos fases: marcar de sucio al escribir, recalcular al leer.
  • Explicar cómo el orden topológico emerge del arrastre por dependencias sin ordenar nada a mano.
  • Situar el corte por igualdad y el versionado como el estado del arte del ecosistema 2026.

El pub/sub ingenuo y sus tres pecados

El punto de partida que todos escribimos alguna vez: cada valor mantiene una lista de suscriptores; al cambiar, empuja su nuevo valor a cada uno, que recalcula al instante y empuja a su vez. Es empuje puro, ansioso y en profundidad. Ya sabemos que falla, y ahora podemos nombrar sus tres pecados con precisión.

👻

Glitches

Al recorrer en profundidad, un nodo con dos rutas hacia una fuente se recalcula por la primera antes de que la segunda llegue: ve una entrada nueva y otra vieja. El diamante siempre lo delata.

🔁

Redundancia

Cada notificación recalcula. El vértice de un diamante corre tantas veces como aristas de entrada tiene, y el coste se dispara con reconvergencias apiladas.

🚫

Sin corte

Si un derivado recalcula al mismo valor de antes, el empuje ingenuo propaga igual: no compara, así que despierta a todo el subárbol aunque nada cambiara de verdad.

Hay un cuarto defecto, más silencioso: el empuje ansioso calcula valores que quizá nadie lee. Un derivado caro se recomputa en cada cambio de sus fuentes aunque ninguna vista lo muestre. El pub/sub ingenuo no tiene forma de ser perezoso.

Push-pull: marcar al escribir, recalcular al leer

Las señales modernas parten el trabajo en dos fases separadas en el tiempo. La primera, el empuje, ocurre al escribir y es baratísima: no calcula nada, solo pinta de sucio el cono de nodos afectados. La segunda, el arrastre, ocurre al leer: un nodo sucio se recalcula bajo demanda, y para ello valida primero sus dependencias.

const LIMPIO = 0, REVISAR = 1, SUCIO = 2

// FASE PUSH: al escribir solo se marca, no se calcula
function escribir(senal, valor) {
  senal.valor = valor
  senal.version++
  for (const dep of senal.dependientes) marcar(dep, SUCIO)
}
function marcar(nodo, estado) {
  if (nodo.estado >= estado) return                 // ya marcado igual o peor: corta
  nodo.estado = estado
  for (const dep of nodo.dependientes) marcar(dep, REVISAR)  // transitivos: quizas
}

// FASE PULL: al leer se validan las dependencias primero
function leer(nodo) {
  if (nodo.estado === REVISAR) {
    for (const fuente of nodo.fuentes) {
      leer(fuente)                                  // arrastre recursivo
      if (fuente.version !== nodo.versionVista.get(fuente)) nodo.estado = SUCIO
    }
  }
  if (nodo.estado === SUCIO) {
    const nuevo = nodo.calcular()                   // sus fuentes ya son finales: sin glitch
    nodo.cambio = nuevo !== nodo.valor              // corte por igualdad
    nodo.valor = nuevo
  }
  nodo.estado = LIMPIO
  return nodo.valor
}

La belleza está en la fase de arrastre: leer invoca leer sobre cada dependencia ANTES de calcular el nodo. Esa recursión desciende hasta las fuentes y regresa calculando de abajo arriba, así que cuando un nodo por fin se evalúa, todas sus dependencias ya tienen su valor final. Es exactamente el orden topológico de la lección 3, pero nadie lo calculó: emerge de la pila de llamadas del arrastre.

Recorramos el diamante precio, subtotal, iva, total con este algoritmo:

  1. Se escribe precio. La fase de empuje marca subtotal e iva como SUCIO y, transitivamente, total como REVISAR. No se calcula nada aún.
  2. Un efecto lee total. Como está en REVISAR, valida sus fuentes antes de calcularse a sí mismo.
  3. Al validar, arrastra subtotal: está SUCIO, así que lo recalcula tras arrastrar precio, que ya es final.
  4. Arrastra iva: está SUCIO, lo recalcula con el mismo precio ya memoizado; precio no se evalúa dos veces.
  5. Ambas fuentes cambiaron de versión, así que total pasa a SUCIO y se calcula UNA vez, con subtotal e iva finales.

precio se evaluó una vez, total una vez, y en ningún punto se combinó una rama nueva con otra vieja. El diamante que glitcheaba y se recalculaba de más queda resuelto por el mismo mecanismo, sin una sola línea de ordenación explícita.

stateDiagram-v2
[*] --> limpio
limpio --> sucio: una dependencia directa cambio
limpio --> revisar: una dependencia transitiva quizas cambio
revisar --> limpio: al leer ninguna fuente cambio de valor
revisar --> sucio: al leer una fuente si cambio de valor
sucio --> limpio: al leer recalcula y memoiza

Versionado y corte por igualdad

Dos mecanismos elevan el algoritmo de correcto a eficiente. El primero es el corte por igualdad: si un nodo se recalcula al mismo valor que tenía, marca su cambio como falso y sus dependientes, al validarse, ven que su versión no se movió y se quedan limpios sin recalcular. Un cambio que no cambia nada muere en el sitio, en vez de despertar medio grafo.

El segundo es el versionado, que hace la pregunta “¿algo cambió?” en tiempo constante. Cada señal lleva un contador de versión que sube al escribir; cada derivado recuerda qué versión leyó de cada fuente. Un reloj global permite un atajo: si el reloj no se ha movido desde la última validación del nodo, está garantizadamente limpio y se devuelve su valor memoizado sin tocar sus fuentes. Solo cuando el reloj avanzó se recorre la lista de fuentes comparando versiones. Este esquema de versiones con dos marcas —SUCIO para “una dependencia directa cambió seguro” y REVISAR para “una transitiva quizás”— es el corazón del algoritmo que popularizó Reactively y que estandariza hoy la propuesta de Signals.

ℹ️
Por qué dos marcas y no una

La marca REVISAR existe para no recalcular subárboles en balde. Si solo tuviéramos SUCIO, marcar todo el cono de un cambio obligaría a recalcular cada nodo del cono al leerlo. Con REVISAR, un nodo transitivo primero pregunta a sus fuentes si de verdad cambiaron de valor; si el corte por igualdad detuvo el cambio más abajo, el nodo vuelve a LIMPIO sin ejecutar su cómputo. La combinación de la marca ligera y el corte por igualdad es lo que hace que el trabajo sea proporcional a lo que realmente cambió de valor, no a lo que fue tocado.

El contraste con el pub/sub ingenuo se ve mejor lado a lado.

Aspecto Pub/sub ingenuo Señales push-pull
Evaluación ansiosa, empuja y recalcula perezosa, recalcula al leer
Orden en profundidad dependencias primero
El diamante de más y con glitch una vez y consistente
Valor sin cambio vuelve a propagar corta la propagación
Trabajo proporcional al grafo proporcional a lo leído
Consistencia eventual siempre glitch-free

El ecosistema 2026

Casi toda la reactividad seria de hoy es una variación de este algoritmo. La propuesta de Signals de TC39 —con Signal.State, Signal.Computed y el Watcher de Signal.subtle— estandariza el push-pull perezoso con versionado, y su polyfill es prácticamente el pseudocódigo de arriba. Angular construyó sus señales sobre el mismo esquema; Preact Signals, SolidJS, la reactividad de Vue con ref y computed, las runes de Svelte 5, las derivaciones de MobX y las señales de Qwik comparten la misma médula: marcar al escribir, arrastrar al leer, cortar por igualdad.

Y hay un regalo extra: la pereza. Como nada se calcula hasta que se lee, un derivado caro que ninguna vista muestra jamás se evalúa, por mucho que sus fuentes cambien. El pub/sub ansioso no puede ofrecer esto —recomputa todo el cono en cada cambio—; el push-pull solo paga por lo que de verdad se observa.

📝
Los efectos son quienes despiertan al grafo

Como los derivados son perezosos, un grafo push-pull en reposo no hace nada aunque sus fuentes cambien: solo acumula marcas. Quien lo pone en marcha es un efecto, la única pieza que DEBE ejecutarse. En la propuesta de Signals, un Watcher recibe la notificación de empuje, se programa —con el batching de la lección 4— y, al vaciarse, lee sus señales; esa lectura es el arrastre que lleva al grafo entero a consistencia. Sin un observador que lea, un computed puede quedarse sucio para siempre, y no pasa nada: nadie lo necesitaba.

El pub/sub ingenuo no desaparece: sigue siendo la herramienta correcta para eventos gruesos donde no hay invariantes que preservar —un bus de mensajes, los eventos del DOM, un emisor entre módulos—. Lo que este nivel enseña es cuándo NO basta: en cuanto tienes un grafo de derivaciones con invariantes, el pub/sub ingenuo te dará glitches, y necesitas push-pull.

Queda una sutileza que hace todo esto aún más notable: las dependencias no se declaran, se descubren. Cada vez que un computed se ejecuta, el motor registra qué señales leyó durante esa ejecución, y esas se vuelven sus fuentes; las que dejó de leer se desconectan. El grafo se re-teje en cada recálculo, y aun así el arrastre lo recorre en orden correcto, porque el orden emerge de las lecturas reales, no de una estructura fija.

ℹ️
El grafo se descubre al leer

Este rastreo automático de dependencias es lo que hace que las signals se sientan mágicas: escribes a() + b() y el sistema deduce solo que el computed depende de a y de b, sin que lo declares. Si en una rama del cálculo dejas de leer b, la arista hacia b desaparece en el siguiente arrastre y b ya no despertará a ese nodo. Dependencias dinámicas y push-pull encajan de forma natural: como el recálculo es el momento en que se leen las fuentes, es también el momento perfecto para volver a trazar las aristas.

El orden topológico que nadie calcula

El giro más profundo del push-pull es que resuelve el problema central del nivel —recorrer el grafo en orden de dependencias— sin construir jamás ese orden de forma explícita. En lugar de preguntar “¿en qué secuencia actualizo?”, invierte el control: el consumidor que lee dispara el cómputo, y como cada nodo, antes de calcularse, arrastra a sus dependencias, la recursión del arrastre RECORRE el grafo en orden topológico por pura consecuencia de su estructura. El orden no se planifica, emerge; la pila de llamadas es el planificador. Sobre esa base, el versionado convierte la pregunta “¿cambió algo?” en una comparación de enteros en tiempo constante, y el corte por igualdad garantiza que un cambio que no altera valores muera donde nace en vez de sacudir el grafo entero. Cuando se juntan las cuatro ideas del nivel —la consistencia como propiedad de cada instante, el diamante como su reto mínimo, el orden topológico como su cura y el batching como su agrupamiento en el tiempo— colapsan en este único algoritmo de dos fases. Y ahí está la lección última: el pub/sub que todos escribiríamos a mano es la línea base ingenua que cada librería moderna de señales fue diseñada para superar. No aprendes push-pull para memorizar una API, sino para reconocer que la reactividad glitch-free es un resultado de ingeniería duramente ganado, no la opción por defecto, y para saber exigirlo cuando tu estado tiene verdades que no admiten mentiras intermedias.

⚔️ Reconstruye el algoritmo
  1. Implementa escribir, marcar y leer con las marcas LIMPIO, REVISAR y SUCIO y verifica que el diamante calcula cada nodo una sola vez.
  2. Añade el corte por igualdad y comprueba que un cambio que no altera el valor no recalcula a los dependientes.
  3. Instrumenta el arrastre para imprimir el orden en que se calculan los nodos y confirma que coincide con un orden topológico.
  4. Crea un computed caro que ninguna lectura observe y demuestra que, con push-pull, nunca llega a ejecutarse.
  5. Compara tu implementación con la propuesta de Signals de TC39: localiza Signal.State, Signal.Computed y el Watcher.