What temporal coupling means for synchronous vs asynchronous microservices
In a synchronous call, the caller sends a request, usually over HTTP or gRPC, and blocks until it gets a response or a timeout. In asynchronous messaging, the producer writes a message to a broker or message queue and moves on. A consumer processes the message later, whenever it is able to.
The key difference is temporal coupling. With synchronous calls, both services must be running and responsive at the same moment. If the downstream service is slow, the caller is slow. If it is down, the caller fails or degrades.
This compounds across a call chain. Illustrative estimate: assume a request path depends on three services, each available 99.9% of the time, and that their failures are independent. The path is available 0.999 × 0.999 × 0.999 ≈ 99.7% of the time. Over a 30-day month, that is roughly 2.2 hours of expected unavailability instead of about 43 minutes for a single dependency. Every dependency you add to the synchronous path lowers that number further.
Teams adopt queues to break this coupling for work that does not need an immediate answer. The broker absorbs the message while the consumer is down, restarting or overloaded. The consumer catches up when it recovers.
Async does not mean the client gets no response. For client-facing APIs, the request-accepted pattern handles eventual completion. The API returns HTTP 202 Accepted along with a status resource the client can poll, or it later notifies the client through a registered callback.
How does a message queue work?
A queue-based exchange has three parties: a producer, a broker and one or more consumers. The lifecycle of a single message looks like this:
- Publish. The producer serializes the message, usually as JSON, Avro or Protobuf, with headers such as a message ID, a type and a correlation ID. It sends the message to a named queue or topic on the broker.
- Persist and acknowledge. The broker stores the message. Depending on configuration, it writes the message to disk or replicates it to other broker nodes before it acknowledges the producer. Until the producer receives that acknowledgment, it cannot assume the message is safe. A producer that never gets the acknowledgment will typically retry, and that retry is one source of duplicates.
- Deliver. A consumer receives the message, either by pulling it or by having the broker push it over an open connection. The broker marks the message as in flight but does not delete it yet.
- Process and acknowledge. The consumer does its work, then acknowledges the message. Only then does the broker consider it done.
- Redeliver on failure. If the consumer crashes, loses its connection or does not acknowledge within a timeout, the broker makes the message available again. After a configured number of failed attempts, the broker or consumer moves it to a dead-letter queue.
Steps 4 and 5 are why delivery is at-least-once. A consumer can finish the side effect and crash before it acknowledges, and the broker will then deliver the same message again. The idempotency techniques later in this article exist because of that gap.
Brokers distribute messages in two main shapes:
- Point-to-point queue (competing consumers). Each message goes to exactly one of the consumers reading the queue. Adding consumer instances spreads the work and raises throughput. This fits commands and background jobs.
- Publish/subscribe topic. Each subscription receives its own copy of every message. Email, fulfillment and analytics can each read the same
OrderPlacedevent independently. This fits events.
Some brokers are built as retained logs. They keep messages for a configured retention period whether or not they have been consumed, and each consumer group tracks its own position, or offset, in the log. This allows a consumer to replay history after a bug fix. Ordering is usually guaranteed only within a partition, which is why the article later recommends partitioning by entity ID.
The decision test: does the caller need the result right now?
The rule that decides most cases: if the caller needs the response to build its own reply, call synchronously. Reads, input validation and authorization checks fall here. An order service can't tell a client "approved" until the permissions service has answered, so putting a queue between them only delays the same dependency.
If the work is a side effect the caller doesn't need to wait for, publish it asynchronously. Notifications, search indexing, analytics and downstream fulfillment steps belong here. The user's request succeeds whether or not the email goes out in the next second, so a slow or failed consumer shouldn't fail the request.
Don't use a queue for request/reply. If the caller blocks waiting for a correlated response message, it is still temporally coupled to the consumer. It also takes on broker latency, reply-queue plumbing and timeout handling that HTTP already provides. Low-volume internal calls with tight latency budgets rarely justify running a broker at all.
| Dimension | Synchronous (HTTP) | Asynchronous (queue) |
|---|---|---|
| Coupling | Caller and callee must both be up | Producer runs independently of consumer |
| Latency | Adds callee time to the request path | Request path is fast, and completion arrives later |
| Failure visibility | Immediate error to caller | Errors land in retries and dead-letter queues |
| Consistency | Read-your-writes is straightforward | Eventual consistency |
| Debuggability | One trace, linear call chain | Spans cross broker hops, so correlation IDs are required |
| Schema evolution | Versioned endpoints | Old messages persist, so consumers must tolerate past schemas |
| Rollback | Redeploy and retry the request | Bad messages already consumed may need replay or compensation |
When should you use a queue?
Apply the decision test first. If the work passes it, because the caller does not need the result, a queue is worth considering in these situations:
- The work outlasts a reasonable request timeout. Video transcoding, report generation, bulk imports and large exports can take longer than a client or load balancer will wait. Accept the request, enqueue the job and return 202 with a status resource.
- Traffic is burstier than the consumer can absorb. If producers spike during a sale or a batch upload, and the downstream service can only handle a steady rate, the queue stores the excess as a backlog. The consumer processes at its own pace instead of failing under load. This only works if the average produce rate stays below consumer capacity. Otherwise the backlog grows without limit.
- Several services react to the same fact. When one business event triggers email, indexing, analytics and fulfillment, publishing one event is simpler and less fragile than having the producer call each service in turn.
- The downstream dependency is unreliable or rate-limited. Third-party APIs, legacy systems and services with strict quotas are easier to call from a consumer that can retry, back off and pause without holding a user request open.
- Teams need to deploy independently. If a consumer's downtime or redeploy must not affect the producer's availability, the broker absorbs messages during that window.
Do not use a queue in these situations:
- The caller needs the answer to respond, as with reads, validation, authorization, or a price or stock check.
- The user must see the effect of their write immediately, and you have no plan for the read-your-writes gap.
- Traffic is low, the latency budget is tight and both services are owned by the same team. A hardened synchronous call is cheaper to build, test and operate.
- You are using the queue to hide a missing timeout or retry policy. Fix the synchronous call first, as described in the next section.
Who should use asynchronous messaging?
Queues pay off for some teams and systems more than others.
Good fits:
- Organizations with several teams owning separate services. Events let a producing team publish facts without coordinating with every consumer, and new consumers can subscribe without a change to the producer. This is where the command-versus-event distinction below matters most.
- Systems with clear side-effect workloads. E-commerce checkouts, account sign-ups that trigger onboarding emails, and data pipelines feeding search or analytics all have work that naturally happens after the main transaction.
- Workloads with uneven load. Systems that see large peaks relative to their average benefit from a buffer that turns spikes into temporary lag instead of errors.
- Teams that can operate the broker. Someone must own capacity, upgrades, retention, consumer-lag alerts, dead-letter queues and replay procedures. Without that ownership, failures accumulate quietly.
Weaker fits:
- A small team running a monolith or a handful of services. In-process calls, or a jobs table in the existing database polled by a worker, often cover background work without adding a new piece of infrastructure.
- Teams without distributed tracing or correlation IDs. Async failures surface later and in a different service. Without a way to follow a message across hops, debugging becomes slow.
- Domains that require strong, immediate consistency across services. Eventual consistency has to be designed into the UI and the business process. If the business cannot tolerate that, keep the interaction synchronous or inside one transaction.
Most real systems end up hybrid. The question is not whether a team "uses async" but which specific calls belong on each side of the boundary. The checkout example below shows one such split.
Hardening synchronous calls before reaching for a queue
Many outages blamed on synchronous calls are really missing timeouts. A client with no timeout, or with a library default measured in minutes, holds its thread, connection and memory while a slow dependency hangs. Under load those held resources pile up until the caller fails too. A queue would hide that problem, but a timeout fixes it.
Set each outbound timeout shorter than the caller's own deadline. If your handler must answer within 800 ms, a 2-second downstream timeout means you will already have failed your client before you notice. Better still, propagate the deadline: pass the remaining time budget with the request so each hop derives its timeout from what is left rather than from a fixed number. Work then stops when nobody is waiting for the answer anymore.
Retries need limits. Unbounded or immediate retries multiply traffic against a service that is already struggling, so a brief slowdown turns into a retry storm that keeps it down. Cap the attempts. Space them with exponential backoff, and add random jitter so that clients do not retry in lockstep. Only retry operations that are safe to repeat.
A circuit breaker stops calls to a dependency that keeps failing. It returns a fast error or a fallback, then sends a few trial requests to check for recovery. Bulkheads give each dependency its own connection pool or concurrency limit, so one slow service cannot consume the capacity every other call needs.
// Pseudocode: call a downstream service within the caller's deadline,
// with bounded, jittered retries. Only used for idempotent requests (a GET).
MAX_ATTEMPTS = 3 // hard cap: never retry forever
BASE_BACKOFF = 50 ms
MAX_BACKOFF = 400 ms
PER_ATTEMPT_CAP = 300 ms // no single try may use the whole budget
RESERVE = 30 ms // time kept back to build our own response
function getStock(itemId, deadline): // deadline = absolute time from our caller
for attempt in 1..MAX_ATTEMPTS:
remaining = deadline - now() - RESERVE
if remaining <= 0:
return Error("deadline exceeded") // nobody is waiting anymore; stop work
// The timeout is derived from what is left, never a fixed large default.
timeout = min(remaining, PER_ATTEMPT_CAP)
// Propagate the budget so the next hop can derive its own timeouts.
// Header name is a placeholder; use whatever your stack standardizes on.
result = inventoryClient.get("/items/" + itemId,
timeout = timeout,
headers = { "X-Request-Budget-Ms": remaining })
if result.ok:
return result
if not isRetryable(result): // e.g. 400 or 404: retrying will not help
return result
// Exponential backoff with full jitter so clients do not retry in lockstep.
backoff = min(MAX_BACKOFF, BASE_BACKOFF * 2^(attempt - 1))
sleepFor = randomBetween(0, backoff)
if now() + sleepFor >= deadline - RESERVE:
break // a retry could not finish in time
sleep(sleepFor)
return Error("inventory unavailable") // caller can serve a fallback
function isRetryable(result):
return result.isTimeout or result.isConnectionError
or result.status in [429, 502, 503, 504]
Language-neutral pseudocode for a client call that derives each attempt's timeout from the propagated deadline, passes the remaining budget downstream, and makes a capped number of retries with exponential backoff and full jitter, only on errors where a retry can help.
Commands vs events and how they shape team boundaries
Once you decide a call should go through a broker, there is a second choice: are you sending a command or publishing an event? A queued command like ReserveInventory is still a request to one specific service. The sender knows who should act and what outcome it expects. The queue removes the need for both services to be available at the same moment, but the logical coupling remains. If the inventory team renames the operation or splits it in two, the sender has to change too.
An event like OrderPlaced states a fact that the producer owns. It makes no assumption about who reacts. Fraud scoring, loyalty points or analytics can subscribe later without a ticket to the order team. That is the property that lets teams ship independently. The cost is that the producer can no longer see everyone who depends on its schema.
| Aspect | Command | Event |
|---|---|---|
| Naming | Imperative (ChargeCard) |
Past tense (PaymentCaptured) |
| Ownership | Consumer defines the contract | Producer defines the contract |
| Consumers | Exactly one | Zero to many |
| Change impact | Coordinated with one team | Affects unknown subscribers |
Schema evolution needs extra care because messages outlive deployments. A message can sit in a backlog, a dead-letter queue or a retained log, and a replay can deliver it weeks after you shipped a new schema. Consumers must therefore accept several versions at once. Producers should evolve schemas additively: add optional fields and never repurpose or remove existing ones. When a change cannot be additive, publish a new event type alongside the old one until every subscriber has migrated.
How to implement async correctly: outbox, idempotency and ordering
The naive flow, where you commit the order and then publish OrderPlaced, is a dual write with no shared transaction. If the process crashes after the commit but before the publish, the event is lost. If the publish succeeds but the acknowledgment times out and you retry, the event goes out twice. No ordering of the two steps fixes this, because the database and the broker cannot commit atomically together.
The transactional outbox removes the second write from the request path. In the same transaction that updates business state, insert a row into an outbox table holding the event type, aggregate ID, payload and a sequence number. A separate relay reads unpublished rows, publishes them and marks them sent. Because the relay can crash between publishing and marking, the outbox converts lost messages into duplicate messages. That trade is the right one, and it fits what brokers actually provide: at-least-once delivery. Do not build on end-to-end exactly-once claims. They usually hold only inside the broker's own boundary, not across your database.
Consumers therefore have to be idempotent. There are three common ways to do it:
- Dedup table: record each processed message ID in the same transaction as the side effect, using an insert that fails or no-ops on a duplicate key. If the insert finds the key already present, skip the message.
- Idempotency key: carry a caller-supplied key through to downstream APIs.
- Natural idempotency: prefer writes like "set status to SHIPPED" over "increment count."
For ordering, avoid global ordering, since it serializes all throughput. Partition by entity ID so that each order's events stay in sequence. Then guard updates with a version check: UPDATE ... SET ..., version = :v WHERE id = :id AND version < :v. When zero rows change, the message is stale or a replay, and the consumer can safely drop it.
-- PostgreSQL. Producer: business write and outbox row commit atomically.
BEGIN;
UPDATE orders SET status = 'PLACED', version = version + 1
WHERE id = 'ord-1042';
INSERT INTO outbox (aggregate_id, event_type, payload)
VALUES ('ord-1042', 'OrderPlaced',
'{"orderId":"ord-1042","version":3}'::jsonb);
COMMIT;
-- Relay: claim a batch without blocking other relay instances.
-- Publish each row, then mark it sent. A crash here causes a duplicate, not a loss.
SELECT id, aggregate_id, event_type, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED;
-- ...publish rows, keyed by aggregate_id so each order stays on one partition...
UPDATE outbox SET published_at = now() WHERE id = ANY(:published_ids);
-- Consumer: dedup and side effect share one transaction.
BEGIN;
INSERT INTO processed_messages (message_id)
VALUES ('msg-7f3a')
ON CONFLICT (message_id) DO NOTHING;
-- If this affected 0 rows, the message was already handled: ROLLBACK and ack it.
-- Version guard: apply only if this event is newer than the stored state.
UPDATE shipments
SET status = 'PLACED', version = 3
WHERE order_id = 'ord-1042'
AND version < 3;
-- 0 rows affected means the event is stale or out of order. Skip it and do not fail.
COMMIT;
PostgreSQL showing an outbox insert in the same transaction as the business update, a relay that claims unpublished rows with SKIP LOCKED, and a consumer that combines a dedup-table insert with a version-guarded update to reject duplicates and stale events.
A hybrid checkout: where the sync/async boundary sits
The checkout splits at the database commit. Everything before it decides what the user sees. Everything after it can happen later.
Browser ──HTTP──> Checkout API
│ sync: Inventory.reserve(items)
│ sync: Payments.authorize(amount)
▼
BEGIN TX
INSERT order (status = CONFIRMED)
INSERT outbox (OrderPlaced, traceparent)
COMMIT ──────> 201 Created + orderId
│
outbox relay ──> broker topic: OrderPlaced
├──> Email
├──> Fulfillment
└──> Analytics
Stock reservation and payment authorization stay synchronous because their outcomes decide the response. If either fails, the user has to change the cart or the card now. A queue would only postpone that bad news. Email, fulfillment and analytics cannot change the answer, so they hang off the event.
The boundary sits at the commit because that is the first moment the order is a durable fact. An event published before the commit can announce an order that later rolls back. Writing the outbox row in the same transaction means the order and its event exist together or not at all.
The UI shows "Order confirmed" from the 201 response. Downstream views will lag, such as the shipment tracker or the order history served from a projection. To cover that read-your-writes gap, the client renders the order from the data it already holds. Projection-backed screens show a "processing" state until the projection catches up. They should not show an empty list.
For tracing, the API stores the W3C traceparent value in the outbox row. The relay copies it into the message headers, and each consumer starts a span linked to that context. A single trace then runs from the button click to the confirmation email.
+---------+ POST /checkout +--------------+ reserve +-----------+
| Browser |------------------>| Checkout API |----------->| Inventory |
| |<------------------| | +-----------+
+---------+ 201 + orderId | | authorize +-----------+
| | |----------->| Payments |
| +--------------+ +-----------+
| |
| | one local transaction
| +-------v--------------------+
| | orders: status=CONFIRMED |
| | outbox: OrderPlaced |
| | + traceparent |
| +----------------------------+
| ====== COMMIT: sync / async boundary ======
| |
| | outbox relay copies
| v traceparent to headers
| +----------------------------+
| | broker topic: OrderPlaced |
| +----------------------------+
| | | |
| v v v
| +-------+ +-------------+ +-----------+
| | Email | | Fulfillment | | Analytics |
| +-------+ +-------------+ +-----------+
| | spans join same trace
| reads may lag: show v
+------------------------------> +----------------------+
"processing" until caught up | history / shipment |
+----------------------+
Checkout flow: inventory and payment calls stay synchronous, the commit writes the order and outbox row together, and the relay fans OrderPlaced out with traceparent in the message headers.
Operating queues and the impact on cost and engineering time
That trace is also what you reach for when the queue itself misbehaves. The broker is now critical infrastructure. It needs capacity planning, upgrades, and retention limits sized to your longest plausible consumer outage. Someone also has to understand how it fails. If every service publishes through one cluster, you have created a new single point of failure.
Async also hides breakage. A crashed consumer returns no errors; it builds a backlog. Make consumer lag, measured as the age of the oldest unprocessed message, the primary health alert.
Illustrative estimate (assumed numbers, not a benchmark): drain time = backlog ÷ (consume rate − produce rate). Assume a 2-hour outage at 50 msg/s, which leaves 360,000 messages. Consumers recover at 150 msg/s while traffic continues at 50 msg/s. That gives 360,000 ÷ 100 = 3,600 s, or one more hour of stale data.
When lag grows, apply backpressure. Cap in-flight work and throttle producers so retention never expires messages silently. Cap redelivery attempts so a poison message moves to a dead-letter queue instead of looping forever. Give that queue a named owning team and a written replay procedure.
Async saves engineering time when the work is a true side effect. Sync remains cheaper to reason about, test and roll back.
| Sync HTTP | Queue | |
|---|---|---|
| Deploy coupling | Callee must be up | Independent deploys |
| Load spikes | Caller sees errors | Absorbed as lag |
| Testing and rollback | Simple | Must handle in-flight messages |
| Failure visibility | Immediate | Delayed |
Before adding a queue
- Is the work a side effect the caller never waits on?
- Who owns the broker and the dead-letter queue?
- What lag value pages someone?
- How many redeliveries happen before a message is dead-lettered?
- Can consumers replay messages idempotently?
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to synchronous vs asynchronous microservices, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.