Cache y frescura: mostrar lo viejo mientras llega lo nuevo
La cache HTTP que ya tienes activada sin saberlo, cómo las cabeceras del servidor gobiernan la frescura y la revalidación con ETag, qué significa de verdad cada política de cache de una petición, y por qué el patrón de servir lo almacenado y refrescar en segundo plano es la diferencia entre una app que parece instantánea y una que parece rota sin cobertura.
Hay una pregunta que casi ninguna app responde bien: qué mostrar en el instante en que el usuario abre una pantalla y la red aún no ha contestado. La respuesta perezosa es una rueda girando, y es la peor de todas, porque convierte cada apertura en una apuesta contra la latencia y cada zona sin cobertura en una pantalla vacía. La respuesta buena es que ya tenías los datos de la última vez, que los pintas de inmediato y que la petición de red solo sirve para corregirlos si cambiaron. Eso no es un truco de interfaz: es una arquitectura de frescura, y HTTP lleva treinta años ofreciendo las piezas para construirla. Esta lección enseña a usarlas en lugar de reinventarlas peor.
- Entender qué hace
URLCachepor defecto y cómo dimensionarla. - Leer las cabeceras que deciden frescura y revalidación, incluida la respuesta 304.
- Elegir con criterio la política de cache de cada petición.
- Implementar el patrón de servir lo almacenado y refrescar en segundo plano.
La cache que ya tienes activada
URLSession incorpora una cache HTTP que funciona desde la primera línea de código y que la mayoría de apps ni configura ni sabe que existe. Con la configuración por defecto, cada respuesta GET que el servidor autorice a almacenar se guarda en memoria y en disco, y una petición idéntica posterior puede resolverse sin tocar la red. Merece la pena hacerlo explícito, porque los tamaños por defecto son conservadores y porque una cache compartida entre subsistemas produce interferencias difíciles de diagnosticar.
let cache = URLCache(
memoryCapacity: 32 * 1024 * 1024, // 32 MB en RAM
diskCapacity: 256 * 1024 * 1024, // 256 MB en disco
directory: nil
)
let configuracion = URLSessionConfiguration.default
configuracion.urlCache = cache
configuracion.requestCachePolicy = .useProtocolCachePolicy
let sesion = URLSession(configuration: configuracion)
Dos límites que conviene conocer antes de confiar en ella. Solo se almacenan respuestas de peticiones seguras —en la práctica, GET— y solo si el servidor no lo prohíbe; un POST no se cachea aunque devuelva un cuerpo idéntico cada vez. Y la clave de la cache es la petición completa, así que dos URL que difieren en un parámetro de consulta irrelevante son dos entradas distintas: incluir una marca de tiempo o un identificador aleatorio en la query es la forma más común de desactivar la cache sin querer.
Si la app descarga imágenes pesadas y también JSON pequeño, dales sesiones separadas con caches propias. Una galería de fotos puede expulsar de la cache todas las respuestas de la API en cuestión de segundos, y el síntoma —la lista tarda más cuanto más navega el usuario— es casi imposible de atribuir a la causa real.
Las cabeceras que gobiernan la frescura
La cache HTTP no adivina: obedece al servidor. Cache-Control es la cabecera que fija la política, y su directiva max-age declara durante cuántos segundos la respuesta se considera fresca. Dentro de esa ventana, la petición se resuelve desde la cache sin red alguna. Pasada la ventana, la respuesta queda rancia, y entonces empieza lo interesante: rancia no significa inservible, significa que hay que preguntar si sigue valiendo.
Esa pregunta se llama revalidación y se hace con validadores. El servidor entrega junto a la respuesta un ETag —una huella opaca del contenido— o un Last-Modified. En la siguiente petición, el sistema envía esa huella en If-None-Match o la fecha en If-Modified-Since. Si nada cambió, el servidor responde 304 Not Modified sin cuerpo, y URLSession te entrega la copia almacenada. El ahorro no es de latencia sino de datos: has confirmado la validez de un megabyte con un intercambio de unos cientos de bytes.
flowchart TD A[Peticion GET] --> B[Hay copia en URLCache] B --> C[Copia fresca dentro de max-age] B --> D[Copia rancia con validador] B --> E[No hay copia] C --> F[Se sirve sin tocar la red] D --> G[Se envia If-None-Match] G --> H[Respuesta 304 sin cuerpo] G --> I[Respuesta 200 con cuerpo nuevo] H --> F I --> J[Se almacena y se sirve] E --> J style F fill:#a6e3a1,color:#11111b style H fill:#89b4fa,color:#11111b style J fill:#f9e2af,color:#11111b
Un servidor que no envía Cache-Control ni validadores deja al sistema aplicar heurísticas, y las heurísticas son impredecibles. Si controlas el servidor, declarar la frescura es la optimización de red más barata que existe; si no lo controlas, mide qué envía antes de diseñar sobre suposiciones.
Fresco
Dentro de la ventana de max-age. Se sirve desde disco sin abrir un solo socket: coste cero y latencia cero.
Rancio y revalidable
Venció la ventana pero hay ETag. Una petición diminuta confirma la validez y el servidor contesta 304 sin cuerpo.
Sin política
El servidor no dijo nada. El sistema aplica heurísticas propias y el comportamiento deja de ser predecible entre versiones.
Conviene además separar dos conceptos que el vocabulario cotidiano confunde. La caducidad es una afirmación temporal del servidor sobre cuánto vale su respuesta; la validación es una pregunta puntual sobre si una copia concreta sigue siendo la buena. La primera ahorra la petición entera y la segunda solo ahorra el cuerpo, pero la segunda funciona indefinidamente y la primera caduca por definición. Una API bien diseñada envía ambas: un max-age corto para la mayoría de los accesos y un ETag que rescate la copia cuando ese plazo venza.
Las políticas de la petición
Cada URLRequest puede sobrescribir la política de la sesión, y los nombres son lo bastante parecidos como para confundirse. Vale la pena fijarlos:
| Política | Qué hace | Cuándo usarla |
|---|---|---|
useProtocolCachePolicy |
Obedece las cabeceras HTTP | El valor por defecto; casi siempre |
reloadIgnoringLocalCacheData |
Ignora la cache y va a la red | Refresco explícito del usuario |
returnCacheDataElseLoad |
Cache aunque esté rancia, si no red | Primera pintura instantánea |
returnCacheDataDontLoad |
Cache o error, nunca red | Modo sin conexión declarado |
La tentación de forzar reloadIgnoringLocalCacheData en todas partes para “asegurar datos frescos” es un error frecuente y caro: desactiva la revalidación, obliga a transferir el cuerpo entero cada vez y convierte cada apertura en una espera. Resérvala para el gesto de arrastrar hacia abajo, donde el usuario ha pedido explícitamente datos nuevos y acepta el coste.
Fíjate en que returnCacheDataElseLoad y returnCacheDataDontLoad ignoran la caducidad por completo: sirven la copia por vieja que sea. Eso las hace excelentes para la primera pintura y peligrosas como política permanente, porque una app que las use siempre nunca volverá a ver un dato nuevo mientras la entrada siga en la cache. Se usan como un paso de una secuencia, no como el estado estable del sistema.
Servir lo viejo y refrescar detrás
Las políticas anteriores obligan a elegir entre lo rápido y lo fresco. El patrón que evita esa elección consiste en no elegir: entregar primero lo almacenado, aunque esté rancio, y emitir después el resultado de la red. La forma natural de expresarlo en Swift es una secuencia asíncrona que produce hasta dos valores, y la vista simplemente se redibuja cuando llega el segundo.
func articulos() -> AsyncThrowingStream<[Articulo], Error> {
AsyncThrowingStream { continuacion in
Task {
// 1. Lo que ya teniamos: instantaneo, quiza rancio.
if let guardados = try? await almacen.leerArticulos(), !guardados.isEmpty {
continuacion.yield(guardados)
}
// 2. Lo que dice el servidor: revalida y corrige.
do {
let frescos = try await cliente.enviar(.articulos(pagina: 1)).map(Articulo.init)
try await almacen.guardar(frescos)
continuacion.yield(frescos)
continuacion.finish()
} catch {
continuacion.finish(throwing: error) // ya mostramos algo util
}
}
}
}
Hay una decisión de diseño escondida en el ejemplo: la copia local no vive en URLCache sino en un almacén propio. Ambas capas son compatibles y resuelven problemas distintos. URLCache opera al nivel del protocolo, sin saber qué significan los bytes, y por eso ahorra transferencias con coste de escritura casi nulo; el almacén de dominio guarda modelos ya validados, sobrevive a la limpieza de la cache y permite consultar, filtrar y ordenar sin red. La regla es sencilla: deja que URLCache optimice el tráfico y que tu almacén garantice que siempre hay algo que mostrar.
Este patrón cambia el significado del error de red. Si la petición falla pero ya emitimos la copia local, el fallo deja de ser fatal y se convierte en una nota al margen: la pantalla tiene contenido y basta con indicar que no está actualizado, con la fecha de la última sincronización. Una app así no tiene pantalla vacía sin cobertura, tiene una pantalla honesta.
Mostrar datos viejos sin decirlo es engañar al usuario, y en dominios sensibles —saldos, disponibilidad, precios— es un fallo grave. El patrón exige su contrapartida en la interfaz: una marca de tiempo, un indicador discreto de sincronización o un aviso cuando el refresco fracasa. La frescura es un dato del modelo, no un detalle visual.
En cuanto una app guarda una respuesta —en URLCache, en disco o en memoria— deja de ser un cliente que consulta y se convierte en un sistema distribuido con una réplica local, con todo lo que eso arrastra: dos copias de la misma información, que divergen sin que nadie lo anuncie, y ninguna manera de saber si la copia local sigue siendo válida sin preguntar. La consecuencia incómoda es que la frescura absoluta no existe: incluso el dato que acaba de llegar es una foto del pasado, obsoleta desde el instante en que salió del servidor, y lo único que puedes elegir es cuánto pasado estás dispuesto a mostrar. Formulado así, el diseño deja de ser una cuestión de rendimiento y pasa a ser una cuestión de contrato con el usuario. La genialidad de las cabeceras HTTP es haber convertido esa negociación en un protocolo explícito: max-age es el servidor declarando cuánto pasado tolera para ese recurso, el ETag es la manera de confirmar validez sin pagar el precio del contenido, y el 304 es la prueba de que la pregunta correcta no es “dame los datos” sino “sigue valiendo lo que tengo”. Servir lo rancio mientras llega lo nuevo es la aplicación literal de esa idea a la interfaz: se desacopla el momento de mostrar del momento de saber, y el usuario deja de pagar la latencia de la red con su tiempo. Pero el precio de esa comodidad es la honestidad: quien muestra una copia sin marcar su edad no está optimizando, está afirmando algo que no puede sostener. La regla que ordena todo el asunto es sencilla y exigente: la edad del dato es parte del dato, y una app que no la modela no está cacheando, está mintiendo por omisión.
- Configura una
URLCacheexplícita para tu sesión y comprueba con Instruments que las peticiones repetidas no viajan. - Inspecciona las cabeceras que devuelve tu API y anota si envía
Cache-Control,ETago ninguna de las dos. - Provoca una revalidación y verifica en el registro de red que el servidor responde 304 sin cuerpo.
- Recorre las cuatro políticas de petición y decide cuál corresponde a la primera carga y cuál al gesto de refrescar.
- Implementa la secuencia de dos valores, muestra la fecha de última actualización y comprueba el comportamiento en modo avión.