wandres.dev
WORKMANAGER · trabajo diferido

Cuándo WorkManager: el trabajo que no puede perderse

Antes de escribir una sola línea de código conviene responder a una pregunta que casi ningún equipo formula de forma explícita: qué parte de tu trabajo en segundo plano debe seguir ocurriendo cuando el usuario cierra la aplicación, cuando el sistema mata el proceso para recuperar memoria o cuando el móvil se reinicia. Esta lección clasifica el trabajo en segundo plano en tres familias según quién lo espera y qué se pierde si no ocurre, enuncia con precisión quirúrgica qué garantiza `WorkManager` y qué se niega deliberadamente a garantizar, describe la maquinaria de persistencia y planificación que sostiene esa promesa, y delimita los casos en que recurrir a él es directamente un error de diseño.

⏱ 18 min

Todo el código que has escrito hasta ahora vive dentro de una ficción cómoda: la de un proceso que existe mientras tú lo necesites. Una corrutina lanzada desde un modelo de vista sobrevive a la rotación, sobrevive a la navegación y sobrevive a que el usuario mire otra pantalla. Lo que no sobrevive es a que el sistema decida que tu proceso es prescindible, cosa que ocurre a los pocos segundos de que la aplicación pase a segundo plano si hay presión de memoria, y ocurre siempre y sin excepción cuando el usuario desliza la tarjeta fuera de la lista de recientes o cuando el dispositivo se reinicia. En ese instante, cualquier trabajo a medias desaparece sin dejar rastro, sin excepción, sin registro y sin que nadie se entere: ni el usuario, que asume que su foto se subió, ni tú, que no recibirás ningún informe de fallo porque técnicamente no falló nada. Ese silencio es exactamente el problema que WorkManager existe para resolver, y entenderlo empieza por aceptar que hay una clase de trabajo cuya corrección no puede depender de que un proceso siga vivo.

🎯 Al terminar esta lección sabrás
  • Clasificar cualquier tarea en segundo plano según quién la espera y qué se pierde si no llega a ocurrir.
  • Enunciar con precisión qué garantiza WorkManager y qué se niega deliberadamente a garantizar.
  • Comprender la maquinaria de persistencia y planificación que sostiene esa garantía a través de reinicios.
  • Reconocer los casos en que usar WorkManager es un error de diseño y saber qué corresponde usar en su lugar.

Tres familias de trabajo, no una

La confusión habitual nace de meter en un mismo saco todo lo que no ocurre en el hilo principal. Pero “en segundo plano” no describe una categoría técnica: describe una ausencia. Lo que de verdad clasifica el trabajo son dos preguntas independientes. La primera es si alguien está esperando el resultado ahora mismo mirando la pantalla. La segunda es qué se pierde si el trabajo nunca llega a ejecutarse. El cruce de ambas produce tres familias con herramientas completamente distintas.

Inmediato y observado

El usuario tiene la pantalla delante y espera la respuesta: cargar una lista, validar un formulario, buscar. Vive en una corrutina atada al ámbito de la pantalla y su cancelación al salir es lo correcto, no un defecto.

Diferible y garantizado

Nadie lo mira, puede ocurrir dentro de diez segundos o dentro de dos horas, pero tiene que ocurrir: subir una foto, sincronizar cambios locales, enviar telemetría, comprimir un registro. Este es el territorio de WorkManager.

Atado a un instante exacto

Una alarma que debe sonar a las siete y cuarto, un recordatorio de medicación, un evento de calendario. Aquí la puntualidad es el requisito y WorkManager no la ofrece: corresponde a AlarmManager con sus variantes exactas.

ℹ️
El error de clasificar por duración

La intuición dice que lo largo va al segundo plano y lo corto se hace en el momento, y esa intuición produce arquitecturas equivocadas. Enviar dos líneas de texto es cortísimo y pertenece a la familia garantizada si el usuario dio la acción por hecha; recorrer diez mil filas para pintar una estadística es larguísimo y pertenece a una corrutina de pantalla si el usuario está esperando el gráfico. Lo que decide no es cuánto tarda, sino qué se rompe si desaparece.

