Software Architecture · Distributed Architecture
Temporal Coupling Between Services
What a synchronous call forces on two services, how message passing changes the coupling without removing it, and where each style breaks under load.
Two services are temporally coupled when one cannot make progress without the other being available at the same instant. A synchronous HTTP or RPC call establishes that relationship by construction: the caller holds a connection, a thread, and a unit of user-visible latency open until the callee answers. The dependency exists for the duration of the call, and so does the availability product that comes with it.
The alternative, sending a message and continuing, removes the simultaneity but not the dependency. It replaces “the callee must be up now” with “the callee must eventually consume this”, and that trade moves the failure from the request path into a queue, where it appears as lag rather than as an error.
The call that has to succeed now
A synchronous edge is the strongest coupling available short of a shared database. The caller’s latency includes the callee’s. The caller’s failure rate includes the callee’s. A change in the callee’s latency distribution is a change in the caller’s, with no code change on either side.
The cost is visible in the arithmetic of round trips. An in-process function call takes nanoseconds to a few microseconds. A call to another process on the same host adds serialisation and a context switch. A call across a datacenter adds a network round trip of a few hundred microseconds to a couple of milliseconds on the same continent, and light in fibre covers roughly 200 km per millisecond, so a 10,000 km cross-region round trip has a hard floor near 100 ms before any processing happens.
Eight serial remote calls at 1 ms each add 8 ms to a request. The same eight calls across regions add 800 ms, which exceeds the entire latency budget for most interactive operations. That difference, not any difference in processing power, is why cross-region calls are reserved for data that is genuinely regional.
A design where the service topology mirrors the in-process object graph is the signature of a distributed monolith. The classic description of the problem is in A Note on Distributed Computing: the failure modes, latency, and memory access semantics of a remote call differ from a local one in ways that cannot be hidden behind an interface that pretends otherwise. Code written against a “transparent” remote interface has already made assumptions that the network will violate.
What a message actually buys
Publishing an event and continuing replaces a synchronously required dependency with an asynchronous one. The producer’s request completes when the broker has durably accepted the message, so the consumer’s availability no longer appears in the producer’s availability product. Temporal decoupling of this kind is the main architectural reason to introduce a broker.
The durability terms differ between brokers, but the shape is common: the message survives producer failure once acknowledged under the broker’s configured acknowledgement mode, and survives broker failure only if it has been replicated to enough nodes. An acknowledgement that comes before replication buys latency with a data-loss window, and that window is the operational parameter that matters most.
What a message does not buy is correctness across the boundary. Delivery is at-least-once in most practical configurations, so consumers see duplicates, and every consumer must be written to tolerate them. Ordering is usually preserved within a partition or a key, not across a topic, so any consumer that assumes global order is relying on a property the broker never offered. Both facts push work back onto the consumer: deduplication by a business key and partitioning by an entity identifier that needs ordering.
The other cost is diagnosis. A synchronous failure produces an error with a stack trace at a known point in a request. An asynchronous failure produces a growing lag number and a consumer that is happily processing messages from ten minutes ago. Correlation identifiers carried in message headers help, but the observable behaviour is a delay rather than an exception, and delay is much harder to alert on without a defined freshness target.
// Synchronous: the caller's latency and failure rate include the callee's.
ctx, cancel := context.WithTimeout(r.Context(), 250*time.Millisecond)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, priceURL, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
// Degrade rather than fail: the price is optional for this page.
price = cachedPrice
} else {
defer resp.Body.Close()
decode(resp.Body, &price)
}
Compressing round trips at the interface
The most effective way to reduce temporal coupling is to need fewer calls. Interface shape determines the number, and the shape is chosen long before any latency problem is visible.
A fine-grained interface exposes one operation per entity: fetch the customer, fetch their orders, fetch the status of each order. A screen built on that interface issues one call plus N, and N grows with the user’s data. A coarse-grained interface exposes the question the screen actually asks: fetch the order summary for this customer, with the statuses joined in the owning service. The second is less reusable in principle and far cheaper in practice, because the join happens where the data lives rather than across the network.
Batching helps when the call pattern is inherently per-item. Sending 100 identifiers in one request instead of 100 requests removes 99 round trips, and the cost is a larger payload and a partial-failure response shape that has to represent “these three items do not exist” without failing the whole batch.
The measurable quantity is round trips per user action. A read path with more than about three serial round trips is usually a decomposition problem rather than an optimisation problem, and CQRS-style read models exist largely to turn a multi-service join into one local read. Read Models Across Service Boundaries covers that option.
| Property | Synchronous call | Message |
|---|---|---|
| Failure visibility | Immediate error at the caller | Lag or dead-letter, later |
| Latency contribution | Full callee latency | Broker append only |
| Ordering | Per call, as issued | Per partition, if keyed |
| Duplicates | Only with retries | Routine, at-least-once |
| Backpressure | Pool and connection limits | None unless enforced |
When the queue becomes the bottleneck
A broker absorbs bursts, which is its main operational benefit and its main hazard. A synchronous system applies backpressure automatically: when the callee’s connection pool is full, callers block or fail, and the load offered to the callee stops growing. A queue does the opposite. Producers write as fast as the broker accepts, the backlog grows without bound, and the consumer falls further behind while every dashboard shows healthy producers and healthy consumers.
The queue’s health metric is therefore lag, expressed in time rather than messages: how old is the oldest unconsumed message, and how does that compare with what the business can tolerate. A lag of 10,000 messages is meaningless without a consumption rate; a lag of four minutes is a statement about freshness. Once lag exceeds the tolerance, the correct response is usually to shed or coalesce work at the producer, not to add consumers, because a consumer that is slow for a systemic reason will be slow at any replica count.
Unbounded queues also hide the need for admission control at the producer. If a user action enqueues work that takes minutes to complete, the user has already been told the action succeeded while the work has not happened. The message boundary has moved the failure, and the product promise has to move with it.
Choosing per interaction
The choice is made per interaction, not per system, and the deciding question is what the caller can do with the answer.
When the caller needs the result to produce its own response, a synchronous call is correct, and the design work is to bound it: a timeout shorter than the caller’s remaining budget, a fallback for the optional parts, and a rejection path for the overloaded callee. Deadline Propagation Across Service Hops treats the budget side.
When the caller only needs the effect to happen eventually, a message is correct, and the design work moves to the consumer: idempotency, ordering by key, lag targets, and a dead-letter path for messages that cannot be processed. Idempotency Keys and Duplicate Suppression at Service Boundaries covers the duplicate side.
The failure mode to avoid is the hybrid, where a synchronous call is wrapped in a retry loop and placed behind a queue that the caller then polls for a result. That arrangement has the latency of both styles, the failure modes of both, and a correlation table that has to be cleaned up when callers go away.
References
- A Note on Distributed Computing — Waldo, Wyant, Wollrath, Kendall, Sun Microsystems Laboratories, 1994
- Kafka: a Distributed Messaging System for Log Processing — Kreps, Narkhede, Rao, NetDB 2011
- Site Reliability Engineering, Chapter 21: Handling Overload — O'Reilly Media, 2016