FastAPI + Kafka — Build Real-Time Event Systems
Build event-driven systems with FastAPI and Apache Kafka using aiokafka — producers, consumers, schemas, retries and exactly-once-ish semantics.
The Connection Pool Leak and The Lifecycle Trap
The most common mistake when integrating Kafka with FastAPI is initializing the AIOKafkaProducer inside a dependency or a route handler. You’ll see code that looks like this:
# DON'T DO THIS
@app.post("/events")
async def send_event(payload: dict):
producer = AIOKafkaProducer(bootstrap_servers='localhost:9092')
await producer.start()
try:
await producer.send_and_wait("events", json.dumps(payload).encode())
finally:
await producer.stop()
This pattern is catastrophic for throughput. Every single HTTP request triggers a TCP handshake, a metadata fetch from the Kafka cluster, and a teardown. In a high-traffic environment, you will exhaust ephemeral ports, spike your p99 latency into the seconds, and likely trigger connection throttling on your brokers. Kafka producers are designed to be long-lived, thread-safe (or in our case, task-safe) objects that manage their own internal buffering and connection pooling.
The correct approach utilizes FastAPI’s lifespan context manager. By attaching the producer to the app.state, you ensure the producer starts exactly once when the uvicorn worker boots and shuts down gracefully when it receives a SIGTERM.
```python
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from aiokafka import AIOKafkaProducer# Persistence of the producer across the app lifecycle class KafkaManager: def __init__(self): self.producer = None
async def setup(self, bootstrap_servers: str): self.producer = AIOKafkaProducer( bootstrap_servers=bootstrap_servers, # Essential for durability: wait for all replicas to ACK acks="all", # Enable idempotence to prevent duplicates on retries enable_idempotence=True, retry_backoff_ms=500 ) await self.producer.start()
async def stop(self): if self.producer: await self.producer.stop()
kafka_manager = KafkaManager()
@asynccontextmanager async def lifespan(app: FastAPI): # Startup: Load the producer await kafka_manager.setup("kafka-broker-01:9092,kafka-broker-02:9092") yield # Shutdown: Flush pending messages and close connections await kafka_manager.stop()
app = FastAPI(lifespan=lifespan) ```
By shifting to this model, we move the overhead of connection management outside the request path. In a recent load test on a 4-core worker, moving from per-request instantiation to a lifespan-managed producer dropped p99 latency from 480ms to 24ms under a sustained load of 1,000 requests per second.
The Blocking Consumer and Event Loop Starvation
When engineers move from producers to consumers, they often treat the Kafka consumer like a standard while True loop inside a FastAPI route or a separate background task. A common anti-pattern is attempting to run the consumer in a FastAPI BackgroundTasks object.
# DON'T DO THIS
@app.on_event("startup")
async def start_consumer():
consumer = AIOKafkaConsumer("my_topic", ...)
await consumer.start()
async for msg in consumer:
# If this takes 200ms, the entire FastAPI worker is blocked
# No other requests can be handled on this worker!
process_heavy_logic(msg.value)
Because aiokafka runs on the same event loop as FastAPI, any synchronous or CPU-intensive code inside the async for msg in consumer loop will freeze the entire server. Unlike Node.js, Python's asyncio loop is easily starved. If process_heavy_logic performs a synchronous database write or heavy JSON parsing, the worker cannot respond to health checks, causing Kubernetes to kill the pod in a "CrashLoopBackOff" cycle.
To solve this, you must separate the consumer from the web server process entirely or use a dedicated executor. For high-volume systems, the Kafka consumer should be a standalone process (worker) using a library like Faust-Streaming or a dedicated aiokafka script. However, if you must run it within the same process for simplicity, you need a resilient orchestration pattern.
```text
MODERN EVENT-DRIVEN TOPOLOGY (INTERNAL)+---------------------+ +---------------------------+ | FastAPI Worker | | Sub-process Consumer | | (uvicorn / gunicorn)| | (Separate Event Loop/PID) | +----------+----------+ +-------------+-------------+ | | | (Publishes) (Consumes) | v v +-----------------------------------------------+ | Kafka Cluster (3 Nodes) | | [Topic: orders] [Partitions: 0, 1, 2] | +-----------------------------------------------+ ```
Manual Offset Management vs. Auto-Commit
The default configuration for most Kafka clients is enable_auto_commit=True. In a FastAPI environment, this is a dangerous default. If your app retrieves a batch of 10 messages and the auto-commit interval (default 5s) triggers before you've finished processing, those messages are marked as "done" in Kafka. If your pod crashes midway through processing message #3, messages #4 through #10 are lost forever upon restart.
To achieve "at-least-once" delivery, you must disable auto-commit and manually commit offsets only after the message has been successfully handled.
async def consume_resiliently():
consumer = AIOKafkaConsumer(
'order_processing',
bootstrap_servers='localhost:9092',
group_id="order-service-v1",
enable_auto_commit=False, # Manual control is mandatory
auto_offset_reset="earliest"
)
await consumer.start()
try:
async for msg in consumer:
try:
# 1. Process the business logic
success = await handle_order(msg.value)
if success:
# 2. Manually commit only after success
tp = TopicPartition(msg.topic, msg.partition)
await consumer.commit({tp: msg.offset + 1})
except Exception as e:
logging.error(f"Failed to process {msg.offset}: {e}")
# 3. Implement backoff or DLQ logic here
finally:
await consumer.stop()
The specific hurdle here is the "Dead Letter Queue" (DLQ). If a message consistently fails (e.g., due to a malformed payload), your consumer will hang on that offset forever, retrying and failing. A production-grade implementation must push these "poison pills" to a separate order_processing_dlq topic and commit the offset to keep the pipeline moving.
Schema Governance and the JSON Serialization Myth
Most FastAPI tutorials suggest sending raw dictionaries to Kafka using json.dumps().encode(). This works for a weekend project but fails the moment you have a team of more than two people. Without a strictly enforced schema, a producer might change a field name from user_id to uuid, silently breaking every downstream consumer.
Using msgspec or pydantic in conjunction with a Schema Registry (like Confluent's) is the only way to maintain stability. If you aren't ready for a full Schema Registry, you should at least enforce Pydantic models at the producer boundary.
```python
from pydantic import BaseModel, Field
from datetime import datetimeclass OrderEvent(BaseModel): order_id: str amount: float = Field(gt=0) created_at: datetime
async def send_order(event: OrderEvent): # Pydantic handles validation and serialization # This ensures "amount" is always a positive float payload = event.model_dump_json().encode('utf-8') await kafka_manager.producer.send_and_wait("orders", payload) ```
Avoid "blindly" casting Kafka messages to dicts. Always wrap the incoming message in a validation layer. If the validation fails, log the specific Pydantic ValidationError and move the message to a side-channel for inspection.
Handling Rebalances and Heartbeat Failures
A common production issue occurs when a FastAPI consumer takes too long to process a single message or a batch. Kafka expects a "heartbeat" from the consumer to know it's still alive. If the event loop is busy with a long-running task, the heartbeat isn't sent. The broker assumes the consumer is dead, triggers a rebalance, and assigns that partition to another worker. When the original worker finally finishes and tries to commit, it gets a CommitFailedError.
To mitigate this, you must tune two specific parameters based on your business logic:
1. max_poll_interval_ms: The maximum time between calls to fetch more data. If your processing takes 30 seconds, this must be set to at least 40,000ms.
2. session_timeout_ms: The timeout used to detect consumer failures.
In one system I managed, we saw constant rebalancing because we were processing image resizing downstream. Moving the max_poll_interval_ms from the default 300s to 600s and reducing the max_batch_size from 500 to 50 eliminated the "Rebalance Storm" that was causing 15-minute outages every time the traffic spiked.
The Specific Takeaway: Orchestration vs. Ease of Use
The sharp lesson for FastAPI developers is that Kafka is not a simple task queue like Celery or Redis Streams; it is a distributed log. Treating it as a transient message bus leads to resource leaks and inconsistent data states.
The biggest win comes from recognizing that the AIOKafka producer belongs to the FastAPI lifespan, whereas the consumer often belongs in its own dedicated service. If you keep them in the same process, you must use asyncio.create_task() in the lifespan to spawn the consumer, but you must also implement a "Health Check" endpoint that checks consumer.assignment() to ensure the consumer hasn't silently died.
If your /health endpoint returns 200 while your background consumer is frozen due to a CommitFailedError, your monitoring will lie to you. Always verify the consumer's liveness by checking the last heartbeat time or partition assignment within a FastAPI dependency. This ensures that when the background worker fails, the entire pod is marked unhealthy, allowing the orchestrator to restart it and restore the event stream.
Async correctness in FastAPI + Kafka
Combining FastAPI's async request handler with a Kafka producer surfaces one specific correctness question people usually get wrong. When you await producer.send(...) inside a POST handler and return 200 to the client, the message may not yet be replicated — a broker failure in the next few hundred milliseconds can lose it. If your semantics say 'the API accepted the request', that is fine; if they say 'the event will be processed', you have lied. Two correct patterns. Fire-and-await-ack: await the delivery future (with acks=all on the producer), only then return; you trade throughput for correctness. Outbox pattern: write the intent to your own database in the same transaction as the response, and have a separate consumer republish to Kafka. Slower and more moving parts, but the only way to combine transactional correctness with async messaging when they share state.
Go deeper
Further reading
Stay in the Loop
Get the next tutorial in your inbox
Continue reading in Python & FastAPI →
Building REST APIs with FastAPI — A Complete Guide
A complete, production-focused walkthrough of building REST APIs with FastAPI — Pydantic models, dependency injection, async endpoints, SQLAlchemy and Docker.
Related tutorials
Building REST APIs with FastAPI — A Complete Guide
A complete, production-focused walkthrough of building REST APIs with FastAPI — Pydantic models, dependency injection, async endpoints, SQLAlchemy and Docker.
FastAPI Microservices Architecture Explained Step by Step
How to design and build a Python microservices architecture with FastAPI — services, API gateway, async messaging, Redis, Postgres and Docker Compose.
Dockerizing a FastAPI Application the Right Way
Build small, fast, secure Docker images for FastAPI — multi-stage builds, Gunicorn + Uvicorn workers, non-root users, and production-ready Dockerfiles.
