🎓 What You Will Learn
- WebSockets: Real-time bidirectional communication
- Progress Tracking: Update clients on task progress
- Celery + Redis: Scalable long-running task execution
- Retry Logic: Handle failures with exponential backoff
- Timeout Handling: Gracefully manage task timeouts
- Monitoring: Track task health and performance
1Challenges of Long-Running Tasks
Long-running tasks create UX problems. Users do not know if anything is happening. HTTP connections time out. And there is no easy way to see progress.
Solution: Track progress in real-time and stream updates to clients using WebSockets.
2WebSocket Fundamentals
A WebSocket keeps one connection open. The server and the client can send messages at any time. This is perfect for sending progress updates. The endpoint below reads progress from TaskService, which we will build in the next section.
import asyncio
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from app.services.task_service import TaskService
app = FastAPI()
@app.websocket("/ws/task/{task_id}")
async def websocket_endpoint(websocket: WebSocket, task_id: str):
await websocket.accept()
try:
while True:
# Get task progress from Redis
data = TaskService.get_progress(task_id)
progress = data["progress"] if data else 0
status = data["status"] if data else "pending"
# Send to client
await websocket.send_json({
"task_id": task_id,
"progress": progress,
"status": status,
})
if status in ("completed", "failed"):
break # stop streaming once the task is done
await asyncio.sleep(1) # update every second
except WebSocketDisconnect:
pass # client closed the connectionTip: the Redis client here is synchronous. Calls are fast, so this is fine for a demo. For a fully async app, use redis.asyncio instead.
⏳ Real-Time Progress Bar Demo
3Storing Progress in Redis
Use Redis to store task progress so multiple clients can track the same task.
import json
from datetime import UTC, datetime
import redis
redis_client = redis.Redis(host="localhost", port=6379, decode_responses=True)
class TaskService:
@staticmethod
def update_progress(task_id: str, progress: int, status: str = "running"):
data = {
"progress": progress,
"status": status,
"updated_at": datetime.now(UTC).isoformat(),
}
# Keys expire after 1 day so finished tasks do not pile up
redis_client.set(f"task:{task_id}", json.dumps(data), ex=86400)
@staticmethod
def get_progress(task_id: str):
data = redis_client.get(f"task:{task_id}")
return json.loads(data) if data else None4Celery Task Progress Reporting
Report progress from inside the Celery task. We use self.update_state() for Celery's own state, and we also write to Redis with TaskService so the WebSocket endpoint can read it.
import csv
from celery import Celery
from app.services.task_service import TaskService
celery_app = Celery(
"app",
broker="redis://localhost:6379/0",
backend="redis://localhost:6379/0",
)
@celery_app.task(bind=True)
def process_large_file(self, file_path: str):
with open(file_path, newline="") as f:
rows = list(csv.reader(f))
total_items = len(rows)
for idx, row in enumerate(rows):
handle_row(row) # replace with your own processing logic
# Update progress in Celery and in Redis
progress = int((idx + 1) / total_items * 100)
self.update_state(
state="PROGRESS",
meta={"current": idx + 1, "total": total_items, "progress": progress},
)
TaskService.update_progress(self.request.id, progress)
TaskService.update_progress(self.request.id, 100, status="completed")
return {"status": "Complete", "items_processed": total_items}5Implementing Retry Logic
Retry failed tasks with exponential backoff. Backoff means each retry waits longer (1s, 2s, 4s, ...). This helps when a failure is temporary, like a slow network.
@celery_app.task(
bind=True,
autoretry_for=(Exception,),
retry_kwargs={"max_retries": 3},
retry_backoff=True, # wait 1s, 2s, 4s, ... between retries
retry_backoff_max=600, # never wait more than 10 minutes
retry_jitter=True, # add randomness so retries do not stack up
)
def resilient_task(self, data: dict):
# Any exception raised here triggers an automatic retry
return process_data(data) # replace with your own logic6Handling Task Timeouts
Set timeouts to prevent tasks from running indefinitely.
from celery.exceptions import SoftTimeLimitExceeded
@celery_app.task(
time_limit=3600, # Hard limit: 1 hour, worker is killed
soft_time_limit=3300, # Soft limit: 55 minutes, exception is raised
)
def time_limited_task(data: dict):
try:
return long_running_operation(data) # replace with your own logic
except SoftTimeLimitExceeded:
# You get 5 minutes to clean up before the hard limit
save_partial_results() # replace with your own cleanup logic
raise7Monitoring with Flower
Use Flower to monitor Celery tasks in real-time.
Setup: Install it with pip install flower, run celery -A app.celery_app flower, then visit http://localhost:5555
8HTTP Polling Alternative to WebSockets
For simpler cases, clients can poll for status via HTTP instead of WebSockets.
from fastapi import APIRouter, HTTPException
from app.services.task_service import TaskService
router = APIRouter()
@router.get("/task/{task_id}/status")
def get_task_status(task_id: str):
progress = TaskService.get_progress(task_id)
if progress is None:
raise HTTPException(status_code=404, detail="Task not found")
return {
"task_id": task_id,
"progress": progress.get("progress", 0),
"status": progress.get("status", "unknown"),
}9Job Queue Architecture
Implement a reliable job queue with retries and persistence.
from datetime import UTC, datetime
from sqlmodel import Field, SQLModel
def utc_now() -> datetime:
return datetime.now(UTC)
class Job(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
task_id: str
status: str = "pending" # pending, running, completed, failed
progress: int = 0
error_message: str | None = None
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
retry_count: int = 010Dead Letter Queue for Failed Tasks
Move tasks to a dead letter queue after all retries are exhausted.
import json
from app.services.task_service import redis_client
def send_to_dead_letter_queue(data: dict, error: str) -> None:
redis_client.lpush("dead_letter_queue", json.dumps({"data": data, "error": error}))
@celery_app.task(bind=True, max_retries=3)
def critical_task(self, data: dict):
try:
return process(data) # replace with your own logic
except Exception as exc:
if self.request.retries < self.max_retries:
raise self.retry(exc=exc, countdown=60)
# All retries used up: keep the payload for manual review
send_to_dead_letter_queue(data, str(exc))
raise11Load Balancing Across Workers
Distribute tasks fairly across multiple workers using prefetch settings.
# Each worker takes only 1 task at a time (fair for long tasks)
celery_app.conf.worker_prefetch_multiplier = 1
# Restart a worker process after 1000 tasks (avoids memory leaks)
celery_app.conf.worker_max_tasks_per_child = 100012Testing Long-Running Tasks
Test tasks synchronously during development and testing.
def test_long_running_task():
from app.celery_app import celery_app
celery_app.conf.task_always_eager = True
result = process_large_file.delay("test_file.csv")
assert result.successful()
assert result.result["items_processed"] > 013Common Pitfalls
- No timeout: Tasks can hang indefinitely
- No retry: Transient failures cause permanent failure
- No monitoring: Can't debug stuck tasks
- No idempotency: Retries cause side effects
- Blocking WebSockets: Hold connections open unnecessarily
Performance: WebSocket connections are stateful and consume server resources. Use HTTP polling for non-critical updates to reduce load.
14Advanced Patterns
- Task Groups: Run multiple tasks in parallel
- Pipelines: Chain task results together
- Chords: Run tasks then aggregate results
- Custom Routing: Send tasks to specific workers
15Resources & What's Next
You now understand how to handle long-running tasks with real-time progress tracking. Use these patterns for file processing, data imports, report generation, and batch operations.
Next Topics: Database migrations, security authentication, and deployment strategies.
Congratulations! Your application can now handle complex, long-running operations while keeping users informed. Build with confidence! 🚀