La segunda familia es la interesante porque es la única cuya corrección no se puede expresar dentro del ciclo de vida de un componente. Una corrutina en un modelo de vista es una promesa condicionada: haré esto si nadie me mata. Un trabajo encolado en WorkManager es una promesa incondicional que se apoya en algo que sí sobrevive al proceso, que es el disco.

Hay además una cuarta categoría que no aparece en ningún diagrama oficial y que causa más incidentes que las tres anteriores juntas: el trabajo que el usuario cree que está ocurriendo. Cuando alguien pulsa el botón de publicar y ve desaparecer el formulario, ha establecido un contrato mental de que el mensaje se envió. Si ese envío vive en una corrutina de pantalla y el usuario cierra la aplicación dos segundos después, el contrato se rompe sin que nadie lo note. La regla operativa que se deriva es simple y muy poco aplicada: en cuanto la interfaz confirma visualmente una acción, esa acción pertenece a la segunda familia, no importa lo rápida que parezca.

Conviene además entender por qué esta distinción se volvió obligatoria y no es una preferencia estilística. Hasta Android 7 se podía tener un servicio de fondo indefinido y una radio que escuchaba emisiones implícitas del sistema, y el resultado agregado fue un desastre de batería que nadie controlaba: decenas de aplicaciones despertándose ante cada cambio de conectividad. A partir de Android 8 el sistema cerró esa puerta con los límites de ejecución en segundo plano, y a partir de Android 9 introdujo los cubos de espera que racionan la frecuencia con que cada aplicación puede despertarse según lo que el usuario la use. La consecuencia práctica es que el sistema operativo pasó a ser el planificador, y tu papel dejó de ser decidir cuándo se ejecuta el trabajo para pasar a ser declarar bajo qué condiciones es aceptable ejecutarlo.

El contrato: qué promete y qué se niega a prometer

Un contrato mal entendido es peor que no tener contrato, porque produce arquitecturas que dependen de propiedades inexistentes. Merece la pena enunciar las dos mitades con el mismo cuidado.

Lo que WorkManager promete es esto. Primero, persistencia: al encolar un trabajo, su descripción se escribe en una base de datos interna del propio proceso antes de que la llamada devuelva el control. Segundo, ejecución eventual: mientras la aplicación esté instalada, ese trabajo se ejecutará en algún momento aunque el proceso muera, aunque el usuario cierre la aplicación y aunque el dispositivo se reinicie. Tercero, respeto de las restricciones: no se ejecutará hasta que se cumplan las condiciones que declaraste. Cuarto, reintento con retroceso: si el trabajo señala que quiere reintentarse, el sistema lo reprograma con una espera creciente. Y quinto, compatibilidad hacia atrás: la misma llamada funciona en las versiones antiguas y en las modernas eligiendo por debajo el mecanismo adecuado.

Lo que no promete es igual de importante. No promete un instante concreto de ejecución, ni siquiera aproximado, y cualquier diseño que dependa de que algo ocurra dentro de los próximos cinco minutos está construido sobre arena. No promete ejecución exactamente una vez: promete al menos una vez, lo que significa que un trabajo puede ejecutarse dos veces si el proceso muere justo después de completar su efecto y antes de registrar el resultado. No promete ejecutarse si el usuario fuerza la detención de la aplicación desde los ajustes, porque esa acción deja la aplicación en un estado detenido del que solo sale con una interacción explícita. Y no promete nada frente a las capas de gestión de energía agresivas que instalan algunos fabricantes por encima del sistema, que matan trabajos perfectamente legítimos y son la causa de una fracción sustancial de los informes de trabajos que nunca ocurren.

