El dato ya está aquí: el problema es saber quién lo miraba
Con una base local el dato nunca falta, así que el problema deja de ser recuperarlo y pasa a ser averiguar qué consultas se han quedado obsoletas por una escritura ocurrida a cuatro milímetros de ellas.
Toda la disciplina de caché que traes del mundo cliente-servidor está construida sobre una carencia: el dato no está aquí, y por eso hay que pedirlo, guardarlo, fecharlo y decidir cuándo dejar de creérselo. Cuando la base de datos vive en el disco del usuario esa carencia desaparece de golpe, y con ella desaparece también la razón de ser de casi todo el aparato. Lo que queda al retirarlo no es un problema resuelto, sino un problema distinto y bastante más incómodo: acabas de escribir una fila, la fila está en disco, la consulta que la mostraría se resolvería en microsegundos, y sin embargo la pantalla sigue enseñando el resultado de hace un minuto porque nadie ha avisado a nadie.
- Entender por qué la invalidación local no es un caso particular de la invalidación de caché, sino su inversión económica.
- Reconocer qué información perdiste al dejar de tener una clave de caché que coincidía con una URL.
- Situar el problema real donde está: en la coherencia entre la base y la proyección materializada que la interfaz lee.
- Definir el contrato mínimo de una consulta viva, incluida la parte que casi nadie escribe el primer día.
La inversión: sobra el dato, falta la noticia
En una arquitectura cliente-servidor la operación cara y la operación informativa son la misma: la petición de red. Pagas cientos de milisegundos por traer el dato, y a cambio recibes gratis un montón de metadatos sobre su frescura —una cabecera de control de caché, un ETag, una marca temporal, un código de estado— que una biblioteca como TanStack Query o SWR convierte en política. Todo el diseño de esas bibliotecas se deduce de ahí: como pedir es caro, hay que pedir poco; como el servidor es el único que sabe la verdad, hay que releerla de vez en cuando; y como releer tarda, hay que enseñar lo viejo mientras llega lo nuevo. La invalidación por clave, la revalidación en segundo plano y el tiempo de vida configurable no son ideas fundamentales: son tres formas distintas de administrar la ignorancia.
Con una base local no eres ignorante. Eres, de hecho, el único que sabe lo que ha pasado, porque la escritura la hiciste tú, en una transacción tuya, sobre un fichero tuyo. Lo que ocurre es que ese conocimiento se queda encerrado en el punto exacto del código donde se produjo, y no hay ningún mecanismo —ninguna respuesta que inspeccionar, ninguna cabecera, ningún canal implícito— que lo lleve hasta las partes de la aplicación que estaban mirando lo que acabas de cambiar.
// Cliente-servidor: la clave de cache coincide con la peticion,
// asi que invalidar es nombrar lo que ya sabias nombrar
await api.post('/proyectos/7/notas', nota);
cache.invalidar(['notas', 7]); // la biblioteca vuelve a pedir por red
// Base local: la escritura termino y el mundo no se ha enterado
await db.ejecutar(
'INSERT INTO notas (id, proyecto, texto) VALUES (?, ?, ?)',
[id, 7, texto]
);
// no hay respuesta que inspeccionar ni cabecera que leer:
// la fila esta en disco y la lista de la pantalla sigue siendo la de antes
La inversión económica es total y conviene enunciarla sin adornos: reconsultar pasó a ser barato y averiguar qué reconsultar pasó a ser el gasto. Una consulta indexada dentro del motor cuesta decenas de microsegundos; decidir si esa consulta se ha visto afectada por una escritura puede costar más que ejecutarla entera. Cualquier intuición sobre caché que traigas de la red apunta en la dirección contraria a esta y te llevará a optimizar lo que ya es gratis.
flowchart LR A[Mutacion contra un servidor] --> B[Invalidar una clave de cache] B --> C[Nueva peticion de red] C --> D[Respuesta y repintado] E[Escritura en la base local] --> F[La fila ya esta en disco] F --> G[Que consultas han quedado obsoletas] G --> H[Nadie lo sabe salvo que lo construyas] style D fill:#a6e3a1,color:#11111b style H fill:#f38ba8,color:#11111b
Hay una segunda diferencia que se pasa por alto y que cambia la escala del problema. Contra un servidor, las escrituras son caras, deliberadas y escasas: el usuario rellena un formulario y pulsa un botón, y esa fricción impone de forma natural un ritmo de una mutación cada muchos segundos. Con la base en el disco propio esa fricción desaparece y el diseño de producto se aprovecha de inmediato: se guarda al escribir, se reordena al arrastrar, se registra la posición del cursor, se persiste el borrador en cada pausa. Una aplicación local-first madura confirma decenas de transacciones por minuto sin que nadie pulse nada, y cada una de ellas es una ocasión para decidir a quién avisar. El camino de la invalidación deja de ser un evento ocasional y se instala en la ruta caliente.
Hay además un cambio de tolerancia que agrava el asunto. Una lista desactualizada durante dos segundos contra un servidor remoto es un inconveniente que el usuario atribuye a la red, porque tiene un modelo mental de que sus datos están lejos y viajan. Una lista desactualizada sobre su propio disco, justo después de que él mismo haya guardado algo y haya visto que la operación terminaba al instante, no es un inconveniente: es una aplicación que miente. La ventana de obsolescencia aceptable no se reduce, se anula, y con ella se anula la estrategia más socorrida del mundo de la red, que consistía precisamente en tolerar un poco de retraso a cambio de no pensar.
Lo que perdiste al perder la URL
Merece la pena mirar de cerca qué te daba en realidad la clave de caché, porque su desaparición explica la mayor parte de la dificultad. En una API de recursos, la clave era simultáneamente tres cosas: la identidad de la petición, la identidad del resultado y una descripción del subconjunto de datos implicado. Cuando escribías en /proyectos/7/notas sabías, sin analizar nada, que lo afectado eran las claves que empezaban por notas y 7, porque el servidor había impuesto una jerarquía de recursos que hacía coincidir la forma de las escrituras con la forma de las lecturas.
Merece la pena desglosar lo que esa coincidencia te regalaba, porque cada punto de la lista es un problema que ahora tienes que resolver por separado:
- La identidad del resultado, que permitía compartir una misma respuesta entre todos los componentes que la pedían sin ejecutar nada dos veces.
- La descripción de la dependencia, porque el prefijo de la clave decía literalmente de qué recurso dependía el resultado.
- La coincidencia de forma entre lectura y escritura, impuesta por el diseño de la API: se escribía sobre el mismo recurso del que se leía.
- La granularidad ya decidida, porque el servidor había elegido por ti dónde acababa un recurso y empezaba otro.
- El árbitro externo, que en el peor caso siempre podía consultarse de nuevo para salir de dudas.
Una base local no impone ninguna jerarquía. Una consulta es una expresión arbitraria sobre tablas que otras diez partes de la aplicación también escriben, con uniones que atraviesan entidades que nadie diseñó pensando en la caché, con predicados que dependen de parámetros calculados en tiempo de ejecución y con agregados cuyo valor puede cambiar por filas que ni siquiera aparecen en el resultado. El caso que ilustra mejor el problema es ese último: una consulta que cuenta las notas pendientes de un proyecto no devuelve ninguna nota, de modo que ningún razonamiento basado en comparar identificadores del resultado con identificadores escritos detectará jamás que hay que refrescarla.
Conviene además separar dos cosas que la palabra clave mezclaba y que aquí se divorcian. Una consulta viva necesita una identidad, para reconocerla, compartirla entre componentes y no ejecutarla dos veces; y necesita una descripción de sus dependencias, para saber cuándo ha caducado. En el mundo de las URL ambas coincidían y por eso nadie distinguía entre ellas. En el mundo local, la identidad es fácil —la sentencia más sus parámetros, convertidas en una cadena estable— y la descripción de dependencias es justamente lo que no viene con la consulta y hay que derivar, declarar o inferir. Confundir las dos lleva al error más común de todo el nivel: creer que como ya tienes una clave de consulta, ya tienes invalidación.
Todo lo que construyas a partir de aquí falla de una manera concreta que conviene tener presente desde el primer minuto: no lanza excepciones, no aparece en los registros y no rompe ninguna prueba unitaria. Una consulta que debía refrescarse y no se refrescó produce una pantalla perfectamente coherente consigo misma que simplemente no corresponde al estado del disco. El usuario lo detecta antes que tú, y lo describe como que la aplicación va rara. Diseñar para que el fallo por defecto sea el exceso de refresco y no la omisión es la decisión de arquitectura más rentable del nivel entero.
La segunda copia y su coherencia
Conviene recordar dónde está exactamente el desfase, porque no está donde la intuición lo coloca. La base de datos nunca está obsoleta: contiene lo que se escribió y punto. Lo que está obsoleto es la proyección materializada que vive en el hilo de la interfaz, esa copia del resultado que apareció en el nivel 10 en cuanto la base se mudó a un Worker y las lecturas dejaron de poder ser síncronas. Desde entonces tienes dos representaciones del mismo hecho, y este nivel entero trata de la relación entre ellas.
Que la copia exista no es un accidente que se pueda eliminar con mejor ingeniería. Es una necesidad estructural: pintar es una operación síncrona y la frontera del Worker es asíncrona, así que el resultado tiene que estar ya materializado del lado de quien pinta antes de que empiece a pintar. La pregunta útil no es cómo evitar la copia, sino qué política la mantiene alineada con su origen y qué garantías ofrece esa política.
Y hay que asumir desde ya que ninguna política la mantendrá alineada en todo momento, porque el desfase es físico y no lógico: entre que la transacción se confirma en el Worker y que el aviso se procesa en el hilo principal transcurre un intervalo durante el cual la pantalla muestra, necesariamente, un estado que ya no es el vigente. Lo que se puede diseñar no es la ausencia de desfase, sino su cota —cuánto puede durar como máximo— y su forma —que el usuario no pueda observar jamás dos partes de la pantalla que se contradigan entre sí—. Esa segunda propiedad es la que de verdad importa y la que más fácil se rompe: dos consultas que se refrescan por caminos distintos y llegan desacompasadas producen una interfaz donde el contador dice tres y la lista tiene cuatro elementos, y ese es el tipo de incoherencia que destruye la confianza mucho más rápido que un segundo de retraso uniforme.
La base: autoridad, asíncrona
Contiene la verdad, resuelve consultas en microsegundos y vive detrás de un canal de mensajes. Nunca está desactualizada, pero tampoco está disponible en el instante del pintado.
La proyección: derivada, síncrona
Es el resultado ya calculado, sentado en la memoria del hilo principal, listo para leerse sin esperar. Su único defecto es que describe un pasado que puede haber quedado atrás.
El canal: la única costura
Todo lo que reconcilie ambas copias tiene que caber en mensajes. No hay memoria compartida, no hay lectura de cortesía, no hay atajo.
La versión: el árbitro barato
Un contador monótono que acompaña a cada resultado permite responder la pregunta que importa —si esta proyección corresponde al estado actual— sin volver a consultar nada.
El contrato de una consulta viva
Con el problema situado, el objeto que lo resuelve se deja describir en pocas líneas. Una consulta viva es una suscripción: recibe la sentencia y sus parámetros, entrega un primer resultado en cuanto puede y vuelve a entregar otro cada vez que alguien determina que ese resultado ha dejado de ser válido. Toda la dificultad del nivel está escondida en el verbo determinar, que es lo que ocupa la lección siguiente.
// El contrato minimo, con las cuatro piezas que hacen falta
const cancelar = db.suscribir({
sql: 'SELECT id, titulo FROM notas WHERE proyecto = ? ORDER BY creada DESC LIMIT 50',
params: [7],
onResultado(filas, version) { // se invoca la primera vez y en cada cambio relevante
almacen.set(filas, version); // la proyeccion que la interfaz lee de forma sincrona
},
});
// La pieza que casi nadie escribe el primer dia y que la leccion 5 convierte en obsesion
cancelar();
Antes de las garantías conviene fijar una obviedad que se olvida: la primera entrega también forma parte del contrato, y no es instantánea. En el intervalo entre suscribirse y recibir el primer resultado la pantalla no tiene datos, y tampoco tiene un error: tiene una ausencia legítima que hay que representar. Ese estado inicial es más largo de lo que parece cuando coincide con el arranque en frío del Worker, y es además el único momento en que una consulta viva se comporta exactamente igual que una petición de red, con su carga y su posible fallo. Modelarlo desde el principio evita la solución fea que aparece cuando no se hizo, que consiste en devolver un vector vacío y dejar que la interfaz muestre durante medio segundo el mensaje de que no hay nada.
// Los cuatro estados que la interfaz tiene que saber representar
// arrancando -> el Worker aun no esta listo, no hay ni consulta
// cargando -> suscrita, esperando el primer resultado
// al dia -> hay filas y su version coincide con la de la base
// obsoleta -> se sabe que hay cambios y aun no han llegado
Tres propiedades de ese contrato no son negociables y conviene fijarlas antes de implementar nada. La primera es que la notificación llega después de confirmar la transacción, nunca durante: un suscriptor que observa filas de una transacción que después se deshace pinta un hecho que no ocurrió, y ese error es indistinguible de una corrupción para quien lo padece. La segunda es que el resultado viene versionado, porque un número monótono convierte preguntas difíciles —si un mensaje se perdió, si dos respuestas llegaron desordenadas, si esta proyección es la de ahora— en comparaciones de enteros. Y la tercera es que la suscripción es cancelable y alguien tiene que cancelarla, porque su coste no lo paga quien la creó sino todas las escrituras futuras de la aplicación.
La primera implementación honesta de una consulta viva reconsulta absolutamente todo después de cada transacción confirmada. Es correcta por construcción, se escribe en una tarde y con veinte consultas activas sobre una base pequeña resulta indistinguible de la solución sofisticada. Su valor real no es servir de solución, sino de línea base medible: cuando el barrido total deje de bastar sabrás exactamente cuánto cuesta, qué consultas lo dominan y cuánto margen tiene que ganar la alternativa para justificar su complejidad. Empezar por la precisión sin ese número es optimizar a ciegas.
La frase de Phil Karlton sobre que solo hay dos problemas difíciles en informática, invalidar cachés y nombrar cosas, se cita normalmente como un chiste sobre lo mucho que cuesta acertar con los tiempos de expiración. Al llevar la base de datos al cliente descubres que el chiste apuntaba a algo bastante más profundo, y que el mundo cliente-servidor lo había ocultado bajo una capa de conveniencia que resulta ser accidental. Invalidar una caché es difícil porque exige responder a una pregunta sobre dependencias entre datos —qué resultados derivados dejan de ser correctos cuando cambia una entrada—, y esa pregunta es difícil en el sentido fuerte del término, no en el sentido de tediosa. Lo que la red hacía era permitirte no responderla: como pedir el dato de nuevo era caro pero siempre posible, podías sustituir la respuesta exacta por una aproximación temporal —caduca a los treinta segundos, revalida al enfocar la ventana, vuelve a pedirlo si el usuario insiste— y el sistema quedaba correcto por reintento, no por razonamiento. La expiración por tiempo no era una política de caché: era una forma de comprar corrección con latencia. Cuando el dato está en el disco del propio usuario esa moneda deja de existir, porque ya no hay latencia que gastar ni oráculo remoto al que preguntar, y la pregunta original reaparece desnuda: dada esta escritura, exactamente qué resultados derivados han dejado de ser válidos. Es literalmente la pregunta que la comunidad de bases de datos lleva desde los años ochenta llamando mantenimiento de vistas materializadas, y no es casualidad que las tres lecciones siguientes reproduzcan, con nombres modernos y en el navegador, la misma escalera de soluciones que aquella literatura recorrió: reconsultar, bloquear por predicado, propagar deltas. Local-first no ha inventado un problema nuevo aquí. Ha retirado la capa de red que llevaba treinta años dejándonos ignorar uno viejo, y al retirarla ha devuelto al desarrollador de interfaces un problema de teoría de bases de datos que creía que no era suyo.
- Coge una pantalla real de tu aplicación con base local y cuenta cuántas consultas distintas alimenta. Ese número es el que va a gobernar todo el nivel.
- Escribe una fila desde una segunda pestaña, o desde la consola, sin tocar la interfaz. Cronometra cuánto tarda la pantalla en enterarse y anota qué la despertó, si es que algo lo hizo.
- Busca en tu código una consulta agregada, del tipo contar o sumar. Comprueba que ningún razonamiento basado en los identificadores del resultado podría detectar que hay que refrescarla.
- Implementa la versión tosca: un contador de transacciones confirmadas y un barrido que reconsulta todo cuando cambia. Mide el coste total por escritura y guárdalo como línea base.
- Añade a cada resultado un número de versión y comprueba que puedes decidir si una proyección está al día sin volver a consultar la base.