Skip to content
All articles

Event-Driven AND Synchronous: Low-Latency Responses Without Blocking

Dean Jain

Dean Jain

Senior Staff Software Engineer · Enterprise AI, Data & Cloud Architect

· 6 min read

---
config:
  theme: dark
  fontSize: 17
  themeVariables:
    fontFamily: "Comic Sans MS, Comic Neue, Chalkboard SE, cursive"
---
flowchart LR
    ASYNC["🌊 Event-driven system<br/>async by nature"]:::obs -.->|"stale data or long wait"| USER["👤 User needs<br/>sync · fast · accurate"]:::warn
    classDef obs fill:#AED6F1,stroke:#2E86C1,stroke-width:2px,color:#0F172A
    classDef warn fill:#FFE6A8,stroke:#E0A106,stroke-width:2px,color:#0F172A

Figure 1: The core tension. An asynchronous architecture meeting a synchronous, low-latency requirement.

You built an event-driven platform Kafka, services, multiple databases, eventual consistency and it scales beautifully. Then a use case arrives that demands the opposite of everything EDA is good at: a synchronous response, with low latency, reflecting the current state.

The producer, broker, consumer and database path takes time. So the user either waits too long or reads stale data. The instinct to either block on the async flow or rip out EDA is wrong. The right move is to carve a synchronous fast-path for the few flows that truly need it, while everything else stays async. Here’s how.

TL;DR

  • The conflict is real: EDA is async; some flows need synchronous, fast, accurate responses. Don’t force every flow to be one or the other.
  • Three broad moves: make part of the flow synchronous, speed up the async flow, or change the interaction model (poll/push).
  • The pragmatic winner is hybrid: put the critical path synchronous with direct gRPC, bypass Kafka, and let side-effects stay async. Enable it only for the flows that need it.
  • Dual-write to the read model (CQRS query side) on the sync path so data is immediately queryable.
  • When true sync isn’t required, change the UX: return 202 Accepted + a task ID, then poll or push the result.

1. Three ways to bridge async and sync

Start by naming exactly what’s needed. It’s usually three things at once: a synchronous response, so the user waits for a result; low latency; and accurate data that reflects current state. EDA naturally gives you none of these on a fresh write, because the write propagates asynchronously. There are three broad strategies, and the right answer often combines them:

  • Option 1 make part of the flow synchronous. For specific requests, bypass or shortcut the async hop so the result is ready immediately.
  • Option 2 speed up the async flow. Make eventual consistency happen faster (optimize consumers, DB writes, Kafka config). You should do this anyway, but it narrows the gap rather than closing it.
  • Option 3: change the interaction model. Maybe a truly synchronous response isn’t actually required. Accept the write, and let the client poll or get pushed the result.

The key framing: this is per-flow, not system-wide. The vast majority of your events should stay async (that’s why you chose EDA). You’re surgically adding synchronous behavior only where a consumer genuinely needs an immediate, accurate answer and accepting more complexity there as the price.

2. The hybrid fast-path: critical path sync, side-effects async

The most effective pattern keeps the user-critical step synchronous while the rest of the system stays event-driven:

---
config:
  theme: dark
  fontSize: 17
  themeVariables:
    fontFamily: "Comic Sans MS, Comic Neue, Chalkboard SE, cursive"
---
flowchart LR
    U["👤 User"]:::warn --> API["🌐 API"]:::gate
    API -->|"gRPC, sync (no Kafka)"| SOR["⚙️ System of Record"]:::server
    SOR -->|"dual write (gRPC)"| Q[("🔎 Query DB (read model)")]:::good
    SOR --> API --> U
    SOR -.->|"async events for everything else"| K[("🌊 Kafka")]:::obs
    classDef warn fill:#FFE6A8,stroke:#E0A106,stroke-width:2px,color:#0F172A
    classDef gate fill:#D7C3F2,stroke:#8E5BD0,stroke-width:2px,color:#0F172A
    classDef server fill:#A8E6D0,stroke:#2FA37C,stroke-width:2px,color:#0F172A
    classDef good fill:#BFEFC8,stroke:#3FA34D,stroke-width:2px,color:#0F172A
    classDef obs fill:#AED6F1,stroke:#2E86C1,stroke-width:2px,color:#0F172A

