El coste de la comodidad: lo que paga una consulta viva
Una suscripción cobra memoria permanente y trabajo en cada escritura futura, a veces es semánticamente incorrecta frente a una consulta puntual, y una interfaz que se refresca de más solo se depura con un embudo instrumentado capa por capa.
Después de cuatro lecciones dedicadas a que la interfaz se entere sola, toca la pregunta que cierra el nivel y que nadie hace a tiempo: cuánto cuesta que se entere. Una consulta viva no es una consulta con una comodidad añadida, es un compromiso con todo el futuro de la base de datos —un derecho a ser recalculada que cada escritura posterior tendrá que respetar—, y su factura no la paga quien la escribe sino todo lo que venga después. Sumadas, esas facturas convierten el número de suscripciones activas en la métrica que gobierna el comportamiento de la aplicación entera, exactamente igual que en el nivel 10 lo era el número de cruces de la frontera. Y hay un caso, más frecuente de lo que parece, en el que la consulta viva no es solo cara sino directamente errónea.
- Descomponer el presupuesto de una suscripción en memoria retenida, trabajo por escritura y tráfico de avisos.
- Reconocer las pantallas donde una consulta puntual es la respuesta correcta, incluso por motivos semánticos.
- Gestionar el ciclo de vida de las suscripciones con recuento de referencias, periodo de gracia y pausa en segundo plano.
- Depurar el exceso de refresco con un embudo instrumentado que localice la capa culpable en lugar de adivinarla.
El presupuesto de una suscripción
Una consulta puntual tiene un coste que empieza y termina: se ejecuta, devuelve filas y desaparece. Una consulta viva tiene tres costes, y ninguno de los tres se detiene.
Memoria retenida
El último resultado se conserva para poder compararlo, y con él los metadatos de dependencia: tablas declaradas, predicado, conjunto de lectura o, en el caso incremental, los índices de cada operador con estado. Todo eso pesa mientras la suscripción viva, no mientras se ejecuta.
Trabajo por escritura
Cada transacción confirmada paga un peaje proporcional al número de suscripciones activas, no al número de suscripciones visibles. Una pantalla que nadie mira desde hace veinte minutos sigue cobrando en cada guardado.
Tráfico y clonado
Los avisos cruzan la frontera y se copian por destinatario, y cada refresco devuelve un resultado que también se clona. Una suscripción sobre un resultado grande es un clonado recurrente disfrazado de comodidad.
Complejidad de diagnóstico
El coste que no aparece en ningún perfilador. Un fallo de refresco atraviesa cinco capas y ninguna de ellas emite un error, así que el tiempo de depuración es parte real del precio.
Ese peaje se mide sin instrumentación sofisticada y conviene medirlo pronto, porque su magnitud decide qué estrategia del nivel te hace falta. Ejecuta la misma escritura dos veces, una con cero suscripciones activas y otra con todas las de tu pantalla más cargada, y divide la diferencia entre el número de suscripciones: obtienes el impuesto unitario en microsegundos. Multiplícalo por el número de suscripciones que tu aplicación llega a acumular en una sesión larga y tendrás, con dos multiplicaciones, la respuesta a si el barrido total te basta o si necesitas precisión. Es un cálculo de diez minutos que ahorra semanas de arquitectura especulativa.
De los cuatro, el que decide la escala de la aplicación es el segundo, y conviene enunciarlo como una fórmula mental: el coste de una escritura es su coste base más la suma de lo que cuesta evaluar cada suscripción viva. Esa suma no la controlas ajustando una consulta concreta; la controlas manteniendo pequeño el conjunto de suscripciones. Cien suscripciones baratas hunden una aplicación que aguantaba diez caras, y la diferencia entre ambas situaciones no suele ser una decisión de diseño sino la acumulación silenciosa de suscripciones que nadie liberó.
Cuándo la consulta puntual es la correcta
La conversación sobre consultas vivas suele plantearse como si fueran una mejora universal y la consulta puntual el residuo de una época anterior. Es falso en dos niveles, y el segundo es el importante.
El primer nivel es económico y tiene la forma habitual: hay pantallas donde la suscripción no compensa. Un dato que se lee una vez y se muestra en un contexto que se cierra —un diálogo, un panel de detalle, un asistente de varios pasos— no tiene tiempo de amortizar su coste. Una consulta cara sobre datos que cambian una vez al día tampoco. Los datos que solo se leen dentro del propio camino de escritura, para validar o para calcular, no necesitan suscripción en absoluto porque quien los lee ya sabe cuándo cambian.
Como criterio rápido, estas cinco preguntas resuelven casi todos los casos dudosos sin discusión de despacho:
- ¿Vive la pantalla más que unos pocos segundos? Si no, la suscripción no llega a amortizar ni su construcción.
- ¿Puede cambiar el dato mientras el usuario lo mira? Si nadie más escribe ahí, no hay nada que notificar.
- ¿Tiene sentido que cambie delante de él? Si la respuesta incomoda, la consulta debe ser puntual.
- ¿Lo lee una persona o lo lee el propio código? El código que escribe ya sabe cuándo cambia lo que leyó.
- ¿Cuánto retiene esa consulta viva? Si su estado intermedio pesa más que la pantalla, el equilibrio ya está roto.
El segundo nivel es semántico y se pasa por alto casi siempre: hay resultados que deben ser una fotografía, y convertirlos en consulta viva los corrompe. Un informe, un cierre contable, una exportación, una vista de auditoría o una factura describen el estado en el instante en que alguien pidió verlos, y ese instante forma parte del significado del resultado. Si las cifras cambian bajo el cursor porque otro proceso escribió en la base, la pantalla ha dejado de responder a la pregunta que se le hizo. Lo mismo ocurre con cualquier flujo donde el usuario compara antes y después: si el antes se refresca solo, ya no hay comparación posible.
Cuando el resultado debe ser una fotografía pero además interesa que el usuario sepa que hay novedades, existe un patrón intermedio que resuelve las dos cosas: suscríbete a la invalidación, pero no al resultado. La consulta no se reejecuta, la pantalla no se mueve y aparece una marca discreta que dice que hay cambios y ofrece recargar. Es lo que hace cualquier lista de mensajes que se respete, cuesta lo mismo que una suscripción normal en trabajo por escritura y bastante menos en tráfico, y devuelve al usuario el control sobre cuándo cambia lo que está leyendo.
Desuscribirse: el ciclo de vida que nadie escribe
Toda suscripción tiene que pertenecer a algo que sepa morir. En la práctica ese algo es el componente que la renderiza, y el mecanismo es la limpieza de su efecto: la función de cancelación se devuelve al montar y se invoca al desmontar. El puente estándar entre un almacén externo y el árbol de la interfaz, useSyncExternalStore y sus equivalentes en otros marcos, existe precisamente para que esa pareja de operaciones no se pueda separar por accidente.
El patrón que evita la mayoría de las fugas no es más disciplina, sino un recuento de referencias por clave de consulta. Diez componentes que piden la misma consulta con los mismos parámetros comparten una sola suscripción viva; cuando el último se marcha, se libera.
const vivas = new Map(); // clave -> { fuente, refs, temporizador }
const GRACIA = 5000; // ms antes de liberar de verdad
function suscribir(clave, sql, params, oyente) {
let e = vivas.get(clave);
if (!e) {
e = { fuente: db.suscribir(sql, params), refs: 0, temporizador: null };
vivas.set(clave, e);
}
clearTimeout(e.temporizador); // volvio alguien: cancela la liberacion
e.refs++;
e.fuente.escuchar(oyente);
return () => {
e.fuente.olvidar(oyente);
if (--e.refs > 0) return;
e.temporizador = setTimeout(() => { // periodo de gracia antes de tirarlo
e.fuente.cancelar();
vivas.delete(clave);
}, GRACIA);
};
}
El periodo de gracia merece una justificación, porque no es una precaución sino una necesidad medida. Navegar a otra pantalla y volver es la interacción más común que existe, y sin gracia esa maniobra destruye y reconstruye la suscripción entera: metadatos, primer resultado y, en el caso incremental, los índices de todos los operadores con estado, que pueden costar mucho más que la propia consulta. Unos segundos de retención convierten una reconstrucción cara en una reincorporación gratuita.
Hay dos situaciones más que se confunden con la cancelación y que exigen algo distinto. Una pestaña oculta no debe desuscribirse: debe pausarse, que es lo que ya hace la lógica de visibilidad de la lección anterior, porque desuscribirse tira el estado que va a hacer falta dentro de treinta segundos. Y una página que el navegador congela o guarda en la caché de retroceso no ejecuta ninguna limpieza, así que al despertar hay que reconciliar por versión y no dar nada por sentado sobre lo que quedó vivo.
La fuga de suscripciones no se manifiesta como una fuga de memoria evidente sino como una aplicación que se va poniendo lenta cuanto más rato lleva abierta, y eso se atribuye a cualquier cosa antes que a la causa real. El diagnóstico cuesta cinco líneas: un contador de suscripciones activas expuesto en la consola y una prueba automatizada que navega por diez pantallas, vuelve al inicio, espera a que pase el periodo de gracia y comprueba que el contador ha regresado a su valor de partida. Si crece de forma monótona con la navegación, tienes la fuga localizada antes de que llegue a producción, y la tienes con un número exacto en lugar de una sospecha.
Depurar la interfaz que se refresca de más
Cuando una pantalla parpadea o va lenta, la pregunta que hay que responder no es por qué se refresca, sino en qué capa se refresca de más, porque las causas posibles son cinco y el remedio de cada una es incompatible con el de las otras. La única forma barata de saberlo es instrumentar las cinco transiciones como un embudo y mirar dónde no estrecha.
flowchart TB A[Transacciones confirmadas] --> B[Avisos emitidos] B --> C[Consultas reejecutadas] C --> D[Resultados que cambiaron de verdad] D --> E[Repintados en pantalla] E --> F[Donde el embudo no estrecha esta el fallo] style F fill:#f38ba8,color:#11111b
Cada tramo que no estrecha señala un culpable distinto y bastante concreto. Si hay muchas más transacciones de las que esperabas, alguien está escribiendo sin que se lo pidan: un registro de actividad, una tabla de metadatos de sincronización, un contador de accesos que se actualiza en cada lectura. Si por cada transacción salen varios avisos, falta la agrupación por ráfaga. Si se reejecutan casi todas las consultas ante cualquier escritura, tu granularidad de invalidación es demasiado gruesa, y casi siempre por una tabla muy escrita que aparece en demasiadas consultas. Si se reejecutan muchas pero casi ninguna cambia de resultado, la invalidación funciona y lo que sobran son falsos positivos, que se absorben con comparación estructural antes de entregar. Y si los resultados cambian poco pero se repinta todo, el problema ya no es de datos sino de identidad: estás entregando vectores nuevos con contenido idéntico y nadie los está comparando.
// Cinco contadores baratos que convierten una sospecha en un diagnostico
const m = { commits: 0, avisos: 0, reejecuciones: 0, cambios: 0, repintados: 0 };
function entregar(s, filas, version) {
m.reejecuciones++;
if (igualEstructural(s.ultimo, filas)) return; // falso positivo absorbido aqui
m.cambios++;
s.ultimo = filas;
s.oyente(filas, version);
}
// Y la pregunta que ahorra mas tiempo que ninguna otra herramienta
console.debug('reejecuta', s.nombre, 'por', aviso.tablas, 'version', aviso.version);
Esa última línea es la funcionalidad más valiosa de todo el subsistema y la que casi nadie construye a tiempo. Una traza que diga qué suscripción se reejecutó, qué aviso la disparó y qué tabla o clave provocó la coincidencia convierte una sesión de depuración de dos horas en una lectura de treinta segundos. Debe existir desde el primer día, detrás de un interruptor apagado en producción, y debe llevar el nombre de la consulta y no su SQL, porque una traza que vuelca sentencias completas es ilegible en cuanto hay más de tres.
Quedan dos patologías que el embudo no captura y conviene buscar a mano. La primera es el bucle: una suscripción cuyo manejador escribe en la base provoca un aviso que la vuelve a disparar, y el sistema entra en una rotación indefinida que consume el hilo sin lanzar ningún error. Un guardián de reentrada que corte a la tercera vuelta y registre la cadena responsable lo detecta en el acto. La segunda es la tabla contaminante: una consulta que se une contra una tabla de metadatos escrita en cada operación queda invalidada permanentemente por motivos que no tienen nada que ver con lo que muestra. El remedio es de esquema y no de código —separar lo volátil de lo estable en tablas distintas— y suele recortar más refrescos que cualquier refinamiento del filtrado.
La forma correcta de pensar en una suscripción no es como en una lectura que además avisa, sino como en una obligación que contraes con el resto del programa. Una consulta puntual es una transacción entre tú y la base: pides, te responde, se acabó, y su coste está acotado y localizado en el instante en que ocurre. Una consulta viva es una afirmación sobre el futuro —este resultado seguirá siendo correcto pase lo que pase— y esa afirmación no la sostiene quien la hizo, la sostiene cada escritura que la aplicación ejecute a partir de entonces, incluidas las que están en otro módulo, las que escribirá el motor de sincronización dentro de seis meses y las que hará un desarrollador que no sabía que tu pantalla existía. Por eso el número de suscripciones vivas es la métrica arquitectónica dominante de una aplicación local-first reactiva, del mismo modo que el número de cruces de la frontera lo era del nivel diez, y por eso la degradación característica de estos sistemas no es un cuello de botella localizado que se pueda perfilar y arreglar, sino un encarecimiento difuso de absolutamente todas las escrituras que nadie sabe atribuir a nada. La conclusión práctica es que la reactividad no es gratis ni cara: es amortizada, y lo único que decide de qué lado cae es que el conjunto de consultas vivas se mantenga proporcional a lo que hay en pantalla y no a lo que la aplicación ha visitado desde que arrancó. De ahí salen las tres disciplinas que cierran el nivel y que valen más que cualquier motor que elijas: que toda suscripción tenga un dueño que sepa morir, que su liberación esté probada de forma automática y no confiada a la memoria de nadie, y que exista un contador visible que convierta la acumulación silenciosa en un número que se pueda mirar. Con esas tres, cualquiera de las cuatro estrategias del nivel funciona. Sin ellas, la más sofisticada de todas acabará comportándose peor que la más tosca, y lo hará despacio, sin errores y sin que nadie sepa cuándo empezó.
- Cuenta las suscripciones vivas de tu aplicación en tres momentos: recién arrancada, tras diez minutos de uso normal y tras recorrer todas las pantallas. Compara los tres números.
- Mide el coste de una escritura con cero suscripciones y con todas las de la pantalla más cargada. La diferencia dividida entre el número de suscripciones es tu impuesto unitario.
- Implementa el recuento de referencias con periodo de gracia y demuestra con una prueba de navegación que el contador vuelve a su valor de partida.
- Localiza una pantalla que debería ser una fotografía —un informe, una exportación, una comparación— y conviértela a consulta puntual con aviso de novedades sin refresco.
- Instrumenta el embudo de cinco contadores, provoca una escritura irrelevante y anota en qué tramo no estrecha. Corrige esa capa y solo esa.
- Añade el guardián de reentrada y provoca a propósito un bucle escribiendo desde el manejador de una suscripción. Comprueba que se corta y que la traza nombra al culpable.