Back to Blog
Apr 22, 20264 min readOnuzulike Anthony

Event-Driven vs Request-Response: Choosing the Right Communication Model

When async event-driven patterns are worth the complexity, when request-response is the right call, idempotency keys, and SSE as the pragmatic middle ground.

ArchitectureArchitectureDesignReal-time

The proliferation of message queues, event buses, and streaming platforms has made async event-driven architecture seem like the default for serious applications. It often isn't the right choice. Let's look at when each model is appropriate and what the transition costs.

Request-Response: The Default

HTTP request-response is synchronous: the client sends a request, waits, and gets a response. It's simple, well-understood, and maps naturally to most web interactions.

The strengths:

  • Simple error handling: you get a response or an error, immediately
  • Natural consistency: the response reflects the state after your operation
  • Easy to debug: the entire interaction is in one network exchange
  • Clients are stateless between requests

Most APIs should be request-response. The complexity of async patterns is only worth paying when there's a specific, concrete reason.

When Event-Driven Wins

Long-running operations. If a request takes 30 seconds to complete (video processing, ML inference, complex report generation), holding an HTTP connection open for that long is fragile. The client disconnects, the operation keeps running, and now you have orphaned work with no way to report completion.

Better: accept the job, return a 202 Accepted with a job ID immediately, and let the client poll or receive a notification when done.

POST /api/reports → 202 { jobId: "abc123" }
GET /api/reports/abc123 → 200 { status: "processing" }
GET /api/reports/abc123 → 200 { status: "complete", url: "..." }

Fan-out. One event triggers multiple independent actions (sending an email, updating analytics, clearing a cache, triggering a webhook). Doing all of these synchronously in the request handler means a slow email provider delays your response time.

Decoupling services with different load profiles. Your order service generates 10k orders/second during a flash sale; your invoice generation service can handle 100/second. A queue between them lets the fast producer run at full speed while the slow consumer catches up.

Audit trails. Emitting an event for every state change ("order created", "order shipped") gives you a replayable history. You can replay events to rebuild state, debug issues, or hydrate new services.

The Consistency Challenge

The main cost of async is eventual consistency. When you publish an event and return a success response, the downstream handlers haven't run yet. If the user immediately fetches their updated profile, they might see stale data.

Mitigation strategies:

  • Optimistic UI: update the UI immediately, revalidate in the background
  • Read-your-writes: route reads for the same user to the same instance that processed the write
  • Polling with version: return a version number; the client polls until it sees a version >= the one it expects

Idempotency Keys

In async systems (and in direct APIs with retries), the same operation might be submitted twice. An idempotency key ensures the operation only happens once:

ts
// Client sends a unique key with each operation
POST /api/payments
Idempotency-Key: a3f9b2c1-4e7d-8a6f-3b2e-1c4d5e6f7a8b
 
// Server stores: (key → result) for 24 hours
// If the same key arrives again, return the stored result without reprocessing

This lets clients safely retry on network failure without risk of duplicate charges, duplicate sends, or duplicate database writes.

SSE as the Pragmatic Middle Ground

Server-Sent Events (SSE) is an HTTP-based protocol where the server streams events to the client over a persistent connection. It's unidirectional (server → client) and simpler than WebSockets.

NodWatch's download progress uses SSE:

ts
// Server: GET /api/downloads/events
const encoder = new TextEncoder();
const stream = new ReadableStream({
  start(controller) {
    const interval = setInterval(() => {
      const event = `data: ${JSON.stringify(getProgress())}\n\n`;
      controller.enqueue(encoder.encode(event));
    }, 1000);
    // cleanup on close...
  }
});
 
return new Response(stream, {
  headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }
});

SSE is ideal when:

  • Data flows one-way from server to client
  • You need real-time updates but don't want WebSocket complexity
  • The connection can be re-established on disconnect (SSE has built-in reconnection)
  • You want it to work through HTTP/2 multiplexing and standard proxies

Use WebSockets when you need bidirectional real-time communication. Use SSE when the server needs to push updates to a listening client.

The Decision Tree

Is the operation fast enough (<500ms P99) for synchronous response?
├── Yes → Use request-response
└── No → Does the client need real-time progress?
    ├── Yes → Use SSE or WebSocket for progress + polling for completion
    └── No → Use job queue + polling

Start with request-response. Add async complexity only when a specific, measurable problem requires it.