Una caché de datos es una copia con fecha. El problema no es guardarla: es saber cuándo deja de ser verdad, qué sobrevive a una recarga y qué pasa con lo que el usuario escribió sin conexión.
Una caché de datos en el cliente es una copia de lo que te dio el servidor, guardada con dos datos extra: la clave (qué consulta representa) y la fecha de captura. No es la caché HTTP del navegador, que va por URL y cabeceras; esta la controla tu aplicación y vive en memoria, localStorage o IndexedDB (la base de datos del navegador).
Un dato es fresh mientras está dentro de la tolerancia que elegiste y stale cuando la pasó. Esa tolerancia es de producto: el precio de una acción está viejo a los 5 segundos; el nombre de un usuario aguanta 10 minutos. Con stale-while-revalidate pintas la copia igual y pides la nueva en segundo plano; cuando llega, la reemplazas sin spinner.
El problema real no es llenar la caché, es vaciarla. Después de crear un pedido, la lista cacheada sigue diciendo 19. Tres estrategias: por tiempo (refetch cada N segundos; simple, pero muestra datos viejos y gasta peticiones), por evento (tras una mutación marcas las claves afectadas; preciso, exige saber qué toca cada escritura) y por versión (el servidor devuelve un ETag y el cliente descarta si cambió; robusto, necesita soporte del servidor).
Lo que debe sobrevivir a una recarga no es estado de React: es estado de la URL. Filtros, página, pestaña y búsqueda en searchParams: recargar reconstruye la misma vista, el botón atrás funciona y el enlace se comparte. Lo que no va ahí: tokens, textos largos y borradores. Un borrador se guarda aparte, con una versión, y se recupera al volver.
Una actualización optimista aplica el cambio en la copia local antes de que el servidor conteste, guarda el valor anterior y lo restaura si la petición falla. Se siente instantánea porque no espera la red. El riesgo: muestras una promesa como si fuera un hecho; si el servidor valida distinto, el valor salta hacia atrás. Por eso hay que avisar del fallo y reconciliar con la respuesta del servidor.
Sin conexión, lo que permite que la app abra es el service worker: un script que el navegador instala aparte e intercepta la red aun con la pestaña cerrada. En install precarga el shell (HTML, JS y CSS con hash), en activate borra cachés viejas y en fetch decide: cache-first para archivos con hash, network-first para HTML y datos, y nunca cachear POST ni respuestas con datos personales.
Lo que el usuario escribe sin conexión va a una cola de escritura pendiente (IndexedDB) y se reenvía al volver. Ahí aparecen dos problemas: el orden (un renombre y luego un borrado deben reenviarse en ese orden) y los conflictos (el registro cambió en otro dispositivo). Se resuelve con última escritura gana (simple, pierde datos), merge por campo (necesita la versión base) o un 409 con la versión actual. Casi siempre decide el servidor: ve a todos los escritores y el orden canónico.
// Optimista con reversión: guarda, aplica, revierte. async function guardarTitulo(id: string, titulo: string) { const clave = ['job', id]; // 1. copia de seguridad const anterior = cache.get(clave); // 2. la UI ya lo muestra cache.set(clave, { ...anterior, titulo }); marcarPendiente(id, true); try { const ruta = '/jobs/' + id; const guardado = await api.patch(ruta, { titulo }); // 3. manda el servidor cache.set(clave, guardado); } catch (error) { // 4. revierte cache.set(clave, anterior); avisar('No se guardó el título. Intenta de nuevo.'); throw error; } finally { marcarPendiente(id, false); } }
React trae el mismo patrón en useOptimistic: la copia optimista vive mientras la acción está pendiente y se descarta al terminar; si la acción lanza, vuelve al valor real.
| Herramienta | Qué se mira | Cómo se ve el síntoma |
|---|---|---|
| Application › Service Workers | Estado y versión del SW. | "waiting" sirviendo HTML viejo; "redundant": falló la instalación. |
| Application › Cache Storage | Entradas por caché y versión. | HTML viejo, dos versiones a la vez, datos privados cacheados. |
| Network › Offline | Qué falla y qué sale de caché. | Todo rojo salvo el shell: abre pero no lee. |
| Consola | Errores 409/412 y "Failed to fetch". | Conflicto sin manejar; fetch sin catch. |
| Network, contar peticiones | Peticiones duplicadas por vista. | Dos GET iguales: falta deduplicación. Dos PATCH: doble envío. |
| Application › IndexedDB | Cola pendiente: tamaño, orden e ids. | Crece y no baja: el reenvío falla en silencio. |
Si al recargar aparece el dato viejo y luego salta, falla la invalidación; si no aparece nada, el service worker no sirve el shell.
“We cache data by query key with a freshness window and serve stale data while revalidating.”
“Optimistic updates keep the previous value so we can roll back and tell the user if the write fails.”
“Offline writes go into an ordered queue with an idempotency key, and the server resolves conflicts.”
Comprueba que lo tienes
Cuatro preguntas. Al comprobar se marca la lección como completada.
La UI muestra el título nuevo y el PATCH falla a los 400 ms. ¿Qué hace la reversión?
Por qué: el valor optimista es una promesa, no un hecho: sin la copia anterior no hay nada que restaurar y el usuario cree que guardó. Recargar borra el contexto y no explica qué pasó.
Dos pestañas editan el mismo registro. ¿Por qué el conflicto se resuelve en el servidor?
Por qué: cada pestaña solo conoce su copia y puede creer que su escritura es la última. El servidor ve ambas y decide con una versión; el cliente propone y avisa.
Tras crear un pedido, el listado cacheado sigue en 19. ¿Qué lo corrige con menos riesgo?
Por qué: el problema no es el tiempo sino un dato que ya sabes que cambió; invalidar por evento es preciso y no castiga al resto. staleTime cero convierte cada visita en una espera.
Un usuario renombra sin conexión y al volver la escritura se reenvía dos veces. ¿Qué falta?
Por qué: el replay puede repetirse por un timeout o un cierre a medias; la única forma de aplicar una vez es una clave estable. Cachear la respuesta no evita el segundo envío.