Back to Blog
Backend Architecture FastAPI Task Queues Redis

Async Task Queues: Scale APIs with Redis Streams

• 11 min read • Written by Marlon

TL;DR:

  • The Trap: Processing heavy operations synchronously within HTTP request lifecycles (generating invoices, crunching analytical aggregates, firing webhooks) locks server workers for 10–30 seconds, triggering HTTP 504 Gateway Timeouts and bringing down entire platforms during traffic surges.
  • The Architecture: A resilient 4-stage event processing pipeline: ultra-fast HTTP 202 Accepted returns in under 10ms with job_id, Redis Streams with Consumer Groups for persistent at-least-once delivery, idempotency key deduplication, and automated Dead-Letter Queues (DLQ).
  • The Business Impact: Protects API p99 latencies under 35ms regardless of background workload volume, prevents double billing from network retries, and scales background throughput horizontally on sub-$50/month cloud infrastructure.

The Synchronous Paradox: How Heavy Workloads Crash Backends

Forcing an active user to stare at an eight-second loading spinner while your backend generates a PDF report, queries external analytics, and fires three synchronous partner webhooks is the fastest way to destroy user retention and brand credibility.

When engineering teams execute computational or I/O-intensive workloads inside the synchronous lifecycle of an ordinary HTTP request, the web server thread remains completely held hostage.

Under moderate traffic spikes—such as a Black Friday flash sale, a product launch, or an end-of-month financial closing—incoming connection pools exhaust within seconds. The result is a cascade of catastrophic 504 Gateway Timeout errors that bring down unrelated microservices and APIs across the board.

For an application to gracefully support thousands of concurrent users, customer-facing HTTP request handlers must never perform heavy computation.

The architectural imperative is decoupling user intent from execution through Asynchronous Task Queues.

  1. STAGE 01 <10ms

    Fast 202 Accepted

    Validates schema, asserts idempotency lock, appends work intent to Redis Streams, and returns tracking job ID.

  2. STAGE 02 Persistence

    Redis Stream Broker

    Persists event messages in append-only streams with Consumer Groups, managing pending entries list (PEL).

  3. STAGE 03 Resilient Worker

    Idempotent Execution

    Background worker processes job with retry budgets, backoff delays, and explicit transaction commits.

  4. STAGE 04 Resolution

    DLQ & Webhook Fanout

    Emits WebSocket notification upon success, or routes poisoned tasks to Dead-Letter Queue after 3 failed attempts.


Architectural Deep Dive: Surviving the Realities of Distributed Queues

1. The At-Least-Once Delivery Fallacy and Idempotency

Engineers often assume that when a task queue enqueues a job, the worker executes it exactly once. In distributed systems, this is a dangerous myth.

Network partitions, worker crashes during execution, and Redis acknowledgment timeouts (XACK) mean message brokers provide at-least-once delivery:

  • If Worker A processes a billing charge but crashes 2 milliseconds before committing its acknowledgment back to the broker, Redis reassigns the un-acked message to Worker B.
  • If Worker B runs the job blindly without an Idempotency Guard, the customer is charged twice.

The Solution: Every queued job payload must carry a unique, deterministic idempotency_key (e.g. order_id + action). Before executing external side effects, the worker sets a temporary atomic lock in Redis (SET lock:{idempotency_key} NX EX 300) and asserts that the state transition hasn’t already been committed in the primary database.

2. Poison Pill Tasks and Dead-Letter Queues (DLQ)

A “poison pill” is a malformed message whose payload causes the worker to raise an unhandled exception or crash with a segmentation fault (e.g. corrupted binary data, division by zero, or invalid schema).

Without a robust retry budget and Dead-Letter Queue:

  1. The worker crashes upon reading the poison message.
  2. The orchestrator (Docker, Kubernetes, or systemd) restarts the worker.
  3. The worker re-reads the same poison message from the stream.
  4. The worker crashes again in an infinite loop (CrashLoopBackOff), halting all healthy pending jobs in the queue.

The Fix: Workers track retry counts per message in Redis metadata. After exceeding an explicit retry threshold (e.g., 3 attempts), the message is stripped from the active queue and routed to a dedicated Dead-Letter Queue (jobs:dlq) alongside the full stack trace, triggering an alert to the engineering team while allowing the consumer group to continue processing healthy jobs.

3. Fair Queueing and Resource Starvation

If your e-commerce platform processes user checkout emails and monthly batch analytical exports through the same generic queue, a single enterprise tenant requesting a 100,000-row CSV export will saturate your entire worker fleet. Customers waiting for their checkout receipt will experience 40-minute delays.

Production systems enforce Priority Partitioning:

  • queue:critical: Checkout events, transactional auth tokens, password resets (processed immediately with dedicated reserved workers).
  • queue:default: Standard application webhooks and operational syncs.
  • queue:batch: Long-running PDF generation, analytical crunching, and nightly maintenance jobs.

Code Comparison: Naive Blocking Endpoint vs Resilient Asynchronous Engine

Naive: Synchronous Blocking Request Handler