La expresión mientras la aplicación esté instalada merece precisarse, porque delimita el alcance real de la garantía. Los trabajos viven en una base de datos dentro del almacenamiento privado de la aplicación, de modo que desinstalar los borra, y borrar los datos desde los ajustes del sistema también. Una actualización, en cambio, los conserva, y eso introduce un problema que casi nadie contempla: un trabajo encolado por la versión anterior puede ejecutarse bajo la versión nueva, con una clase que quizá cambió de nombre o de firma de entrada. Un trabajador que no encuentra su clase o que recibe una entrada con el formato viejo falla de forma definitiva y silenciosa, y ese es el tipo de incidente que solo se ve en los días siguientes a una publicación.

Merece una mención aparte el mecanismo que más distorsiona los tiempos observados y que casi nunca se reproduce en desarrollo. El modo de descanso agrupa el trabajo diferido de todo el dispositivo en ventanas de mantenimiento cada vez más espaciadas cuando el móvil lleva rato quieto, apagado y sin cargar; los cubos de espera, por su parte, clasifican cada aplicación según la frecuencia con que el usuario la abre y racionan en consecuencia cuántas veces al día puede despertarse. La combinación produce un fenómeno que hay que interiorizar: la latencia de tu trabajo no es una propiedad de tu código, es una propiedad del comportamiento del usuario. La misma aplicación, con el mismo trabajo y las mismas restricciones, se ejecuta en minutos en el dispositivo de quien la usa a diario y en horas en el de quien la abrió una vez el mes pasado.

⚠️
Al menos una vez significa exactamente eso

La garantía es de ejecución, no de unicidad. Si tu trabajo cobra dinero, envía un correo o publica un mensaje, tiene que ser idempotente por construcción: llevar un identificador único de operación que el servidor reconozca y descarte si ya lo procesó. Diseñar asumiendo una sola ejecución funciona durante meses y luego produce un cargo duplicado que nadie sabe explicar.

flowchart TD
A[La app encola un trabajo] --> B[Se escribe en la base de datos interna]
B --> C{Se cumplen las restricciones}
C -->|No| D[Espera bloqueado y no consume nada]
C -->|Si| E[El sistema despierta el proceso y ejecuta]
E --> F{Resultado}
F -->|Exito| G[Se marca como terminado]
F -->|Reintento| H[Se reprograma con retroceso creciente]
F -->|Fallo| I[Se marca como fallido de forma definitiva]
H --> C
D -.-> J[El movil se reinicia]
J --> B
style G fill:#a6e3a1,color:#11111b
style I fill:#f38ba8,color:#11111b

La maquinaria que sostiene la promesa

La garantía no es magia y conviene conocer su mecánica, porque explica tanto sus virtudes como sus limitaciones. Por debajo hay una base de datos local, gestionada con Room, en la que cada trabajo encolado se materializa como una fila con su identificador, su clase, sus datos de entrada, sus restricciones, su estado y su contabilidad de intentos. Esa escritura ocurre de forma síncrona respecto a la garantía: cuando la llamada de encolado retorna, el trabajo ya es un hecho persistido, y a partir de ahí el proceso puede morir sin consecuencias.

Sobre esa base de datos operan dos planificadores. Uno vive dentro del propio proceso y se ocupa de los trabajos que pueden ejecutarse ya, sin restricciones ni retraso, mientras la aplicación esté viva; es el camino rápido y evita pagar el coste de delegar en el sistema para algo inmediato. El otro delega en el planificador de tareas del sistema operativo, que es quien conoce el estado global del dispositivo y quien puede despertar tu proceso más tarde. En las versiones modernas ese planificador es JobScheduler; en la rama de compatibilidad antigua se usaba una combinación de AlarmManager con radios de emisión.

Ese doble camino explica un comportamiento que desconcierta la primera vez que se observa: un trabajo sin restricciones encolado con la aplicación abierta arranca casi instantáneamente, mientras que el mismo trabajo encolado justo antes de que el proceso muera puede tardar minutos. No es aleatoriedad ni un fallo: es que en el primer caso lo atendió el planificador interno y en el segundo tuvo que atenderlo el del sistema, que agrupa despertares y no tiene ninguna prisa. Comparar tiempos de ejecución entre esas dos situaciones sin saberlo lleva a conclusiones erróneas sobre el rendimiento de la librería.

