BackgroundTasks parece resolver el mismo problema que Celery: ejecutar trabajo después de que se envía la respuesta, sin hacer que el usuario tenga que esperarlo. Está integrado en FastAPI, no necesita infraestructura adicional y el código para usarlo ocupa tres líneas. Esa simplicidad es exactamente la razón por la que los equipos recurren a él para enviar correos de confirmación, iniciar un trabajo de generación de informes o escribir una entrada en el registro de auditoría, y funciona perfectamente hasta que un despliegue ocurre a mitad de la solicitud, o el proceso es terminado por OOM, o el autoscaler del pod decide finalizar una instancia; entonces la tarea que estaba “en segundo plano” simplemente nunca ocurrió, sin error, sin reintento y sin registro alguno de que alguna vez debió ejecutarse.
Ese modo de fallo no es un bug de BackgroundTasks. Es BackgroundTasks haciendo exactamente lo que fue diseñado para hacer: ejecutar una corrutina en el mismo proceso, después de la respuesta, sin una capa de persistencia detrás. El error real es usarlo para trabajo que necesitaba las garantías que solo proporciona una cola de tareas real. Este artículo explica qué es realmente BackgroundTasks, las condiciones específicas en las que pierde trabajo silenciosamente, qué añade Celery para corregir eso y un marco concreto para elegir entre ambos en lugar de usar por defecto el primero que configuraste.
Aprenderás:
- Qué hace realmente
BackgroundTaskspor debajo y por qué se ejecuta en el mismo proceso que la solicitud - Los escenarios específicos de fallo — despliegues, crashes, autoscaling — en los que
BackgroundTaskspierde trabajo silenciosamente - Qué añade Celery a nivel estructural: un broker, persistencia, reintentos y procesos worker independientes
- Un marco de decisión basado en requisitos de durabilidad, necesidad de reintentos y duración esperada de la tarea
- Cómo combinar ambos:
BackgroundTaskspara trabajo verdaderamente fire-and-forget, Celery para todo lo que necesite una garantía - Código funcional para ambos, incluida una política de reintentos de Celery y un endpoint de FastAPI que despacha hacia él
Tabla de contenido
- Lo básico
- Cómo funciona realmente BackgroundTasks
- Dónde BackgroundTasks pierde trabajo silenciosamente
- Qué añade Celery
- El marco de decisión
- Patrones híbridos
- Ejemplos de código
- Alternativas más ligeras a Celery
- Errores comunes
- Buenas prácticas de producción
Lo básico
Dos herramientas diferentes que resuelven dos problemas diferentes
BackgroundTasks es una función de Starlette que FastAPI expone directamente: una forma de programar una función para que se ejecute después de que la respuesta HTTP ya se haya enviado, dentro del mismo proceso worker que manejó la solicitud. Celery es una cola de tareas distribuida: un sistema aparte con sus propios procesos worker, un message broker (Redis o RabbitMQ, normalmente) entre tu aplicación y esos workers, y un backend de resultados/estado que sobrevive a que el proceso de la aplicación se reinicie.
La similitud a nivel de superficie — “ejecuta esto después, no bloquees la respuesta” — oculta una diferencia estructural mucho mayor: BackgroundTasks no tiene ninguna capa de persistencia. Si el proceso que programó la tarea muere antes de que la tarea termine, la tarea desaparece. El broker de Celery persiste la tarea en el momento en que se encola, independientemente de si el proceso de tu API sigue siquiera en ejecución cuando un worker la recoge.
Por qué esta distinción suele pasarse por alto
La mayoría de tutoriales presentan BackgroundTasks con ejemplos genuinamente fire-and-forget — logging, calentamiento de caché — donde perder alguna tarea ocasionalmente no tiene consecuencias. Luego los equipos reutilizan el mismo patrón para cosas que no es inocuo perder: enviar un correo de restablecimiento de contraseña, cobrar un pago, generar un documento que un usuario está esperando. La API parece idéntica en ambos casos. La consecuencia del fallo no.
Cómo funciona realmente BackgroundTasks
Un objeto BackgroundTasks se inyecta en una ruta, se registran funciones sobre él y FastAPI las ejecuta después de enviar la respuesta, pero todavía dentro del mismo event loop async, en el mismo proceso worker:
from fastapi import FastAPI, BackgroundTasks
app = FastAPI()
def send_confirmation_email(email: str):
# runs after the response has already gone out
email_client.send(email, "Thanks for signing up")
@app.post("/signup")
async def signup(email: str, background_tasks: BackgroundTasks):
create_user(email)
background_tasks.add_task(send_confirmation_email, email)
return {"status": "created"}
El usuario recibe su respuesta en cuanto create_user termina; no espera a send_confirmation_email en absoluto. Esa es toda la propuesta de valor, y es realmente útil. Pero fíjate en lo que no está ocurriendo: no hay cola, no hay broker, no hay worker separado y no hay nada que haga seguimiento de si send_confirmation_email llegó realmente a completarse. Es una llamada de corrutina programada dentro del mismo ciclo de vida del proceso que la solicitud que la desencadenó, más cercana a asyncio.create_task con una ergonomía algo mejor que a cualquier cosa que se parezca a una job queue.
Si send_confirmation_email es síncrona y bloqueante, también se ejecuta dentro de las mismas limitaciones de threadpool-o-event-loop explicadas en Why Your FastAPI Endpoint Blocks the Event Loop: una tarea lenta en segundo plano aún puede degradar el throughput del resto del proceso si no está escrita para cooperar con el loop. Una función síncrona registrada con add_task se ejecuta en el threadpool de Starlette exactamente igual que una ruta def, mientras que una función async def registrada del mismo modo se ejecuta directamente en el event loop; así que una tarea en segundo plano que bloquee sin hacer await puede atascar requests en curso, no solo otro trabajo en segundo plano, algo fácil de pasar por alto precisamente porque a primera vista la tarea parece desacoplada del ciclo solicitud/respuesta.
También es fácil juzgar mal el manejo de excepciones. Si una tarea registrada lanza una excepción, FastAPI no rompe la respuesta — ya fue enviada — pero por defecto la excepción solo es visible si tu configuración de logging captura específicamente las excepciones no controladas de tareas en segundo plano; un simple try/except alrededor de la propia ruta nunca la verá, porque cuando la tarea se ejecuta, la función de la ruta ya ha retornado.
Dónde BackgroundTasks pierde trabajo silenciosamente
Cada uno de estos casos es un evento normal y esperable en un despliegue de producción, no un caso límite:
Despliegues. Un despliegue rolling envía SIGTERM al proceso antiguo. Si una tarea en segundo plano está a mitad de ejecución (o ni siquiera ha comenzado porque está en cola detrás de otro trabajo en el mismo event loop) cuando expira el periodo de gracia del proceso y este recibe SIGKILL, esa tarea desaparece permanentemente, sin que se registre nada sobre ello.
Reducción por autoscaling. Es el mismo mecanismo que en un despliegue: un orquestador (Kubernetes, ECS) decide finalizar una instancia con poca carga, y cualquier trabajo de BackgroundTasks en curso en esa instancia desaparece con ella.
Crashes del proceso y terminaciones por OOM. Una fuga de memoria no relacionada o una solicitud inesperadamente grande en otra parte del mismo proceso puede hacer que todo el worker muera por OOM, llevándose consigo todas las tareas en segundo plano pendientes, incluso tareas que no tenían nada que ver con lo que causó el crash.
Sin reintento ante fallos. Si send_confirmation_email lanza una excepción — por ejemplo, el proveedor de correo hace timeout — BackgroundTasks no lo reintenta. Por defecto la excepción se traga (solo visible si has conectado logging de excepciones para ello) y la tarea simplemente nunca se completa. No hay dead-letter queue, no hay backoff, no hay segundo intento.
Sin visibilidad entre procesos. Si ejecutas varios workers de Uvicorn/Gunicorn, un trabajo de BackgroundTasks programado en el worker 2 no tiene ninguna relación con el worker 1: no puedes inspeccionarlo, reintentarlo ni cancelarlo desde ningún otro lugar que no sea el proceso exacto que lo programó, y los logs de ese mismo proceso son el único registro de que existió.
Nada de esto hace que BackgroundTasks esté roto. Hace que sea una herramienta para trabajo que realmente puedes permitirte perder de vez en cuando, no una job queue de propósito general con una API más simple.
Qué añade Celery
La arquitectura de Celery responde directamente a cada una de las carencias anteriores insertando una capa duradera e independiente entre “la solicitud que desencadenó el trabajo” y “el proceso que hace el trabajo”:
- Un message broker (Redis/RabbitMQ) que persiste la tarea. En el momento en que se llama a
.delay(), la tarea se serializa y se escribe en el broker, independientemente del ciclo de vida del proceso de FastAPI. Si el proceso de la API muere un milisegundo después, la tarea ya está segura en el broker. - Procesos worker independientes. Los workers de Celery son procesos separados (a menudo contenedores o incluso hosts separados) que extraen tareas del broker. Un despliegue de tu API no los afecta; un despliegue de tus workers no afecta a tu API.
- Reintentos integrados con backoff. Una tarea puede declarar
max_retriesy una política de backoff, de modo que un fallo transitorio — una API downstream brevemente no disponible — se reintenta automáticamente en lugar de morir en silencio. - Un backend de resultados. El estado de la tarea (pending, success, failure y el valor de retorno) se puede consultar después, desde cualquier proceso, lo que convierte “¿esto realmente se completó?” en una pregunta respondible en lugar de un ejercicio de buscar en logs.
- Programación y rate limiting. Las tareas periódicas (
celery beat), los límites de tasa por tipo de tarea y las colas con prioridad son funcionalidades de primer nivel, no algo que tendrías que construir manualmente sobreBackgroundTasks.
La contrapartida es infraestructura real: un broker que ejecutar y monitorizar, procesos worker que desplegar y escalar de forma independiente, y un modelo mental verdaderamente más complejo; la serialización de tareas, la idempotencia y el manejo de conexiones al broker ahora son tu problema de una manera que no lo eran con BackgroundTasks.
El marco de decisión
Tres preguntas resuelven casi todos los casos:
1. ¿Puedes permitirte perder esta tarea por completo, en silencio y sin reintento?
Si la respuesta es sí — un ping de métricas, un calentamiento de caché de best-effort, un evento de analytics — BackgroundTasks está bien y añadir Celery sería puro overhead. Si la respuesta es no — cualquier cosa que involucre dinero, cualquier cosa cuyo resultado un usuario esté esperando explícitamente, cualquier cosa con un requisito de compliance o auditoría — necesitas la durabilidad de Celery.
2. ¿La tarea necesita sobrevivir a un despliegue o a un evento de scale-down?
El trabajo en BackgroundTasks solo sobrevive si el proceso que lo programó permanece vivo el tiempo suficiente para terminarlo. Si tu cadencia de despliegue es frecuente (varias veces al día, algo común con CI/CD) y las tareas pueden durar más que tu ventana de apagado elegante, BackgroundTasks perderá trabajo con una cadencia predecible, no rara.
3. ¿La tarea necesita una política de reintentos, programación o visibilidad entre procesos?
Si un fallo necesita reintento automático, si la tarea necesita ejecutarse según una programación independiente de cualquier solicitud, o si necesitas comprobar el estado de la tarea desde un proceso diferente (un panel de administración, una API separada), eso es exactamente para lo que existen el broker y el backend de resultados de Celery; BackgroundTasks no tiene ningún mecanismo para ninguna de las tres cosas.
| Señal | BackgroundTasks | Celery |
|---|---|---|
| Duración de la tarea | Segundos, no minutos | Segundos a horas |
| Tolerancia a pérdida | Totalmente tolerante | Necesita durabilidad |
| Reintento ante fallo | Ninguno integrado | Integrado, con backoff |
| Sobrevive al reinicio del proceso | No | Sí |
| Visibilidad entre procesos | No | Sí (backend de resultados) |
| Infraestructura requerida | Ninguna | Broker + procesos worker |
| Programación (tipo cron) | No | Sí (celery beat) |
Patrones híbridos
La mayoría de los codebases maduros de FastAPI usan ambos, deliberadamente, en lugar de elegir una sola herramienta para toda la aplicación. Una separación común y eficaz: usar BackgroundTasks para trabajo que sea barato de rehacer o realmente desechable, y despachar a Celery cualquier cosa con requisitos de durabilidad o reintento, a menudo desde el mismo endpoint.
@app.post("/orders")
async def create_order(payload: OrderCreate, background_tasks: BackgroundTasks):
order = await create_order_record(payload)
# Disposable — fine to lose occasionally, no retry needed
background_tasks.add_task(log_analytics_event, "order_created", order.id)
# Durable — must survive a deploy, needs a retry policy
send_order_confirmation.delay(order.id)
return order
La regla práctica es: si te molestaría descubrir en un post-mortem que una tarea silenciosamente nunca se ejecutó, pertenece a Celery. Si te daría igual, BackgroundTasks es la cantidad adecuada de tooling.
Ejemplos de código
Una configuración mínima de Celery que refleja el marco anterior: reintentos con backoff exponencial para una tarea realmente importante:
# celery_app.py
from celery import Celery
celery_app = Celery(
"worker",
broker="redis://localhost:6379/0",
backend="redis://localhost:6379/1",
)
@celery_app.task(
bind=True,
max_retries=5,
default_retry_delay=10, # seconds, before backoff kicks in
)
def send_order_confirmation(self, order_id: int):
try:
order = fetch_order(order_id)
email_client.send(order.customer_email, render_receipt(order))
except EmailProviderTimeout as exc:
# exponential-ish backoff: 10s, 20s, 40s...
raise self.retry(exc=exc, countdown=10 * (2 ** self.request.retries))
# app/orders/router.py
from app.workers.celery_app import send_order_confirmation
@router.post("/")
async def create_order_endpoint(payload: OrderCreate):
order = await create_order(payload)
send_order_confirmation.delay(order.id)
return order
.delay(order.id) retorna inmediatamente; solo encola la tarea en el broker, por lo que el tiempo de respuesta del endpoint no se ve afectado, mientras que la garantía real de finalización ahora vive en el broker y la política de reintentos de Celery, no en el proceso de FastAPI. Este tipo de límite entre módulos — un paquete workers/ al que el router despacha sin conocer ninguno de sus detalles internos — encaja de forma natural en la estructura orientada al dominio descrita en FastAPI Project Structure That Survives Growth.
Alternativas más ligeras a Celery
Celery no es la única opción duradera y, para un codebase de FastAPI orientado primero a async, a menudo no es el encaje más natural: el modelo de workers de Celery es anterior a asyncio y trata las tareas async como una característica añadida, no como un diseño de primera clase.
arq es una cola basada en Redis construida específicamente para asyncio desde cero. Las funciones de tarea son async def, los workers ejecutan su propio event loop y no hay que pensar en puentes de síncrono a async:
# worker.py
async def send_order_confirmation(ctx, order_id: int):
order = await fetch_order(order_id)
await email_client.send_async(order.customer_email, render_receipt(order))
class WorkerSettings:
functions = [send_order_confirmation]
redis_settings = RedisSettings(host="localhost")
# dispatching from FastAPI
redis = await create_pool(RedisSettings(host="localhost"))
await redis.enqueue_job("send_order_confirmation", order.id)
Dramatiq y RQ ocupan un espacio similar: configuración más simple que Celery, Redis como broker y una superficie de funcionalidades más pequeña (por ejemplo, sin equivalente a celery beat para programación en el core de RQ) a cambio de menos overhead operativo.
El marco de decisión anterior sigue aplicando independientemente de la cola duradera que elijas: la pregunta nunca fue específicamente “Celery o nada”, sino “¿esta tarea necesita realmente durabilidad respaldada por broker?”. Celery sigue siendo la opción por defecto adecuada cuando necesitas su ecosistema — herramientas maduras de programación, routing y monitorización que las alternativas más nuevas aún están alcanzando — pero para un servicio async de FastAPI greenfield, vale la pena evaluar arq primero precisamente porque evita mezclar un task runner orientado a sync en un codebase que por lo demás es orientado a async.
Errores comunes
Error: usar BackgroundTasks para cualquier cosa relacionada con pagos o compliance. Una confirmación de cobro o una entrada de registro de auditoría perdida silenciosamente es un problema de negocio, no una incomodidad técnica. Solución: cualquier cosa con requisitos legales, financieros o de auditoría pasa por Celery, sin excepciones.
Error: asumir que las tareas de Celery son automáticamente idempotentes. Una tarea reintentada vuelve a ejecutar la función entera; si send_order_confirmation no es seguro para ejecutarse dos veces, un reintento después de un fallo parcial puede enviar un duplicado. Solución: diseña las tareas para que sean idempotentes (comprobar antes de actuar, o usar claves de idempotencia) siempre que los reintentos estén habilitados.
Error: poner trabajo realmente de larga duración en BackgroundTasks. Una tarea que se ejecuta durante minutos dentro del mismo proceso que tus workers de API compite por los mismos recursos que el manejo de solicitudes. Solución: cualquier cosa que dure más de unos pocos segundos pertenece a un pool de workers separado, que es exactamente lo que proporciona Celery.
Error: levantar Celery para una sola tarea de poca importancia. El coste operativo de un broker y una flota de workers no se justifica por una sola llamada de analytics best-effort. Solución: usa por defecto BackgroundTasks hasta que aparezca un requisito concreto de durabilidad o reintento.
Error: no monitorizar la salud de los workers de Celery. Una flota de workers caída o saturada acumula silenciosamente una cola cada vez mayor sin síntomas visibles para el usuario hasta que el problema es grave. Solución: monitoriza la profundidad de la cola y la disponibilidad de los workers (Flower, o la integración de Celery de tu APM) como una métrica de producción de primera clase.
Buenas prácticas de producción
- Usa
BackgroundTaskspor defecto y evoluciona deliberadamente. No levantes infraestructura de Celery antes de que una tarea concreta necesite realmente las garantías que proporciona. - Haz que las tareas de Celery sean idempotentes por defecto. Asume que cada tarea será reintentada al menos una vez, porque tarde o temprano ocurrirá.
- Establece límites de reintento y backoff explícitos; nunca reintentos ilimitados. Una tarea que reintenta para siempre contra una dependencia rota permanentemente simplemente se convierte en otro tipo de incidente.
- Registra por separado el despacho y la finalización de la tarea. Saber que una tarea fue programada no es lo mismo que saber que tuvo éxito; instrumenta ambas cosas.
- Trata la profundidad de la cola como una métrica con alerta. Un backlog creciente y sin procesar en Celery es una de las señales más tempranas de una caída en un sistema downstream.
Cierre
BackgroundTasks y Celery no son soluciones competidoras para el mismo problema: son la herramienta adecuada para dos requisitos de durabilidad genuinamente distintos, y el error es elegir basándose en el esfuerzo de configuración en lugar de en lo que ocurre cuando un despliegue cae a mitad de la tarea. Si perder el trabajo silenciosamente sería un problema real, esa es tu respuesta sin importar cuánto más simple parezca hoy BackgroundTasks. La mayoría de aplicaciones FastAPI en producción terminan usando ambos, a propósito, después de haberse quemado exactamente con una tarea perdida de más.
¿De verdad sabes qué les ocurre a tus tareas en segundo plano en vuelo la próxima vez que despliegues?
