📆 ⏱️ 7 min de lectura

Todos lo hemos escrito mil veces:

Task { [weak self] in
    await self?.descargarDatos()
}

Usamos [weak self] religiosamente para evitar memory leaks. ✅ Problema resuelto, ¿verdad?

Pues no del todo.

El problema que no sabías que tenías

Imagina esta situación super común:

class PerfilViewController {
    func cargarPerfil() {
        Task { [weak self] in
            let foto = await descargarFotoGigante() // 10 segundos
            self?.imagenPerfil.image = foto
        }
    }
}

El usuario abre el perfil, pero a los 2 segundos se arrepiente y cierra la pantalla.

¿Qué pasa?

  • ✅ El ViewController se destruye
  • self se vuelve nil
  • ✅ No hay memory leak
  • PERO la descarga sigue corriendo 8 segundos más 🧟

Estás desperdiciando batería, datos y CPU en algo que nadie va a ver.

La demostración: 3 casos reales

Me puse a investigar esto y creé una app de prueba con 3 casos para entender qué estaba pasando realmente.

🔴 Caso A: Memory Leak

El problema clásico que todos conocemos:

class CasoAViewModel: ObservableObject {
    private var task: Task<Void, Never>?

    func startWork() {
        // ❌ NO usa [weak self] - captura fuerte
        task = Task {
            await self.performWork()
        }
    }
}

Qué pasa:

  • Task retiene → self
  • self retiene → task
  • Ciclo cerrado ♻️
Logs del Caso A mostrando que el ViewModel nunca se libera

Mira los logs: después de 22 ticks, NO aparece deinit. El ViewModel nunca se libera. Clásico memory leak.

🔍 Proof en Memory Graph

Si abres el Memory Graph Debugger y navegas varias veces al Caso A, verás esto:

Memory Graph mostrando 3 instancias de CasoAViewModel acumuladas en memoria debido al retain cycle

Mira lo que pasa: en el panel izquierdo aparece CasoAViewModel (3) - hay 3 instancias del ViewModel vivas en memoria al mismo tiempo. Cada vez que entras a la vista, se crea una nueva instancia pero nunca se libera porque:

  • La Task retiene al ViewModel (captura fuerte)
  • El ViewModel retiene a la Task (propiedad task)
  • Resultado: Ciclo cerrado que impide que ARC libere la memoria 🔄

🧟 Caso B: Task Zombie (El problema real)

Agregamos [weak self] para evitar el leak:

class CasoBViewModel: ObservableObject {
    func startWork() {
        // ✅ Usamos [weak self]
        Task { [weak self] in
            await longWork()  // 15 segundos
            self?.updateUI()
        }
    }
}
Logs del Caso B mostrando que el ViewModel se destruye pero la Task sigue ejecutándose

Mira esto de cerca:

⚡️ Tick 6
💀 deinit llamado - Vista liberada de memoria
⚡️ Tick 7  ← ¿QUÉ? ¡Sigue corriendo!
⚡️ Tick 8
⚡️ Tick 9
...hasta Tick 10

Y aquí está lo interesante:

El ViewModel se destruyó correctamente (deinit aparece). No hay memory leak. ✅

PERO la Task sigue ejecutándose durante 4 ticks más. 🧟

El verdadero problema

Aquí es donde empecé a entender algo importante: [weak self] rompe retain cycles, pero NO cancela el trabajo.

Cuando el ViewModel se destruye, la Task sigue vivita y coleando:

  • ❌ La Task sigue consumiendo CPU
  • ❌ Sigue usando batería
  • ❌ Si era una descarga, sigue descargando
  • ❌ Si era un request API, sigue esperando respuesta

Esto es lo que llamo un “Task Zombie”: no hay leak en memoria, pero estás desperdiciando recursos sin darte cuenta.


✅ Caso C: Entendiendo cómo funciona realmente

Después de investigar, descubrí que la solución requiere varias piezas trabajando juntas:

class CasoCViewModel: ObservableObject {
    private var task: Task<Void, Never>?

    deinit {
        // 1. Cancelamos la Task manualmente
        task?.cancel()
    }

    func startWork() {
        task = Task { [weak self] in  // 2. Seguimos usando [weak self]
            // Trabajo principal
            for i in 1...10 {
                // 3. Checkeamos si nos han cancelado
                if Task.isCancelled {
                    return
                }
                await self?.performWork()
            }
        }
    }
}

Y en SwiftUI:

.onDisappear {
    viewModel.cancelWork()  // ← Cancela cuando el usuario cierra la vista
}

¿Por qué tantas piezas?

Al principio me pareció excesivo, pero cada una cumple un rol específico:

  • [weak self] → Evita el memory leak (esto ya lo sabíamos)
  • task?.cancel() → Envía la “señal de cancelación” a la Task
  • Task.isCancelled → La Task pregunta “¿me están cancelando?” y se detiene

Cancelar una Task en Swift no la detiene automáticamente. La cancelación es una señal, y el código debe comprobarla y decidir salir. Si no lo hace, la Task seguirá ejecutándose aunque esté “cancelada”.

❌ Cancelada, pero NO cooperativa (Task zombie)

Task {
    for i in 1...10 {
        print("Working \(i)")
        await Task.sleep(for: .seconds(1))
    }
}