La supervivencia al reinicio se apoya en el mismo mecanismo. La librería declara en su manifiesto una radio que escucha el arranque completado del sistema; cuando el dispositivo termina de encenderse, esa radio se activa, la librería abre su base de datos, encuentra las filas que quedaron pendientes y las vuelve a entregar al planificador del sistema. Nada de esto requiere código tuyo, pero explica una consecuencia sutil que sorprende en depuración: entre el reinicio y la reprogramación hay una ventana en la que el trabajo existe en disco pero no está planificado en el sistema.

val subida = OneTimeWorkRequestBuilder<SubirFotoWorker>()
    .setInputData(workDataOf("uri" to uri.toString()))
    .build()

WorkManager.getInstance(context).enqueue(subida)
// al retornar esta linea, el trabajo ya sobrevive a la muerte del proceso

Ese reparto entre dos planificadores explica también un límite compartido que sorprende en aplicaciones grandes: el planificador del sistema admite un número acotado de tareas registradas por aplicación, del orden del centenar, y ese presupuesto lo comparten tu librería de trabajo, tu librería de sincronización y cualquier otra que programe tareas. Encolar trabajos individuales por cada elemento de una lista de mil es una forma segura de agotarlo. La solución es siempre la misma: un trabajo que procese un lote leyendo de tu base de datos, en lugar de mil trabajos que procesen un elemento cada uno.

Hay un detalle de inicialización que conviene conocer porque aparece antes o después. La librería se arranca sola mediante el mecanismo de inicialización de aplicaciones de Jetpack, sin que tengas que tocar nada. Cuando necesitas configurarla, típicamente para inyectar una fábrica de trabajos que resuelva dependencias, se desactiva ese arranque automático en el manifiesto y la clase de aplicación pasa a proveer la configuración.

class MiApp : Application(), Configuration.Provider {
    override val workManagerConfiguration: Configuration
        get() = Configuration.Builder()
            .setWorkerFactory(miFabricaConHilt)
            .setMinimumLoggingLevel(Log.INFO)
            .build()
}

Dónde no debe usarse

Un instrumento que garantiza mucho cuesta mucho, y aplicarlo donde no toca produce latencia innecesaria, complejidad de código y una peor experiencia. Cuatro contraejemplos ordenados por frecuencia con la que se ven en revisiones de código.

El primero y más común es usarlo para trabajo que el usuario está esperando en pantalla. Cargar los datos de una lista al abrirla no es trabajo diferible: nadie quiere que su lista aparezca cuando el sistema lo considere oportuno. Eso pertenece a una corrutina en el ámbito de la pantalla, con su cancelación automática al salir.

La versión sofisticada de este mismo error consiste en encolar un trabajo desde la pantalla y quedarse esperando su resultado para pintar. Si estás esperando, no es diferible; y si no es diferible, estás pagando el coste de la persistencia, del planificador y de la serialización a cambio de nada. La regla es que quien encola no espera: encola y sigue, y la pantalla se entera después observando, si es que le interesa.

El segundo es usarlo para instantes exactos. La ejecución expedita reduce la latencia pero no la fija, y las ventanas de mantenimiento del modo de descanso pueden retrasar un trabajo periódico horas enteras. Un despertador construido sobre WorkManager funciona en el emulador y falla en el móvil de un usuario real, que es la peor combinación posible de síntomas.

El tercero es usarlo para trabajo continuo que el usuario percibe mientras ocurre: reproducir audio, grabar una ruta con el receptor de posición, una llamada. Eso exige un servicio en primer plano con su notificación visible, porque el contrato con el usuario es distinto: le estás diciendo que esto sigue ocurriendo y él puede detenerlo.

El cuarto es usarlo como cola de mensajería de baja latencia entre servidor y dispositivo. Si el servidor tiene algo que decir, el camino correcto es una notificación de datos por mensajería en la nube; WorkManager entra después, como el mecanismo que garantiza que el trabajo derivado de ese mensaje se complete aunque el proceso muera a mitad.