# Naive Endpoint: Executes heavy computation inside the HTTP lifecycle
import time
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()

class InvoiceRequest(BaseModel):
    tenant_id: str
    user_id: str
    month: str

@app.post("/api/v1/invoices/generate")
def generate_invoice_naive(req: InvoiceRequest):
    try:
        # BOTTLENECK 1: Simulates 12-second PDF render & data crunching
        # Completely blocks the server worker thread from handling other clients!
        time.sleep(12) 
        
        # BOTTLENECK 2: Synchronous third-party HTTP call. If Stripe is slow, this hangs!
        # BOTTLENECK 3: If client disconnects or times out, the work is lost with no retries.
        pdf_url = f"https://s3.amazonaws.com/invoices/{req.tenant_id}_{req.month}.pdf"
        
        return {
            "status": "success",
            "pdf_url": pdf_url
        }
    except Exception as exc:
        raise HTTPException(status_code=500, detail="Generation failed")

Production: Resilient Asynchronous Event Engine with Redis Streams

# Production Engine: Sub-10ms HTTP 202, Redis Streams, Consumer Groups, DLQ & Idempotency
import asyncio
import json
import logging
import uuid
import time
from typing import Optional
from fastapi import FastAPI, Request, Response, status, HTTPException
from pydantic import BaseModel, Field
import redis.asyncio as aioredis

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("async_task_engine")

app = FastAPI()
redis_client = aioredis.from_url("redis://localhost:6379", decode_responses=True)

STREAM_NAME = "jobs:stream:invoices"
GROUP_NAME = "workers:group:invoicing"
DLQ_NAME = "jobs:dlq:invoices"

class InvoiceJobPayload(BaseModel):
    tenant_id: str = Field(..., min_length=3, max_length=64)
    invoice_id: str = Field(..., min_length=3, max_length=64)
    idempotency_key: str = Field(..., min_length=8, max_length=128)
    month: str = Field(..., regex=r"^\d{4}-\d{2}$")

# 1. High-Speed API Producer (Sub-10ms HTTP 202 Response)
@app.post("/api/v1/invoices/generate", status_code=status.HTTP_202_ACCEPTED)
async def enqueue_invoice_job(payload: InvoiceJobPayload, response: Response):
    # IDEMPOTENCY GUARD: Prevent duplicate submissions from double clicks or network retries
    idempotency_lock = f"idempotency:lock:{payload.tenant_id}:{payload.idempotency_key}"
    is_new = await redis_client.set(idempotency_lock, "PROCESSING", nx=True, ex=3600)
    
    if not is_new:
        existing_status = await redis_client.get(idempotency_lock)
        logger.info(f"Duplicate request detected for idempotency_key={payload.idempotency_key}")
        return {
            "status": "Accepted",
            "message": "Job already registered and currently processing",
            "idempotency_key": payload.idempotency_key
        }

    job_id = f"job_{uuid.uuid4().hex[:12]}"
    
    # Push job intent onto persistent Redis Stream
    job_envelope = {
        "job_id": job_id,
        "tenant_id": payload.tenant_id,
        "invoice_id": payload.invoice_id,
        "idempotency_key": payload.idempotency_key,
        "month": payload.month,
        "attempts": "0",
        "created_at": str(time.time())
    }
    
    await redis_client.xadd(STREAM_NAME, job_envelope)
    
    # Return 202 Accepted with canonical tracking headers
    response.headers["Location"] = f"/api/v1/jobs/{job_id}"
    return {
        "status": "Accepted",
        "job_id": job_id,
        "poll_url": f"/api/v1/jobs/{job_id}",
        "message": "Invoice generation queued successfully"
    }

