Async Task Queues
INFO 153B/253B Backend Web Architecture
Week 8
Spring 2026 | UC Berkeley School of Information
Welcome to Week 8! Today we cover one of the most important patterns in backend development: async task queues. This is how YouTube processes video uploads, how Amazon sends order confirmations, and how any scalable system handles slow operations. Assignment 2 starts this week and requires implementing this pattern.
Today's Agenda
Part 1: Prep Work Recap - Why Background Processing?
Part 2: Task Queue Architecture
Part 3: Redis & rq Deep Dive
Part 4: Worker Processes
Part 5: Production Patterns
Demo: Building a Task Queue with rq
In-Class Exploration: Notification Service
The lecture flows from concept to implementation. Part 1 connects to the videos they watched. Parts 2-4 go deeper into the mechanics. Part 5 covers production concerns. Then we demo and they build. Emphasize that Assignment 2 uses these concepts - they're building toward it today.
Part 1
Prep Work Recap: Why Background Processing?
Start by connecting to what they learned in the O'Reilly videos. The videos covered rq specifically, so we're building on that foundation.
The Problem: Slow Operations
Some operations take seconds or minutes
Sending emails via external API (500ms-3s)
Processing uploaded images (2-10s)
Generating PDF reports (5-30s)
Video transcoding (minutes to hours)
If Flask waits, the user waits
If Flask waits, other users can't connect
This doesn't scale
Real numbers from industry: Mailgun/SendGrid API calls take 500ms-3s. Image processing with Pillow or ImageMagick takes seconds. PDF generation with WeasyPrint can take 10-30s for complex documents. Video transcoding is measured in minutes. A Flask process blocked on these operations can't serve other requests.
The Solution: Process Later
SYNCHRONOUS (Bad for slow operations)
User → Flask → [Wait 10 seconds...] → Response
ASYNCHRONOUS (Task Queue)
User → Flask → "Got it!" (instant)
↓
Redis Queue
↓
Worker → [Process 10 seconds] → Done
Draw this on the board if helpful. The key insight: the user doesn't need to wait for the work to complete. They just need acknowledgment that the work will happen. YouTube doesn't wait for your video to process before saying "Upload complete!"
What is a Queue?
A data structure for ordered items
FIFO: First In, First Out
Add items to the back (enqueue)
Remove items from the front (dequeue)
Like a line at a coffee shop
The first person in line gets served first
Task Queue: A queue where each item is a function to execute along with its arguments.
The coffee shop analogy works well. When you order, you get a number (job ID). You step aside and wait. The barista (worker) processes orders in order. You check back when your number is called. This is exactly how task queues work.
Key Vocabulary
Term
Definition
Producer
The app that creates tasks (Flask API)
Broker
The system that holds the queue (Redis)
Consumer/Worker
The process that executes tasks (rq worker)
Job
A single task to execute (function + args)
Enqueue
Add a job to the queue
These terms come from message queue theory. They'll see "broker" in connection strings, "worker" in logs, "job" in the rq API. Understanding the vocabulary helps when reading documentation and debugging.
From the Prep Videos
We use rq (Redis Queue)
Simple Python library for task queues
Uses Redis as the broker
Alternative to Celery (more complex)
Tasks go in a separate tasks.py file
Worker runs as a separate process
Uses the decorator pattern - just like Flask!
Reference the specific videos they watched. The instructor in the videos recommended rq over Celery for simplicity. Celery is more popular in large companies but rq does everything we need. If students already know Celery, the concepts transfer.
The Decorator Pattern: A Familiar Friend
# Flask: Execute NOW when HTTP request arrives
@app.route('/notifications')
def handle_notification():
return {"status": "ok"}
# rq: Execute LATER when worker picks it up
@job('notifications', connection=redis)
def send_notification(email, message):
return {"status": "sent"}
Same pattern, different timing
Flask decorator → function becomes HTTP endpoint
rq decorator → function becomes background task
The function is the building block
This is a key insight! Students already understand Flask decorators from Week 3. The @job decorator works the same way - it transforms a regular function. The difference is WHEN and WHERE it executes. This connects to system design building blocks - functions are your units of work, decorators determine execution context.
Building Blocks: Now vs Later
Decorator
Transforms Into
Executes
@app.route()
HTTP Endpoint
Now - when request arrives
@job()
Background Task
Later - when worker is ready
The function is always the building block. Decorators determine the execution context: synchronous (service) or deferred (worker).
This mental model is powerful. When designing systems, think about your building blocks (functions) and how they should execute. Real-time user interaction? Flask endpoint. Can happen later? Background task. This is fundamental to system design.
Part 2
Task Queue Architecture
Now we go deeper into how the pieces fit together. This is the "architecture" they'll implement in the in-class exercise and Assignment 2.
Three Components
1. Producer (Flask)
Receives user requests
Validates input
Enqueues tasks
Returns immediately
2. Broker (Redis)
Stores the queue
Holds job data
In-memory = fast
Persists jobs if configured
3. Consumer (Worker)
Polls Redis for new jobs
Executes task functions
Stores results back in Redis
Think of it like a restaurant: the waiter (Flask) takes orders and puts them on the ticket rail (Redis). The cook (worker) takes tickets one at a time and cooks. The waiter doesn't wait for cooking - they take more orders. This separation is what makes it scalable.
HTTP 202 Accepted
HTTP has a status code for this pattern!
202 Accepted means:
"I received your request"
"Processing has been started"
"But it's not complete yet"
Different from 200 OK (completed now)
Different from 201 Created (resource exists now)
@app.post('/videos/<int:id>/process')
def process_video(id):
job = transcode_video.delay(id) # Queue it, don't wait!
return {"job_id": job.id}, 202 # Accepted, not done!
HTTP 202 is underused but perfect for async operations. It tells clients "I got it, check back later." This is different from 200 which implies the operation completed. 201 means a resource was created - but with async, nothing exists yet, just a promise.
The Job ID Pattern
When you enqueue a task, you get a job ID
Return this ID to the client
Client can check status: GET /jobs/{id}
This is called polling
// POST /videos/42/process
{
"message": "Processing started",
"job_id": "a1b2c3d4-e5f6-7890",
"status_url": "/jobs/a1b2c3d4-e5f6-7890"
}
// GET /jobs/a1b2c3d4-e5f6-7890
{
"job_id": "a1b2c3d4-e5f6-7890",
"status": "finished",
"result": {"formats": ["720p", "1080p"]}
}
This is exactly how YouTube, Dropbox, and other services work. Upload something, get an ID, refresh to check progress. Alternative patterns include webhooks (server calls you when done) or WebSockets (real-time updates), but polling is simplest and works everywhere.
Job Lifecycle
Status
Meaning
queued
Job is waiting in the queue
started
Worker picked up the job, executing now
finished
Job completed successfully, result available
failed
Job threw an exception
deferred
Job scheduled for later
rq provides these statuses automatically. Clients poll and watch for "finished" or "failed". For long jobs, you might want to add progress tracking - that's an extension, not built into rq. The "deferred" status is for scheduled jobs (run at 3am, etc).
Scaling with Workers
One worker = one job at a time
Need more throughput? Add more workers!
Workers can run on different machines
Redis handles coordination automatically
Flask → Redis Queue ← Worker 1
← Worker 2
← Worker 3
This is horizontal scaling. In Docker/Kubernetes, you just increase the replica count for the worker service. Each worker polls Redis, grabs a job, processes it, repeats. Redis ensures each job goes to exactly one worker (no duplicates).
Part 3
Redis & rq Deep Dive
Now we get into the code. Students will implement this in the in-class exercise, so show real examples they can reference.
What is Redis?
In-memory key-value data store
Extremely fast (all data in RAM)
Used for: caching, sessions, queues, pub/sub
We use it as our message broker
# Start Redis with Docker
docker run -d -p 6379:6379 --name redis redis:alpine
# Test connection
docker exec -it redis redis-cli ping
# PONG
Redis is incredibly versatile. Today we use it as a queue, but it's also great for caching (store computed results), sessions (store login state), rate limiting (count requests), and real-time features. Port 6379 is the Redis default.
Redis in Docker Compose
# docker-compose.yml
version: '3.8'
services:
redis:
image: redis:alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 3
redis:alpine - Small, fast Redis image
Healthcheck ensures Redis is ready before other services start
Other services connect via redis://redis:6379
The healthcheck is important - Flask and the worker shouldn't start until Redis is accepting connections. The hostname "redis" works because Docker Compose creates a network where services can find each other by name.
Installing rq
# requirements.txt
flask==3.0.0
redis==5.0.1
rq==1.15.1
redis - Python Redis client
rq - Redis Queue library
rq depends on redis, but be explicit
rq vs Celery: rq is simpler and perfect for learning. Celery is more powerful but has a steeper learning curve. Learn rq first, concepts transfer to Celery.
Celery is the industry standard at large companies, but it's complex - multiple brokers, complex configuration, steep learning curve. rq does 90% of what most apps need with 10% of the complexity. The patterns are the same.
Connecting to Redis
# app.py
from redis import Redis
from rq import Queue
import os
# Connect to Redis
redis_url = os.getenv('REDIS_URL', 'redis://localhost:6379/0')
redis_conn = Redis.from_url(redis_url)
# Create a queue
queue = Queue('notifications', connection=redis_conn)
Redis.from_url() parses the connection string
Queue name ('notifications') groups related tasks
You can have multiple queues for different priorities
The environment variable pattern lets us use different Redis servers in dev vs production. In Docker Compose, REDIS_URL points to the redis service. Locally, it defaults to localhost. Queue names let you separate concerns - "emails", "reports", "uploads".
Defining Tasks with @job
# tasks.py
from redis import Redis
from rq.decorators import job
import time
import os
redis_conn = Redis.from_url(os.getenv('REDIS_URL', 'redis://localhost:6379/0'))
@job('notifications', connection=redis_conn)
def send_notification(user_email, message):
"""
Send a notification email.
This runs in the WORKER, not in Flask!
"""
print(f"Sending to {user_email}...")
time.sleep(3) # Simulate slow email API
print(f"Sent: {message}")
return {"email": user_email, "status": "sent"}
@job('queue_name') - just like @app.route('/path')
Decorator specifies which queue this task belongs to
Function can now be called with .delay()
Point out the parallel to Flask! @app.route says "this function handles HTTP requests to /path". @job says "this function handles jobs from the 'notifications' queue". Same pattern, different execution context. The function is still just a function - the decorator adds the queue behavior.
Enqueueing Tasks with .delay()
# app.py
from tasks import send_notification
@app.post('/notifications')
def create_notification():
data = request.get_json()
# .delay() queues the task - returns IMMEDIATELY
job = send_notification.delay(
data['email'], # First argument
data['message'] # Second argument
)
return {
"job_id": job.id,
"status": "queued"
}, 202
.delay(*args) - queues the task, returns Job object
Just like calling the function, but it runs later
Returns immediately - does NOT wait for completion
The .delay() method is added by the @job decorator. It's syntactic sugar that makes the code read naturally. Compare: send_notification(email, msg) runs NOW, send_notification.delay(email, msg) runs LATER. Same function, different execution timing. This mirrors how @app.route transforms a function into something that responds to HTTP.
Checking Job Status
# app.py
from rq.job import Job
@app.get('/jobs/<job_id>')
def get_job_status(job_id):
try:
job = Job.fetch(job_id, connection=redis_conn)
except Exception:
return {"error": "Job not found"}, 404
response = {
"job_id": job_id,
"status": job.get_status()
}
if job.is_finished:
response["result"] = job.result
elif job.is_failed:
response["error"] = str(job.exc_info)
return response
Job.fetch retrieves job info from Redis by ID. The job object has methods like is_finished, is_failed, get_status(). The result is whatever the task function returned. If the job failed, exc_info has the exception details. This is how clients poll for completion.
Part 4
Worker Processes
The worker is the other half of the equation. Flask enqueues, the worker executes. Understanding this separation is crucial for debugging.
Why a Separate Process?
Flask handles HTTP requests
Worker handles background tasks
They run independently
Benefits:
Flask stays responsive (never blocked)
Workers can run on different machines
Scale workers independently of Flask
Crash isolation (worker crash doesn't kill API)
This is process isolation, a fundamental distributed systems concept. If a video transcoding task crashes, it shouldn't bring down your API. If you need to restart the worker, users can still submit new requests. The queue buffers between them.
Running the rq Worker
# Basic command
rq worker --url redis://localhost:6379 queue_name
# With Docker Compose
services:
worker:
build: .
environment:
- REDIS_URL=redis://redis:6379/0
command: rq worker --url redis://redis:6379/0 notifications
depends_on:
- redis
--url specifies the Redis connection
Last argument is the queue name to watch
Worker polls Redis continuously for new jobs
The worker is a long-running process that loops forever: check queue, process job, repeat. The "notifications" argument must match the queue name in Flask. You can watch multiple queues: `rq worker emails notifications reports`.
Worker in Docker Compose
# docker-compose.yml
services:
flask:
build: .
ports:
- "5000:5000"
environment:
- REDIS_URL=redis://redis:6379/0
command: python app.py
worker:
build: .
environment:
- REDIS_URL=redis://redis:6379/0
command: rq worker --url redis://redis:6379/0 notifications
depends_on:
redis:
condition: service_healthy
Same Docker image as Flask, different command
No ports needed - worker doesn't serve HTTP
depends_on ensures Redis starts first
This is a common pattern: same codebase, different entrypoint. The worker container has the same code but runs `rq worker` instead of `python app.py`. No ports because workers don't accept HTTP - they just poll Redis.
Critical: tasks.py Isolation
# tasks.py - runs in WORKER, not Flask!
import os
from dotenv import load_dotenv
from redis import Redis
from rq.decorators import job
# Worker needs its own environment loading
load_dotenv()
redis_conn = Redis.from_url(os.getenv('REDIS_URL'))
@job('emails', connection=redis_conn)
def send_email(to, subject, body):
api_key = os.getenv('MAILGUN_API_KEY')
# ... send email using api_key
Worker is a separate Python process
It imports tasks.py, NOT app.py
Must load environment variables separately
Cannot access Flask's app context
This trips up many students. The worker runs tasks.py directly - Flask never runs. So if you do load_dotenv() in app.py, that doesn't help the worker. Environment variables, database connections, API keys - all must be available in tasks.py context. The @job decorator needs the Redis connection defined in tasks.py.
What Can Tasks Access?
Can Access
Cannot Access
Environment variables
Flask app object
Database connections (new ones)
Flask request object
External APIs
Flask session
File system
Flask g object
Arguments passed via .delay()
Anything from the HTTP request
Pass everything the task needs via .delay() arguments. Don't pass Flask objects - pass the data they contain. For example, pass user_id and query the database in the task, don't try to pass the user object.
Passing Arguments
# Good - pass simple values
job = send_email.delay(
user.email, # string
user.id, # integer
message_text # string
)
# Bad - pass complex objects
job = send_email.delay(
user, # SQLAlchemy object - problematic!
request # Flask request - won't work!
)
Pass strings, numbers, lists, dicts
Avoid passing ORM objects or Flask objects
Arguments get serialized (pickled) into Redis
Python's pickle module serializes arguments. Simple types work fine. Complex objects like SQLAlchemy models can have issues - they reference database sessions that don't exist in the worker. Best practice: pass IDs, re-query in the task.
Part 5
Production Patterns
These are patterns you'll use in Assignment 2 and in real jobs. Task queues are fundamental infrastructure at any scale.
Common Use Cases
Email/SMS notifications - external API latency
Image/video processing - CPU intensive
PDF generation - memory intensive
Data imports - CSV/Excel processing
Report generation - complex queries
Webhooks - calling external services
Cache warming - pre-computing expensive data
Every company uses task queues. Stripe queues webhook delivery. YouTube queues video processing. Amazon queues order confirmation emails. If something takes more than ~500ms and can happen later, queue it.
Error Handling
# tasks.py
def send_notification(email, message):
try:
# Attempt to send
response = mailgun.send(email, message)
return {"status": "sent", "id": response.id}
except Exception as e:
# Log the error
print(f"Failed to send to {email}: {e}")
# Re-raise so rq marks job as failed
raise
Exceptions in tasks mark the job as failed
Failed jobs can be retried or inspected
rq stores the exception info in Redis
Don't swallow exceptions in tasks - let them propagate so rq can track them. Failed jobs stay in Redis and can be retried. In production, you'd have monitoring on failed job counts. The exc_info is available via job.exc_info.
Retries
# Configure retries in the decorator
from rq.decorators import job
from rq import Retry
@job('notifications', connection=redis_conn,
retry=Retry(max=3, interval=60)) # 3 retries, 60s apart
def send_notification(email, message):
# If this fails, rq retries up to 3 times
response = mailgun.send(email, message)
return {"status": "sent"}
Network errors and timeouts are common
Automatic retries handle transient failures
interval adds delay between retries
After max retries, job stays in failed queue
External APIs fail. Networks blip. Databases have momentary issues. Retries handle transient failures automatically. The interval prevents hammering a failing service. After max retries, the job fails permanently and needs human attention. With decorators, retry config is part of the task definition.
Multiple Queues
# tasks.py - queue name in decorator
@job('urgent', connection=redis_conn)
def send_password_reset(email):
# Password resets go to "urgent" queue
...
@job('low', connection=redis_conn)
def send_newsletter(subscriber_ids):
# Newsletters go to "low" queue
...
# In app.py - just call .delay()
send_password_reset.delay(user.email) # → urgent queue
send_newsletter.delay(subscriber_ids) # → low queue
# Worker processes queues in order listed
rq worker urgent default low
Queue priority is simple: workers process queues in the order listed. List "urgent" first and those jobs get processed before "default" or "low". With decorators, the queue name is part of the task definition - the caller just uses .delay() without worrying about queues.
Scaling Workers
# docker-compose.yml
services:
worker:
build: .
command: rq worker --url redis://redis:6379/0 tasks
deploy:
replicas: 4 # Run 4 worker containers!
More workers = more parallel processing
Each worker handles one job at a time
Redis coordinates - no duplicate processing
Scale based on queue depth and latency requirements
This is horizontal scaling in action. During Black Friday, an e-commerce site might scale from 2 workers to 20 to handle order confirmations. Kubernetes/Docker Swarm make this easy - just change the replica count. No code changes needed.
Industry Alternative: Celery
More popular in large enterprises
More features: scheduling, workflows, monitoring
More complex configuration
Same core concepts as rq!
Learning Transfer: Master rq concepts first. When you need Celery features, the patterns are the same - just more configuration.
Students might see job postings requiring Celery. Reassure them: the concepts are identical. Producer, broker, consumer, jobs, queues - all the same. Celery adds features like periodic tasks (cron), task chaining, canvas workflows. But the fundamentals we learned today apply directly.
What We Learned
Task queues separate fast responses from slow processing
Producer → Broker → Consumer architecture
Redis as the message broker
rq for simple Python task queues
Workers run as separate processes
HTTP 202 for accepted-but-not-complete
Job IDs let clients check status
Review these points quickly. They should be able to explain each one. This pattern is fundamental to any backend system that does more than trivial operations.
Assignment 2 Preview
Build a production Task Manager API
Uses everything we've learned:
Flask REST API (Week 3)
Docker containers (Week 5)
Data validation (Week 6)
SQLAlchemy database (Week 7)
Background tasks (Today!)
Released today, due Week 11
Assignment 2 is their capstone for the technical portion. Everything comes together. The task queue is used for sending notification emails when tasks are completed. Start today so they have time - it's more complex than Assignment 1.
Next Week
Week 9: Spring Break - No Class
Week 10: System Design Theory
Scalability fundamentals
Reliability and fault tolerance
CAP theorem
Work on Assignment 2!
Remind them that Assignment 2 is due Week 11, so they have spring break plus two more weeks. Encourage starting this week while the concepts are fresh. Week 10 shifts to theory - no more coding lectures until the end.
Live Demo
Building a Task Queue with rq
Time for the live demo! Make sure docker-compose is running before starting. Have the DEMO-GUIDE.md open for reference but don't show it to students.
What We'll Build
Flask API that queues video processing jobs
Redis as the message broker
rq worker processing jobs in background
Status endpoint to check job progress
Watch for:
POST returning instantly (not waiting 10 seconds)
Worker logs showing processing
Status changing from "queued" to "finished"
Start Docker Compose before the demo. Show three terminals: one for Flask, one for worker logs, one for curl. The "magic moment" is when POST returns instantly while the worker shows processing. Point this out explicitly.
In-Class Exploration
Notification Service
Transition to hands-on work. They have 45 minutes. The starter code has a synchronous API - they'll convert it to use rq.
Project Overview
Scenario: Convert a slow notification API to async
Starter: Working API that blocks for 3 seconds per notification
Goal: Use rq so POST returns instantly
Success: Multiple notifications queue up and process in background
Connection to Assignment 2: You'll implement this same pattern for sending task completion emails!
Emphasize the Assignment 2 connection - this isn't just practice, it's direct preparation. The starter code is intentionally slow so they can feel the difference after adding the queue.
Getting Started
Clone/download the starter code
Start Redis: docker-compose up -d redis
Run the starter API: python app.py
Test it - notice the 3-second delay!
Now add rq to make it async...
# Test the slow version
time curl -X POST http://localhost:5000/notifications \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com", "message": "Hello!"}'
# Takes 3+ seconds...
Have them run the starter first and experience the slowness. Use `time curl` to show the actual delay. This creates motivation for the fix. Then they'll appreciate when POST returns instantly.
Questions?
Let's build some async systems!
Take questions, then transition to in-class work. Walk around and help during the exploration. Common questions: "How do I know when to use a queue?" (anything >500ms that can be deferred), "What about websockets?" (different pattern, for real-time).