DEV Community

Ugur Aslim
Ugur Aslim

Posted on • Originally published at uguraslim.com

Task Queues in Production: Celery vs Temporal vs Native FastAPI

Task Queues in Production: Celery vs Temporal vs Native FastAPI

I've shipped async job handling in CitizenApp three different ways. Each one taught me something painful. Here's what actually matters when you're running a SaaS at scale.

The problem is simple: some work shouldn't block HTTP responses. Sending emails, processing PDFs, syncing external APIs, generating reports—these take seconds or minutes. Jamming them into request handlers tanks user experience. Task queues solve this. The question isn't if you need one; it's which architecture loses you least sleep.

Native FastAPI + asyncio: The Trap

Let's start with the tempting route.

from fastapi import FastAPI
import asyncio

app = FastAPI()

async def send_onboarding_email(user_id: int):
    await asyncio.sleep(2)  # Simulate email service
    print(f"Email sent to user {user_id}")

@app.post("/register")
async def register(email: str):
    # Fire and forget
    asyncio.create_task(send_onboarding_email(user_id=123))
    return {"status": "registered"}
Enter fullscreen mode Exit fullscreen mode

This feels great for 3 months. It costs nothing extra. No infrastructure. No Redis. No broker.

Then you deploy. A task crashes. You don't know it failed—the exception dies silently in a background coroutine. Your users never get their onboarding emails. You discover this when churn spikes.

Or your deployment process kills the worker mid-task. That 30-minute PDF generation? Gone. No retry. No queue persistence.

I prefer task queues for anything that matters because loose tasks are invisible failures waiting to happen.

The hidden cost of native async is observability debt. You're building error tracking yourself. You're manually implementing retries. You're debugging timeouts with no context.

For fire-and-forget analytics pings? Sure, use create_task(). For anything touching business logic, user data, or money? No. You need a queue.

Celery: The Industry Standard With Operational Friction

Celery is the default for Django/Python SaaS. It's battle-tested. It handles retries, scheduling, dead-letter queues, and monitoring out of the box.

Here's CitizenApp's email task:

from celery import Celery, Task
from celery.exceptions import MaxRetriesExceededError
import smtplib

celery = Celery('citizenapplications')
celery.conf.update(
    broker_url='redis://localhost:6379/0',
    result_backend='redis://localhost:6379/1',
    task_serializer='json',
    accept_content=['json'],
    timezone='UTC',
    enable_utc=True,
)

@celery.task(bind=True, max_retries=5)
def send_onboarding_email(self, user_id: int, email: str):
    try:
        # Your email logic
        result = smtplib.send_email(email)
        return {"status": "sent", "user_id": user_id}
    except smtplib.SMTPException as exc:
        # Exponential backoff: 2^retry_count minutes
        retry_delay = (2 ** self.request.retries) * 60
        raise self.retry(exc=exc, countdown=retry_delay)
    except Exception as exc:
        self.update_state(state='FAILURE', meta=str(exc))
        raise

# In FastAPI
@app.post("/register")
async def register(email: str):
    send_onboarding_email.delay(user_id=123, email=email)
    return {"status": "registered"}
Enter fullscreen mode Exit fullscreen mode

This is solid. Tasks are logged. Failed tasks sit in Redis with retry metadata. You can inspect the queue with celery inspect active. You can manually requeue dead tasks.

The gotcha: Celery's operational overhead grows with your queue size.

At CitizenApp, we hit 200K daily tasks. Our Celery workers (running on Render) started eating CPU during traffic spikes. We had to tune worker concurrency, prefetch settings, and beat scheduler settings. Most engineers don't realize Celery is also a distributed system—it has network partitions, clock skew issues, and can lose tasks if Redis dies before tasks commit to disk.

Celery shines for:

  • Scheduled jobs (reports, cleanups, syncs)
  • High-volume async work with retry logic
  • Teams comfortable debugging distributed systems

Celery fails when:

  • You need guaranteed execution ordering (Celery's guarantees are best-effort)
  • You want tight observability without external tools (Datadog + Celery is better, but that's extra cost)
  • Task dependencies matter (task A must finish before B; Celery makes this messy)

Temporal: The Workflow Engine

This is the newer entrant. Temporal treats your entire job as a durable workflow. It persists state, handles retries, and guarantees execution semantics.

from temporalio import workflow, activity
from datetime import timedelta

@activity.defn
async def send_email_activity(email: str) -> str:
    # Even if this activity fails mid-execution,
    # Temporal replays it deterministically
    await send_email_service(email)
    return f"Email sent to {email}"

@activity.defn
async def create_onboarding_checklist(user_id: int) -> dict:
    return {"checklist": [...]}

@workflow.defn
class OnboardingWorkflow:
    @workflow.run
    async def run(self, user_id: int, email: str):
        # Temporal guarantees this sequence happens exactly once
        await workflow.execute_activity(
            send_email_activity,
            email,
            start_to_close_timeout=timedelta(minutes=5)
        )
        await workflow.execute_activity(
            create_onboarding_checklist,
            user_id,
            start_to_close_timeout=timedelta(minutes=10)
        )
        return {"status": "onboarding_complete"}
Enter fullscreen mode Exit fullscreen mode

Temporal's genius: if a worker crashes, it replays the workflow from the event log. Activities are idempotent by design. You get causality—tasks have dependencies that are enforced, not coded around.

The cost: operational complexity and Temporal Cloud pricing ($25/million events).

For CitizenApp's volume, that's roughly $150–200/month. You also need to run a Temporal server (or use Cloud). Local development requires spinning up containers.

Temporal wins for:

  • Workflows with multi-step dependencies
  • Systems where order and atomicity matter (payment processing, provisioning)
  • Teams that can invest in understanding distributed workflows

Temporal loses when:

  • You have simple, independent tasks
  • Your operations team isn't comfortable with event sourcing concepts
  • You're optimizing for "cheapest possible first SaaS"

What I Actually Run

At CitizenApp:

  • Celery for high-volume async (emails, webhooks, file processing)
  • Native asyncio tasks for non-critical analytics only
  • Temporal for user provisioning workflows (where ordering is critical)

This hybrid approach lets us keep costs reasonable while guaranteeing reliability where it matters.

The Real Question

Before picking a queue, ask yourself:

  1. Can this task fail silently? (No → you need a queue)
  2. Does failure order matter? (Yes → Temporal, maybe Celery with careful orchestration)
  3. What's your monthly task volume? (10K → Celery; 1M+ → consider Temporal; <1K → native async)
  4. Do you have Redis/infra ops? (No → Temporal Cloud; Yes → Celery on EC2/Render)

What I missed: I initially thought Celery was overkill for CitizenApp. Two months later, a single worker crash lost 400 unconfirmed signups because tasks evaporated. That's the real cost of cheap solutions.

Task queues aren't glamorous, but they're the difference between a SaaS that silently eats data and one your customers trust.

Top comments (0)