# 2. Resilient Worker Daemon with Consumer Groups & Dead-Letter Handling
class InvoicingWorker:
    def __init__(self, worker_id: str):
        self.worker_id = worker_id
        self.running = True

    async def setup_stream(self):
        """Creates the Redis consumer group if it does not already exist."""
        try:
            await redis_client.xgroup_create(STREAM_NAME, GROUP_NAME, id="0", mkstream=True)
            logger.info(f"Consumer group {GROUP_NAME} initialized.")
        except Exception as e:
            if "BUSYGROUP" not in str(e):
                logger.error(f"Error creating consumer group: {e}")

    async def run_loop(self):
        await self.setup_stream()
        logger.info(f"Worker {self.worker_id} started listening on {STREAM_NAME}...")
        
        while self.running:
            try:
                # Read 1 pending message with 2-second block
                entries = await redis_client.xreadgroup(
                    GROUP_NAME, self.worker_id, {STREAM_NAME: ">"}, count=1, block=2000
                )
                
                if not entries:
                    continue

                for stream_key, messages in entries:
                    for msg_id, data in messages:
                        await self.process_message(msg_id, data)

            except Exception as loop_err:
                logger.error(f"Worker {self.worker_id} exception in run loop: {loop_err}")
                await asyncio.sleep(1)

    async def process_message(self, msg_id: str, data: dict):
        job_id = data.get("job_id")
        attempts = int(data.get("attempts", "0")) + 1
        
        try:
            logger.info(f"Worker {self.worker_id} executing job_id={job_id} (Attempt {attempts})")
            
            # --- Simulated Work: Heavy PDF Computation and Storage Upload ---
            await asyncio.sleep(2.0) # Non-blocking asynchronous work
            
            # Atomic state transition and Acknowledgment
            await redis_client.set(
                f"job:result:{job_id}", 
                json.dumps({"status": "COMPLETED", "completed_at": time.time()}), 
                ex=86400
            )
            # Acknowledge the message so it is removed from the Pending Entries List (PEL)
            await redis_client.xack(STREAM_NAME, GROUP_NAME, msg_id)
            logger.info(f"Job {job_id} successfully acknowledged (XACK).")

        except Exception as task_err:
            logger.error(f"Execution failed for job {job_id}: {task_err}")
            
            if attempts >= 3:
                # ROUTE TO DEAD-LETTER QUEUE: Exhausted retry budget
                logger.critical(f"Job {job_id} exceeded max retries. Diverting to DLQ: {DLQ_NAME}")
                await redis_client.xadd(DLQ_NAME, {
                    **data,
                    "final_error": str(task_err),
                    "failed_at": str(time.time())
                })
                # Acknowledge on the primary stream so it doesn't block other tasks
                await redis_client.xack(STREAM_NAME, GROUP_NAME, msg_id)
            else:
                # Re-queue with incremented attempt counter and exponential backoff
                backoff_delay = 2 ** attempts
                logger.warning(f"Re-queueing job {job_id} after {backoff_delay}s backoff...")
                await asyncio.sleep(backoff_delay)
                data["attempts"] = str(attempts)
                await redis_client.xadd(STREAM_NAME, data)
                await redis_client.xack(STREAM_NAME, GROUP_NAME, msg_id)

Real-World Case Studies: Event-Driven Throughput

Strategio (Startup Perú 2G Winner): Unthrottled BI Processing

During the architecture and rollout of Strategio, an AI-powered Business Intelligence platform built from scratch in 12 weeks:

  • The Problem: The application needed to aggregate transaction records, calculate multi-variant profit margins, and generate executive analytical dossiers for thousands of retail businesses every night. Initial synchronous prototypes generated HTTP 504 timeouts on catalogs exceeding 10,000 items.
  • The Solution: We decoupled all analytical aggregation using an asynchronous event pipeline orchestrated with Redis Streams and lightweight Python workers running in Docker containers.
  • The Result: The platform handled millions of analytical events seamlessly with public API response times staying below 35ms (p99), while maintaining cloud hosting expenses strictly below $50 USD/month.

PróximaBolilla (proximabolilla.com): Massive Stochastic Simulations

PróximaBolilla performs high-throughput predictive lottery simulations processing more than 1,000,000 algorithmic combinations in real time.

  • The Problem: Running intensive combinatorial distributions synchronously was freezing user web sessions and triggering browser timeouts during high-traffic draw intervals.
  • The Solution: We implemented an asynchronous task pipeline. Simulation requests emit an instant 202 Accepted response with an ephemeral tracking ticket, while isolated background worker pools crunch stochastic combinations across CPU cores. Sockets stream results dynamically as batches conclude.
  • The Result: Complete elimination of HTTP timeout errors, linear multi-core scalability, and instant sub-10ms UI interaction responsiveness.

Pre-Flight Checklist for Asynchronous Task Queues

Before deploying an asynchronous task queue architecture to production, verify these five architectural gates:

  • Instant HTTP 202 Handshake: Does your API endpoint enqueue the task intent and return HTTP 202 in under 15ms without executing inline business logic?
  • Idempotency Protection: Does your job consumer assert an idempotency_key or distributed lock to avoid double billing or duplicated side effects?
  • Persistent Message Broker: Is your broker configured with persistence (Redis AOF/RDB or RabbitMQ durable queues) so pending tasks survive server reboots?
  • Dead-Letter Queue Isolation: Are poison pill tasks automatically diverted to a DLQ after 3 failures with stack traces logged for engineering inspection?
  • Consumer Group Concurrency: Are workers scaled horizontally across consumer groups with explicit XACK confirmations only upon successful side effect commits?

Architectural Advisory

Decoupling high-volume APIs from heavy background workloads requires disciplined distributed systems design, concurrency control, and idempotency guarantees.

At Coins5, Marlon helps venture-backed startups and growing companies eliminate API latency bottlenecks, build resilient event-driven architectures, and optimize background worker pipelines for maximum cost efficiency.

If your platform is experiencing HTTP 504 timeouts or your backend is struggling under heavy computational tasks, schedule a 15-minute technical discovery call or connect via WhatsApp.

Suffering from API Timeouts or Slow Background Jobs?

Let's design a resilient, decoupled event processing architecture in a concise 15-minute technical discovery call.

Marlon - Senior Architect
Author & Systems Architect

Marlon / Coins5

Senior Full Stack Developer & Cloud Architect. Building custom software and production MVPs with Clean Architecture, high-concurrency cloud design, and AI velocity.

Book 15m Call