AsyncImage y caché HTTP en iOS 27: menos magia, más control
Arturo Rivas Arias
🖼️ AsyncImage siempre ha sido una de esas APIs de SwiftUI que entran rápido por los ojos. Le pasas una URL, defines qué quieres mostrar mientras carga, qué hacer cuando llega la imagen y cómo reaccionar ante un fallo. Para una pantalla sencilla parecía suficiente. El problema aparecía cuando esa misma comodidad se llevaba a listas, cuadrículas, o cualquier conjunto de vistas con muchas miniaturas o pantallas que se reconstruyen constantemente al hacer scroll.
Durante años, la duda alrededor de AsyncImage no era si podía descargar una imagen remota. Eso lo hacía bien. La duda era qué ocurría después. Si una imagen volvía a aparecer en pantalla, si una celda se redibujaba, si SwiftUI reconstruía la jerarquía o si el usuario navegaba hacia atrás y adelante, el comportamiento de la caché no era algo que pudiéramos controlar de forma clara desde la propia API.
🔄 Con las novedades de SwiftUI en iOS 27, AsyncImage gana una pieza importante: soporte para caché HTTP estándar por defecto. Esto no significa que Apple haya convertido AsyncImage en una alternativa completa a cualquier implementación manuela. Significa algo más concreto y, a la vez, muy útil: si el servidor responde con las cabeceras HTTP adecuadas para cachear, el sistema de carga de URL puede reutilizar esas respuestas siguiendo las reglas normales del propio protocolo HTTP.
La diferencia es importante. Una cosa es tener una caché de respuestas HTTP y otra muy distinta es tener un control completo sobre el almacenamiento de las imágenes. Una caché HTTP puede evitar repetir una descarga cuando la respuesta sigue siendo válida. Una implementación a medida de descarga de imágenes, suele encargarse además de cachear imágenes ya decodificadas en memoria, redimensionar antes de mostrar, evitar peticiones simultáneas, precargar contenido, gestionar prioridades, cancelar tareas en función del scroll y aplicar políticas de reintentos.
📦 AsyncImage se vuelve mejor por defecto, pero no se vuelve mágico de repente. Si el backend devuelve una imagen con una cabecera como Cache-Control: public, max-age=31536000, immutable, el cliente tiene una indicación muy clara: esa respuesta se puede reutilizar durante mucho tiempo porque el contenido no cambiará bajo la misma URL. Este patrón encaja muy bien con recursos inmutables servidos desde una CDN.
El estándar RFC 9111 define la caché HTTP como un mecanismo para almacenar respuestas y reutilizarlas en solicitudes equivalentes, reduciendo latencia y consumo de red cuando la respuesta almacenada sigue siendo válida. Esa palabra, válida, es la clave. La caché no consiste en guardar cosas sin más, sino en saber cuándo una respuesta puede reutilizarse sin volver a preguntar al servidor y cuándo necesita validación.
⚠️ Por eso el servidor importa tanto como el código Swift. Si la respuesta llega con no-store, con una caducidad muy corta o sin información útil de caché, AsyncImage no puede inventarse una política perfecta para tu caso de uso. El caso clásico es el avatar de usuario. Si el avatar cambia pero la URL sigue siendo exactamente la misma, el cliente puede seguir mostrando la versión anterior mientras la respuesta cacheada siga siendo válida:
https://cdn.example.com/users/42/avatar.png
Una solución rápida sería añadir un valor aleatorio para forzar una URL distinta cada vez:
https://cdn.example.com/users/42/avatar.png?cacheBust=6F4D9A
Pero eso rompe la caché. Cada valor aleatorio convierte el mismo recurso en una URL nueva, así que el cliente y la CDN pierden la oportunidad de reutilizar respuestas. La solución más limpia suele ser versionar el recurso con un valor estable:
https://cdn.example.com/users/42/avatar.png?v=17
O, mejor aún, publicar la imagen como un recurso inmutable:
https://cdn.example.com/avatars/user-42-v17.png
✅ Así el contrato es mucho más predecible. Si el avatar no ha cambiado, la URL no cambia y la caché puede hacer su trabajo. Si el avatar cambia, cambia también la URL y SwiftUI carga la nueva imagen sin tener que desactivar la caché de forma global.
🧭 Otra novedad relevante es poder construir AsyncImage a partir de un URLRequest. Esto abre la puerta a controlar aspectos que antes quedaban demasiado escondidos: la mencionada política de caché, el timeout o las cabeceras cuando el endpoint lo necesita. Ya no estamos limitados a pasar una URL y aceptar todo el comportamiento por defecto.
struct BookCoverView: View {
let coverURL: URL
private var request: URLRequest {
var request = URLRequest(url: coverURL)
request.cachePolicy = .returnCacheDataElseLoad
request.timeoutInterval = 8
return request
}
var body: some View {
AsyncImage(request: request) { phase in
switch phase {
case .empty:
RoundedRectangle(cornerRadius: 12)
.fill(.secondary.opacity(0.15))
.overlay {
ProgressView()
}
case .success(let image):
image
.resizable()
.scaledToFill()
case .failure:
Image(systemName: "book.closed")
.font(.largeTitle)
.foregroundStyle(.secondary)
@unknown default:
EmptyView()
}
}
.frame(width: 120, height: 180)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
En este ejemplo, .returnCacheDataElseLoad tiene sentido si las portadas son recursos prácticamente inmutables. Si la portada ya está en caché, se reutiliza. Si no existe una respuesta cacheada, se carga desde la red. No es una política adecuada para cualquier imagen, pero sí puede funcionar bien para miniaturas versionadas, iconos, stickers o assets publicados en una CDN con URLs estables.
🚫 En el extremo contrario está .reloadIgnoringLocalCacheData, que fuerza al sistema a ignorar la caché local. Puede ser útil en casos muy concretos, pero conviene usarla con cuidado. Desactivar la caché para arreglar una imagen obsoleta suele ser una solución demasiado amplia. El síntoma desaparece, pero a cambio el usuario usa más la red, tiene más latencia y más consumo de energía (batería).
struct SecurityBadgeView: View {
let badgeURL: URL
private var request: URLRequest {
var request = URLRequest(url: badgeURL)
request.cachePolicy = .reloadIgnoringLocalCacheData
request.timeoutInterval = 5
return request
}
var body: some View {
AsyncImage(request: request) { phase in
switch phase {
case .empty:
ProgressView()
case .success(let image):
image
.resizable()
.scaledToFit()
case .failure:
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(.orange)
@unknown default:
EmptyView()
}
}
.frame(width: 64, height: 64)
}
}
Este patrón tendría sentido si la imagen representa un estado que debe comprobarse siempre, por ejemplo una marca temporal generada por el servidor. Aun así, en muchos productos sería mejor que el backend cambiase la URL cuando cambia el contenido o que usase validadores como ETag o Last-Modified para permitir una validación eficiente.
🧱 La otra pieza interesante es poder proporcionar incluso una URLSession personalizado a una parte de la jerarquía con asyncImageURLSession(_:). Esto encaja muy bien con SwiftUI porque la decisión se aplica a partir de un punto del árbol de vistas. Una pantalla de catálogo puede tener una caché aplia, mientras que otra pantalla con imágenes sensibles o poco reutilizables puede tener una configuración distinta.
enum RemoteImageSessions {
static let catalog: URLSession = {
let configuration = URLSessionConfiguration.default
configuration.urlCache = URLCache(
memoryCapacity: 80 * 1024 * 1024,
diskCapacity: 300 * 1024 * 1024
)
configuration.requestCachePolicy = .useProtocolCachePolicy
return URLSession(configuration: configuration)
}()
}
struct WineCatalogView: View {
let bottles: [Bottle]
var body: some View {
ScrollView {
LazyVGrid(columns: [.init(.adaptive(minimum: 140))], spacing: 16) {
ForEach(bottles) { bottle in
BottleCard(bottle: bottle)
}
}
.padding()
}
.asyncImageURLSession(RemoteImageSessions.catalog)
}
}
La sesión debe ser estable. No conviene crear un URLSession nuevo dentro de body, porque SwiftUI puede evaluar el cuerpo de una vista muchas veces. Si cada evaluación crea una sesión distinta, también estamos creando el acceso a red con cada nuevo renderizado. Eso dificulta razonar sobre la caché y puede provocar comportamientos inconsistentes.
// ❌ Evitar este patrón
struct CatalogContainerView: View {
var body: some View {
WineCatalogView(bottles: sampleBottles)
.asyncImageURLSession(URLSession(configuration: .default))
}
}
La alternativa es inyectar una dependencia estable: una propiedad estática, un contenedor de dependencias, un valor de entorno o un objeto de configuración para un conjunto de vistas concreto.
// ✅ Sesión estable y reutilizable
struct CatalogContainerView: View {
var body: some View {
WineCatalogView(bottles: sampleBottles)
.asyncImageURLSession(RemoteImageSessions.catalog)
}
}
⚙️ Este cambio mejora la ergonomía de AsyncImage, pero también obliga a separar responsabilidades. SwiftUI decide cómo representar los estados de carga. URLRequest expresa cómo queremos pedir el recurso. URLSessionConfiguration permite ajustar la caché disponible. El servidor, por su parte, define durante cuánto tiempo puede reutilizarse una respuesta y bajo qué condiciones.
Cuando todas esas piezas están alineadas, el resultado es muy bueno para pantallas simples y medianas: perfiles, tarjetas, miniaturas de artículos, iconos remotos, pequeños catálogos o imágenes de configuración. El código sigue siendo declarativo, no hace falta añadir una dependencia externa y el comportamiento de caché deja de ser una caja tan cerrada.
📉 Pero hay un límite claro. Una pantalla con cientos de imágenes, scroll rápido, tamaños muy grandes, necesidad de prefetching, cancelación exhaustiva de llamadas duplicadas, prioridades, reintentos o downsampling sigue necesitando una pipeline dedicada. La caché HTTP puede evitar descargas repetidas, pero no elimina el coste de decodificar imágenes, redimensionarlas, mantenerlas en memoria o coordinar múltiples solicitudes simultáneas.
En una vistas de galería grande, un marketplace con muchas celdas o un chat con previews multimedia, una librería especializada sigue teniendo sentido. Las soluciones a medida no solo descargan imágenes: modelan todo el ciclo de vida de la carga, desde la petición hasta la representación final. AsyncImage ahora cubre mejor el terreno intermedio, pero no reemplaza esa categoría.
🧠 La regla práctica sería empezar con AsyncImage cuando la pantalla sea sencilla, el número de imágenes sea moderado y el backend tenga las cabeceras de caché apropiadas. Si la carga de imágenes empieza a resultar torpe o bloqueante, si el scroll se resiente, si hay picos de memoria o si necesitas controlar prioridades y precarga, es momento de pasar a una solución específica.
La mejora de iOS 27 no convierte AsyncImage en una arquitectura de imágenes completa. Lo que hace es más interesante: permite que la API continues siendo sencilla pero deje de ser solo una opción de demo y se convierta en una herramienta razonable para implementaciones reales. Menos código propio para los casos normales, más control cuando hace falta y una separación más clara entre SwiftUI, Foundation y el contrato HTTP del servidor.