💡
La combinación que sí funciona

El patrón sano en una aplicación conectada usa las tres herramientas en su sitio y no una en lugar de otra: la mensajería en la nube despierta al dispositivo cuando hay novedades, WorkManager garantiza que el trabajo derivado se complete pase lo que pase, y una corrutina de pantalla atiende lo que el usuario está mirando ahora mismo. Los problemas aparecen cuando se intenta que una de las tres haga el trabajo de las otras dos.

Queda una última observación que ordena todo lo anterior y que conviene tener presente al diseñar. La pregunta de si usar WorkManager no se responde mirando la duración de la tarea ni su consumo, que es el criterio intuitivo y equivocado; se responde mirando su relación con el tiempo. Si la tarea es corta pero no puede perderse, va aquí. Si es larguísima pero el usuario la está mirando, no va aquí. La duración no clasifica nada; lo que clasifica es quién espera el resultado y qué se rompe si nunca llega.

El cambio de sujeto: de ordenar cuándo a declarar bajo qué condiciones

Hay un desplazamiento conceptual detrás de toda esta lección que trasciende con mucho la API concreta, y que es la razón por la que tantos desarrolladores que vienen de otras plataformas construyen sistemas que funcionan en su mesa y se desmoronan en el mundo. En un servidor, o en un escritorio, el proceso es el sujeto de la frase: yo decido cuándo se ejecuta mi tarea, yo mantengo el hilo vivo, yo poseo el tiempo de cómputo, y el sistema operativo es un proveedor de recursos que se limita a obedecer. En un teléfono esa relación está invertida y lo está por una razón física irrebatible: hay una batería finita compartida entre cientos de aplicaciones que se creen igual de importantes, y ninguna de ellas está en condiciones de decidir si merece la pena encender la radio ahora mismo, porque ninguna sabe lo que están haciendo las demás ni cuánta carga queda ni si el usuario está durmiendo. El único agente con esa información global es el sistema, y por eso el sistema se quedó con la decisión. Lo que te queda a ti no es peor, es distinto: pasas de imperativo a declarativo, de ordenar a describir. Ya no dices ejecuta esto ahora, dices esto debe ocurrir alguna vez, no antes de que haya red, no si la batería está crítica, y si falla reinténtalo con esta política. Estás rellenando un contrato, no invocando una función. Y una vez aceptado el cambio de sujeto, todo lo demás de este nivel deja de parecer un conjunto de peculiaridades arbitrarias y se convierte en lo que realmente es: la gramática con la que se escriben esos contratos. Las restricciones son las cláusulas de condición, la política de reintento es la cláusula de incumplimiento, la política de conflicto es la cláusula de exclusividad y la cadena es la cláusula de precedencia. Quien insiste en seguir siendo el sujeto de la frase acaba escribiendo servicios que el sistema mata, alarmas exactas para cosas que no lo necesitan y hilos que se evaporan sin dejar registro; quien acepta ser el redactor del contrato descubre que el sistema es un ejecutor extraordinariamente fiable de aquello que se le pide en su propio idioma.

⚔️ Clasifica antes de programar
  1. Enumera todas las tareas en segundo plano de una aplicación real que conozcas y sitúa cada una en una de las tres familias, justificando la elección con las dos preguntas de la primera sección.
  2. Para cada tarea de la familia diferible, escribe en una frase qué se pierde exactamente si nunca llega a ejecutarse y quién se daría cuenta.
  3. Toma una tarea que hoy viva en una corrutina de modelo de vista y razona por escrito qué ocurre si el proceso muere a mitad; comprueba tu razonamiento matando el proceso desde el terminal.
  4. Identifica en tu código alguna operación no idempotente que se ejecute en segundo plano y diseña el identificador de operación que la haría segura ante una ejecución duplicada.
  5. Busca una tarea que hoy uses con WorkManager y que en realidad pertenezca a otra familia, y describe qué mecanismo sería el correcto.