Figure 2: The hybrid pattern. The user-facing path commits synchronously (gRPC + dual write to the read model); Kafka still carries the async side-effects.

Two concrete techniques make this work:

  • Direct write-to-query (bypass Kafka). On the sync path, have the System of Record perform a quick dual write: once to itself, and once via a direct gRPC insert or update to the CQRS query model (e.g. Firestore). The data is now immediately available through the read API, without waiting for the Kafka round-trip. Enable this only for flows that need immediate consistency.
  • Synchronous business-logic processing. Expose a sync API endpoint that calls the core processing component over gRPC rather than Kafka. It waits for that to finish, including persisting its state, saves the result to the query DB, and returns synchronously. The processing unit supports both async and sync modes; you pick per request.

The trade you’re accepting is the dual-write problem (keeping the two writes consistent) and extra complexity on these paths. That is exactly why you limit it to the flows that justify it.

And speed up the async flow regardless (Option 2). Independent of the sync path, tighten eventual consistency. Optimize consumers with more instances and parallelism within partitions. Optimize DB writes with indexing and faster commits. Tune Kafka with batching, compression, more partitions, and dedicated topics per event type. This shrinks the staleness window for everything, often enough that some flows no longer need the sync path at all.

3. Or: change what “synchronous” means

Sometimes the cheapest fix is to question the requirement. If the user doesn’t truly need the answer in the same request, change the interaction model and you sidestep the whole async-vs-sync fight:

---
config:
  theme: dark
  fontSize: 17
  themeVariables:
    fontFamily: "Comic Sans MS, Comic Neue, Chalkboard SE, cursive"
---
flowchart TD
    W["✍️ User write action"]:::gov --> ACC["⚡ 202 Accepted + task_id"]:::gate
    ACC --> POLL["🔁 Poll status/{task_id}<br/>until ready or timeout"]:::obs
    ACC --> PUSH["📡 Push result (WebSocket / SSE)<br/>when ready"]:::server
    classDef gov fill:#E0D6F5,stroke:#9B7EDE,stroke-width:2px,color:#0F172A
    classDef gate fill:#D7C3F2,stroke:#8E5BD0,stroke-width:2px,color:#0F172A
    classDef obs fill:#AED6F1,stroke:#2E86C1,stroke-width:2px,color:#0F172A
    classDef server fill:#A8E6D0,stroke:#2FA37C,stroke-width:2px,color:#0F172A

Figure 3: When true sync isn't required. Accept the write immediately, then let the client poll for the result or have the server push it.

  • Poll return 202 Accepted with a task_id; the client polls a status endpoint until the result lands (or times out). Simple and robust; the read model returns nil until the async flow completes the task.
  • Push return “accepted,” then push the final result over a persistent connection (WebSocket/SSE) when ready. Best UX, but more complex with many sources and a CQRS model.

Both convert “make the user wait synchronously” into “acknowledge immediately, deliver the result when it’s ready”. That is honest about the async reality, and often better UX than a spinner.

Choosing among the strategies:

StrategyCore ideaLatencyAccuracyComplexityBest for
CQRSDurable writes, projected readsLow (reads)Eventual (reads)HighRead-heavy work that tolerates a lagging read model
Hybrid (sync + event)Critical path sync, side-effects asyncLow (core)High (core)MediumSimple critical ops needing immediate confirmation
Optimize asyncSpeed up the Kafka/consumer/DB pathMedium/LowHighMediumWhen near-real-time is enough
CachingIn-memory read pathVery low (hit)EventualMediumRead-heavy; needs careful invalidation
Change interaction (poll/push)Accept now, deliver laterVariableEventualMediumWhen true sync isn’t mandatory

Figure 4: The strategies and where each fits. Most systems combine the hybrid fast-path with an optimized async flow.

Why it matters: “event-driven” and “synchronous” feel like opposites, and forcing one to behave like the other (blocking on async, or polling a database to death) creates the worst of both. The mature move is to stop treating it as binary. Keep the bulk of the system async for scale and resilience. Add a synchronous fast-path, gRPC plus dual write, for the handful of flows that need immediacy. Speed up the async flow for everyone. And reshape the interaction model where a true sync answer was never really required.

Further reading