All writing
event-driven-architecture system-design async-patterns distributed-systems

Event Driven Architecture vs Request Response: Trade-offs

Ayush Kaushik 8 min read Updated
Event Driven Architecture vs Request Response: Trade-offs

Choosing between event driven architecture vs request response comes down to coupling tolerance and latency requirements. Request-response is simpler to debug and reason about when you need immediate consistency. Event-driven scales better for decoupled, high-throughput workloads but introduces operational complexity you cannot ignore.

I have run both patterns in production at scale. The decision is rarely purely technical. It is often about team size, on-call rotation maturity, and whether your business can tolerate eventual consistency. Let me break down where each approach pays off and where it bites you.

Core architectural differences and coupling implications

Request-response creates temporal coupling. The client blocks until the server processes the request and returns a response. If the downstream service is slow or down, the caller feels it immediately. This is fine for CRUD operations, authentication, or any flow where the user needs an answer now.

Event-driven architecture removes that temporal coupling. The producer emits an event and moves on. Consumers process at their own pace. This is powerful for decoupling deployment cycles and failure domains. I can deploy a new consumer version without touching the producer. I can add a new analytics consumer six months later without asking the producer team for permission.

The trade-off is spatial coupling through the event contract. Schema changes become breaking changes across service boundaries. You need a registry, versioning strategy, and migration discipline. I learned this the hard way when a consumer team added a required field without a default. The producer kept emitting valid v1 events. The consumer crashed on every message. We now enforce backward compatibility in CI using a schema registry.

# Request-response: synchronous, blocking
@app.post("/orders")
async def create_order(order: OrderCreate, db: AsyncSession = Depends(get_db)):
order = await order_service.create(order, db)
await payment_service.charge(order.payment_id) # blocks here
await inventory_service.reserve(order.items) # blocks here
await notification_service.send_confirmation(order.user_id)
return order
# Event-driven: fire and forget
@app.post("/orders")
async def create_order(order: OrderCreate, db: AsyncSession = Depends(get_db)):
order = await order_service.create(order, db)
await event_bus.publish("order.created", OrderCreatedEvent.from_orm(order))
return {"order_id": order.id, "status": "pending"}

The second example returns immediately. The payment, inventory, and notification work happens asynchronously. The user gets an order ID and polls or websockets for status. This pattern is essential when you integrate with flaky third-party APIs or run long-running AI workflows. For more on structuring these boundaries, see Event Driven Architecture vs Microservices: Which to Use.

Latency profiles and throughput characteristics

Request-response latency is the sum of all downstream calls. If payment takes 800ms and inventory takes 400ms, your endpoint latency is at least 1.2s plus overhead. You cannot parallelize easily in a single threaded request without explicit asyncio.gather and even then you are limited by the request timeout and connection pool.

Event-driven shifts latency to the consumer side. The producer latency is just the broker acknowledgment. Typically sub-10ms for Kafka or Redis Streams. Throughput scales horizontally by adding consumer partitions. I have seen a single producer sustain 50k events/sec while consumers scale independently based on their own workload profiles.

But tail latency hurts. A slow consumer creates lag. If your SLA requires “user sees confirmation in 2 seconds”, event-driven makes that probabilistic. You need to design for the p99, not the average. In one system, we added a synchronous “fast path” for critical user-facing confirmations while keeping the heavy lifting async. Hybrid approaches are underrated.

Error handling, retries, and dead letter patterns

Request-response error handling is straightforward. Try-except, return 5xx or 4xx, caller retries with exponential backoff. The retry logic lives in the client. You see the failure immediately in your logs.

Event-driven requires a different mindset. The producer has already succeeded. The consumer fails in isolation. You need:

  • Retry policies with backoff at the consumer level
  • Dead letter queues (DLQ) for poison pills
  • Idempotency keys on every event so retries are safe
  • Alerting on DLQ depth and consumer lag
# Consumer with retry and DLQ
class OrderConsumer:
def __init__(self, dlq: DeadLetterQueue):
self.dlq = dlq
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
async def handle(self, event: OrderCreatedEvent):
try:
await payment_service.charge(event.payment_id)
await inventory_service.reserve(event.items)
except TransientError as e:
logger.warning("Transient failure, will retry", event_id=event.id, error=str(e))
raise # triggers retry
except PermanentError as e:
logger.error("Permanent failure, sending to DLQ", event_id=event.id, error=str(e))
await self.dlq.send(event, reason=str(e))
return # ack the message, don't retry
async def on_message(self, raw_message: Message):
event = OrderCreatedEvent.parse_raw(raw_message.body)
await self.handle(event)
await raw_message.ack()

Idempotency is non-negotiable. Use the event ID or a business key as the idempotency key. Store processed keys in Redis with a TTL matching your retention window. I have seen duplicate charges because a consumer crashed after processing but before acking. The broker redelivered. The consumer processed again. No idempotency key meant double charge.

Observability: distributed tracing vs request-scoped logs

Request-response gives you a natural trace boundary. One request ID spans the entire call chain. Structured logs with request_id correlate everything. FastAPI middleware makes this trivial.

Event-driven breaks that boundary. The producer trace ends at publish. The consumer trace starts at consume. You need context propagation through the event headers. OpenTelemetry handles this with traceparent headers injected into the message.