Aunque llames a .cancel():

  • la Task no lo comprueba
  • sigue hasta el final

✅ Cancelada y cooperativa

Task {
    for i in 1...10 {
        if Task.isCancelled {
            print("Cancelled, stopping")
            return
        }

        print("Working \(i)")
        await Task.sleep(for: .seconds(1))
    }
}

Ahora sí:

  • recibe la señal
  • la detecta
  • se detiene

¿Y si tengo recursos que limpiar?

Si tu Task abre conexiones de red, archivos, o cualquier recurso que necesite limpieza explícita, entonces sí usa withTaskCancellationHandler:

task = Task { [weak self] in
    await withTaskCancellationHandler {
        // Tu trabajo
        for i in 1...30 {
            if Task.isCancelled { return }
            await self?.performWork()
        }
    } onCancel: {
        // Limpia recursos cuando se cancela
        stream?.close()
        connection?.cancel()
    }
}

Pero si no tienes nada que limpiar, no lo necesitas.

UIKit vs SwiftUI

La diferencia principal está en dónde cancelas:

En UIKit:

Cancelas en el deinit de la clase que tiene la Task (normalmente el ViewModel):

class MiViewModel {
    private var task: Task<Void, Never>?

    deinit {
        task?.cancel()
    }
}

Cuando el ViewController se destruye, el ViewModel se destruye también, y el deinit cancela la Task.

En SwiftUI:

Cancelas en el .onDisappear de la vista:

.onDisappear {
    viewModel.cancelWork()
}

No necesitas el deinit en el ViewModel porque onDisappear ya cancela cuando el usuario cierra la vista.

Logs del Caso C mostrando la cancelación inmediata de la Task

Ahora sí:

⚡️ Tick 7
👋 Vista desapareció
🛑 Cancelando trabajo manualmente
🧹 Cleanup: liberando recursos...
❌ Trabajo CANCELADO  ← Se detuvo aquí
💀 deinit llamado
(FIN - no más ticks)

La Task se detiene inmediatamente cuando cierras la vista. ✅

SwiftUI: cuando .task {} te ahorra todo esto

En SwiftUI, muchas de estas preocupaciones pueden resolverse de forma más sencilla usando el modificador .task {} en la vista.

Las tasks creadas con .task {} están estructuradas y ligadas al ciclo de vida de la vista. Esto significa que SwiftUI las cancela automáticamente cuando la vista desaparece o se recrea, evitando por defecto las “Tasks Zombie” sin necesidad de usar onDisappear.

Ejemplo:

struct PerfilView: View {
    @StateObject var viewModel = PerfilViewModel()
    let userId: Int

    var body: some View {
        PerfilUI(state: viewModel.state)
            .task {
                await viewModel.cargarPerfil(userId: userId)
            }
    }
}

En este caso:

  • La Task se inicia cuando la vista aparece
  • Se cancela automáticamente cuando la vista desaparece
  • No es necesario guardar la referencia a la Task
  • No es necesario cancelar manualmente en onDisappear

Eso sí, la cancelación sigue siendo cooperativa: el código dentro de la Task debe responder correctamente a la cancelación (Task.isCancelled, Task.checkCancellation(), o APIs async que ya cooperen).

Por tanto, en SwiftUI:

  • .task {} es la opción recomendada para trabajo ligado a la vista
  • Guardar y cancelar Tasks manualmente sigue siendo útil cuando:
  • la Task se lanza desde acciones (botones, intents)
  • el trabajo no depende estrictamente del ciclo de vida de la vista
  • necesitas control explícito (retry, cancel button, etc.)

Las piezas del puzzle

Guardar la referencia a la Task:

private var task: Task<Void, Never>?

Así puedes cancelarla después.

Cancelar cuando ya no la necesitas:

// En UIKit (en la clase que tiene la Task):
deinit {
    task?.cancel()
}

// En SwiftUI (en la vista):
.onDisappear {
    viewModel.cancelWork()
}

En UIKit cancelas en el deinit de la clase que tiene la Task (ViewModel). En SwiftUI cancelas en .onDisappear de la vista.

La Task tiene que “cooperar” y checkear si la están cancelando:

for i in 1...30 {
    if Task.isCancelled {
        return  // Me detengo aquí
    }
    await work()
}

Si tienes recursos que limpiar:

Solo si tu Task abre conexiones, archivos u otros recursos que necesiten limpieza explícita:

await withTaskCancellationHandler {
    // Tu trabajo
} onCancel: {
    // Limpiar recursos
    stream?.close()
    request?.cancel()
}

Si no tienes nada que limpiar, no lo necesitas.

Mi checklist

¿Tiene sentido que siga corriendo si el usuario cierra la pantalla?

  • Si la respuesta es NO → guardo la referencia y cancelo en deinit o onDisappear
  • Si la respuesta es SÍ → uso Task.detached { } (ej: subir una foto en background)

¿La Task hace trabajo en un loop o puede tardar mucho?

  • Checkeo Task.isCancelled periódicamente para detenerme cuando me cancelen

¿Hay recursos que limpiar? (conexiones de red, archivos abiertos, etc.)

  • Uso withTaskCancellationHandler para limpiar recursos cuando me cancelen

Puedes ver el código completo de estos ejemplos en este repo de GitHub.

Recursos

Nos leemos 🫶