El error era «cache-first»
Cualquier estado en el que un usuario puede entrar pero del que no puede salir es un defecto, por muy buena que fuera la razón para entrar.
Publicamos un service worker con estrategia cache-first y un nombre de caché fijo. Es el ejemplo canónico de todos los tutoriales, hace que la aplicación cargue al instante, y es correcto para recursos inmutables y direccionados por contenido.
Nuestra hoja de estilos y nuestro bundle no eran ni inmutables ni direccionados por contenido. Así que un usuario que instalara la aplicación durante una semana de desarrollo activo podía quedarse clavado, para siempre, en el CSS de aquella semana — mientras el código fuente llevaba días arreglado, desplegado y verificado. Recargar no ayudaba. La recarga también se servía desde la caché.
La propiedad que faltaba
La regla que aplicamos ahora no va de cachés. Va de alcanzabilidad:
Todo estado en el que un cliente puede entrar debe tener una arista de salida que el usuario pueda recorrer sin instrucciones de soporte.
Cache-first con clave fija crea un estado absorbente. Una vez dentro, ninguna acción disponible para el usuario lo saca de ahí: ni reabrir la app, ni reconectar, ni esperar. Solo una acción del desarrollador, tomada antes de que el cliente se quedara atrapado, podría haberlo evitado. Es un modo de fallo difícil de descubrir en pruebas, porque la máquina del propio desarrollador nunca está en el estado absorbente.
El cambio
Para nuestros propios recursos, la estrategia es ahora network-first con la caché como recurso de reserva sin conexión. El usuario siempre recibe el código actual cuando tiene conectividad, y sigue teniendo una aplicación funcional cuando no. Pagamos unos cientos de milisegundos con conexión buena. A cambio compramos la garantía de que una corrección desplegada llega a todos los usuarios.
// Recursos propios: network-first. La caché es un recurso de reserva
// sin conexión, no la fuente de verdad — una corrección publicada debe
// poder llegar siempre a un cliente que ya instaló la app.
self.addEventListener("fetch", (event) => {
if (!isOwnAsset(event.request)) return;
event.respondWith(
fetch(event.request)
.then((response) => {
const copy = response.clone();
caches.open(CACHE).then((c) => c.put(event.request, copy));
return response;
})
.catch(() => caches.match(event.request)),
);
});Los recursos con hash en el nombre pueden seguir siendo cache-first: una build nueva produce una URL nueva, así que el estado absorbente no existe. La estrategia debe seguir a la mutabilidad del recurso, y el ejemplo del tutorial daba por supuesto el caso inmutable sin decirlo.
Dónde más se esconden los estados absorbentes
- Un flag de funcionalidad persistido sin ninguna vía de anulación desde el servidor.
- Almacenamiento local migrado que un esquema más nuevo se niega a leer en silencio.
- Un token de autenticación cacheado que ha caducado pero nunca se revalida.
- Un permiso denegado de forma permanente, sin ninguna ruta desde la app a los ajustes del sistema que podrían restaurarlo.
- Un paso de onboarding marcado como completo en un cliente cuyos datos nunca llegaron a guardarse.
La pregunta de revisión es lo bastante corta como para hacerla en cada cambio que escribe en el almacenamiento del cliente: si este valor acaba siendo incorrecto en el dispositivo de un usuario, ¿qué secuencia de acciones a su alcance lo vuelve correcto? Si la respuesta honesta es «reinstalar», el diseño no está terminado.