# Producer injects trace context
from opentelemetry.trace import get_current_span
from opentelemetry.propagate import inject
async def publish_with_trace(event_bus, topic, event):
headers = {}
inject(headers) # injects traceparent, tracestate
await event_bus.publish(topic, event, headers=headers)
# Consumer extracts and continues trace
from opentelemetry.propagate import extract
from opentelemetry.trace import SpanKind
async def consume_with_trace(handler, message):
ctx = extract(message.headers)
with tracer.start_as_current_span(
f"consume {message.topic}",
context=ctx,
kind=SpanKind.CONSUMER
):
await handler(message)

Without this, you have two disconnected traces. Debugging a failed order means jumping between producer logs, broker metrics, and consumer logs. With propagation, you see the full waterfall in Jaeger or Tempo. It is worth the 30 minutes of setup.

When to choose each pattern for AI agent workflows

AI agent workflows are the new frontier for this decision. They are inherently long-running, non-deterministic, and composed of multiple tool calls.

Request-response works for single-turn interactions. User asks, agent thinks, agent calls one tool, returns answer. Latency is acceptable if the total chain is under 30 seconds. Beyond that, HTTP timeouts, load balancer limits, and user patience become problems.

Event-driven shines for multi-step agents with human-in-the-loop or external API dependencies. The agent emits agent.step.completed events. A planner consumes and decides the next step. A human-review service pauses the workflow until approval. Each step is independently retryable and observable.

I built a RAG pipeline where the embedding generation, retrieval, and synthesis each ran as separate consumers, similar to how an asynccontextmanager fastapi pattern manages lifecycle events. This let us scale the GPU-heavy embedding workers independently from the CPU retrieval workers. The architecture is detailed in Building a Robust RAG Pipeline Architecture for Production.

Don’t force event-driven for simple chat endpoints. The complexity tax is real. Start synchronous. Migrate when you feel the pain of timeouts, coupling, or scaling bottlenecks.

Migration strategies from synchronous to request response

You cannot flip a switch. Migrate incrementally using the outbox pattern. This guarantees exactly-once semantics without two-phase commit.

  1. Write the domain change and the event to an outbox table in the same transaction
  2. A separate relay process publishes outbox events to the broker
  3. Consumers process events asynchronously
  4. The original synchronous endpoint returns immediately after the transaction commits
# Outbox pattern in SQLAlchemy 2.0
class OutboxEvent(Base):
__tablename__ = "outbox_events"
id = Column(UUID, primary_key=True, default=uuid4)
topic = Column(String, nullable=False)
payload = Column(JSONB, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
published_at = Column(DateTime, nullable=True)
async def create_order_with_outbox(order: OrderCreate, db: AsyncSession):
order = await order_service.create(order, db)
event = OrderCreatedEvent.from_orm(order)
outbox = OutboxEvent(topic="order.created", payload=event.dict())
db.add(outbox)
await db.commit() # single transaction
return order
# Relay runs as background task or separate process
async def relay_outbox(db: AsyncSession, event_bus: EventBus):
while True:
events = await db.execute(
select(OutboxEvent)
.where(OutboxEvent.published_at.is_(None))
.limit(100)
)
for event in events.scalars():
await event_bus.publish(event.topic, event.payload)
event.published_at = datetime.utcnow()
await db.commit()
await asyncio.sleep(1)

This pattern lets you keep the existing request-response API contract while moving side effects to events. Once consumers are stable, you can change the API to return 202 Accepted with a polling endpoint. The database transaction boundary remains your source of truth.

I have used this migration path three times. It works. The relay is a single point of failure so run it with leader election or as a Kubernetes Deployment with a single replica. Monitor the outbox table lag like you monitor consumer lag.

FAQ

What is the main difference between event driven architecture vs request response? Request-response couples the caller to the callee in time. The caller blocks waiting for a response. Event-driven decouples them. The producer emits an event and continues immediately while consumers process asynchronously.

When should I avoid event-driven architecture? Avoid it when you need immediate consistency, sub-second user-facing latency guarantees, or lack the operational maturity to run a message broker, handle schema migrations, and debug distributed traces.

How do I handle transactions across event-driven services? Use the outbox pattern. Write domain changes and events in the same database transaction. A relay publishes events reliably. Consumers must be idempotent. Saga orchestrators or choreography handle rollbacks via compensating events.

Can I mix both patterns in one system? Yes. Most production systems do. Use request-response for queries, authentication, and user-facing mutations that need immediate feedback. Use event-driven for notifications, integrations, analytics, and long-running workflows.

Key Takeaways

  • Request-response is simpler to operate and debug. Choose it by default for synchronous user flows.
  • Event-driven removes temporal coupling and enables independent scaling. Adopt it when you have clear scaling bottlenecks or integration boundaries.
  • The outbox pattern is the safest migration path. It preserves ACID guarantees while you build event consumers.
  • Observability requires trace context propagation through message headers. Do not skip this.
  • Idempotency keys and dead letter queues are not optional in event-driven systems.
  • AI agent workflows benefit from event-driven when steps are long-running or require human approval. Keep simple chat synchronous.

Working on something similar?

If you're building backend or AI systems and want a second set of senior eyes, let's talk.

Keep reading

Related articles