Skip to article content
AAverho

Reliable distributed workflows

Synchronous request-response and its limits

Synchronous request-response and its limits sits in the "Reliable distributed workflows" path, whose aim is this: build workflows that survive retries, partial failures, and reordered messages. Within that path, this topic covers one question in particular — when a workflow step should wait for a synchronous dependency result, when it should return an accepted/pending outcome instead, and when it should hand the work off asynchronously.

20 min read9 sectionsPractice & review available

The problem

A synchronous call makes the caller inherit the callee's latency, availability, and capacity limits for the whole wait — and a per-hop timeout that resets at every layer can let a request run long after the caller has already given up.

Contents

Overview

Use a synchronous call only when the result is needed right now, the complete required path fits inside a bounded deadline, and you can state a truthful outcome even when the wait fails. Everything else — work that can finish later, dependencies that are merely helpful, or an effect that cannot be resolved safely after a timeout — belongs on an asynchronous or accepted/pending path instead.

01

Core model

Definition

Synchronous request-response

A caller sends a request and keeps its own operation open while it waits for a correlated response, before it can advance or reply to its own client.

Definition

Deadline

The end-to-end time after which a result has no value to the operation that started it. A deadline should be propagated from the top of a call chain, not reset at each hop.

A synchronous call means the caller's path inherits the callee's latency, availability, and capacity constraints for the entire wait. This is not a detail to manage away — it is the defining cost of the pattern. Treat a request path as a budget, not a set of independent calls: the ingress deadline is divided among local work, downstream calls, network overhead, and a margin for building the response. A downstream call may spend only its assigned share of what remains.

  • Dependency depth — each additional required, sequential call adds another chance that the whole path is slow or unavailable. End-to-end availability is bounded by the composition of every required dependency, not just the least reliable one.
  • Fan-out and join — calling several dependencies in parallel lowers median latency versus calling them one after another, but a join that requires all of them still waits for the slowest required branch, and fan-out raises downstream load per request.
  • Tail latency — a dependency that is usually fast but occasionally slow can dominate the percentile a user actually experiences. At a required join, the slowest branch decides completion, no matter how fast the others were.
  • Cancellation — telling downstream work to stop when its result is no longer useful is best-effort. It is not evidence the work did not happen.
  • Saturation — connections, worker slots, and database sessions are finite. A slow call holds them longer, which queues more work behind it and makes the next call slower too — a feedback loop, not an isolated delay.
Decision map: is a downstream result required for the immediate user-visible decision? If not, persist intent and return accepted. If yes, can it return within a bounded deadline with acceptable availability and an unambiguous failure policy? If not, redesign the boundary. If yes, make a synchronous call with a propagated deadline, bounded attempts, cancellation, and explicit result states.
The question is never 'can I make a synchronous call here' — it's whether the result is actually required now, and whether the full required path fits inside a budget you can state and defend.

Starting from a user action: first ask whether a downstream result is required to make the immediate user-visible decision. If no, persist intent, hand the work off asynchronously, and return an accepted response with a status path. If yes, ask whether the dependency can return within a bounded end-to-end deadline with acceptable availability and an unambiguous failure policy. If no, redesign the boundary — precompute, cache, degrade, or make the whole step pending. If yes, make the synchronous call, but only with a propagated deadline, bounded attempts, cancellation, and explicit result states for success, known failure, and ambiguous outcome.

A timeout is not the same thing as a deadline. A deadline is the end-to-end latest useful completion time for the whole operation; a timeout is a local limit on one particular wait, and it must fit inside whatever remains of that deadline. Independent, generously-set per-hop timeouts can add up to far more than the caller's actual budget, leaving downstream work running long after the client that asked for it has already given up and moved on.

A timeout does not mean the operation failed. It means the caller stopped waiting. The callee may have completed the work, may still complete it, or may never have received the request at all — "I did not receive success" is not evidence that "the action did not happen." Any side-effecting call that can time out needs a way to resolve that ambiguity afterward — an idempotency key, a durable status the caller can check — rather than a guess.

02

When to use it

A synchronous call is the right tool when several of these hold:

  • The result is genuinely required to make the immediate, user-visible decision — not merely convenient to have sooner.
  • Interactive validation or an authorization/policy check must complete before an irreversible next step proceeds.
  • The read is small and freshness truly matters for that specific decision, rather than being a default habit.
  • A bounded command needs a definitive accept/reject answer the caller can act on immediately.
  • The dependency has a clear owner, a known SLO, a bounded payload, and defined response semantics for both success and failure.

03

What it costs

Every synchronous dependency a service adds is coupling it imports into its own request path, not just a function call:

  • Inherited availability. The caller can only be as available as the composition of every dependency it synchronously requires.
  • Inherited tail latency. A caller's p99 tracks whichever required dependency has the worst tail, even if that dependency is fast on average.
  • Deployment and schema coordination. A breaking change on either side of a synchronous contract needs coordinated rollout, not independent release cadence.
  • Held capacity. Connections, worker threads, and database sessions stay occupied for the whole wait, which is exactly the resource a burst of slow requests exhausts first.
  • Retry amplification. If the client, a gateway, and the service itself each retry independently, one user request can become many downstream attempts precisely when the dependency has the least spare capacity.
  • Cross-team on-call exposure. A synchronous call makes another team's incident your incident, the moment their dependency is on your critical path.

04

When not to use it

Move a step off the synchronous path when:

  • The work can genuinely take seconds or minutes — document generation, provisioning, exports, settlement.
  • The only dependency is an external provider with unpredictable latency, and the user does not need that provider's final answer before continuing.
  • The result is nonessential enrichment — recommendations, analytics, notifications — rather than something the immediate decision depends on.
  • The action is side-effecting and a timeout cannot be resolved safely through idempotency or a durable status lookup.
  • Expected bursts would exhaust connection pools, worker slots, or downstream quotas while requests sit waiting.

The usual alternatives — an accepted/pending response backed by asynchronous processing, a queue handoff, client polling of a status resource, or a webhook notification — all share one property this article assumes rather than re-explains: work is durably recorded before the request returns, using the queue and outbox patterns already covered. What differs here is only the contract the caller sees: an immediate, truthful "accepted," not a final result.

05

Practical example: merchant payout request

A payment platform lets merchants request a payout of their available balance. The merchant dashboard needs an immediate answer about whether the *request* was accepted; actual payout execution goes through a bank-facing provider and can take minutes or hours. POST /payouts has a 1.5-second end-to-end target.

Flow:

  1. The client sends POST /payouts with an idempotency key. The API derives an ingress deadline from the 1.5-second budget, reserving a fixed margin to serialize and return the response.
  2. It checks for an existing idempotency record first. If one exists, it returns the durable prior result rather than starting new work — a double-click or client retry must not reserve funds twice.
  3. It calls ledger reservation — checking account status and reserving the requested amount — with a 250 ms child budget, well inside the remaining deadline and with no independent timeout reset at that boundary. This call is required: the payout cannot be accepted without a real reservation.
  4. In parallel, it starts an optional risk-explanation lookup, owned by another team, with a short 100–150 ms cancellable budget. This call is not required — it only supplies dashboard context.
  1. If the ledger reservation succeeds, the handler writes the payout as accepted and an outbox intent to trigger downstream bank submission — in one local transaction, using the transactional-outbox pattern already covered rather than re-explained here. The API never calls the bank provider from the request path: bank submission can take seconds to hours and is not required for the caller's immediate decision.
  2. The response means exactly one thing: the ledger reservation and payout intent are durably recorded. It does not mean money has reached the bank account. A separate status endpoint later reports accepted, submitting, submitted, or failed as the asynchronous worker advances the payout.

Classifying failure on the ledger call matters as much as the happy path. A retryable failure — a transport error or a 5xx before any durable acceptance — can be retried once, with a small bounded delay, only if enough of the remaining deadline is left to fit both the retry and the response margin. A permanent failure — the ledger definitively rejects, insufficient funds — returns a final, stable rejection immediately; retrying it wastes the budget on an answer that will not change. An outcome-unknown failure — the ledger call times out and the API cannot tell whether the reservation actually committed — must not be blindly retried as if it were a fresh attempt: the API resolves it through the idempotency record or a status lookup, the same way the payout worker resolves an ambiguous bank submission later, never by guessing that "no response" means "nothing happened."

That single retryable ledger attempt still needs backoff and jitter, at a much smaller scale than a background worker's: backoff is the delay itself — even one bounded retry should not fire immediately back-to-back with the failed attempt, in case the failure was a brief saturation spike — and jitter randomizes that delay so that many concurrent payout requests hitting the same transient ledger blip do not all retry in the same instant and recreate the spike they were reacting to. On a 250ms child budget there is only room for a tiny fixed-plus-jitter delay, not the multi-second exponential backoff a relay or background worker could afford — the distinction is the same, the numbers are just much smaller because the whole retry has to fit inside a deadline measured in milliseconds, not minutes.

`202 Accepted` is not a reliability strategy by itself. It only changes the contract — the reliability comes from the durable record behind it, the status the client can check, and the idempotency key that makes a retry safe.

06

Trade-offs across communication choices

ApproachBest fitDangerous misuse
Single bounded synchronous callAuthorization, validation, small reads where freshness genuinely mattersUsing it for slow, bursty, or externally unreliable work because it was the easiest thing to write
Sequential synchronous chainRare cases with a few tightly bounded, required internal decisionsService A calling B calling C calling D for one user request, each with its own independent timeout and retries
Parallel synchronous fan-out with required joinA small, bounded set of independent required lookupsFanning out to many services per request with no concurrency cap, so the join waits on whichever one is having a bad day
Synchronous core plus degraded optional enrichmentRecommendations, profile details, or hints that do not determine acceptanceQuietly omitting required policy, price, or entitlement data and presenting the result as final
Accepted/pending response plus asynchronous processingPayout execution, provisioning, document processing, and other durable side effectsReturning `202 Accepted` with no status resource, no notification, and no way to learn the final result

07

Common failure modes and misconceptions

08

Checklist

Before adding or keeping a synchronous call on a request path, confirm:

  • The result is actually required for the immediate decision, not merely convenient to have now.
  • An end-to-end deadline is propagated from the top of the call chain, and each per-hop timeout fits inside what remains of it.
  • Retryable, permanent, and outcome-unknown failures are classified separately, with retries bounded by the remaining deadline.
  • Every side-effecting call that can time out has an idempotency key or a durable status the caller can resolve against.
  • Optional dependencies are parallelized with a short, cancellable budget and degrade truthfully rather than blocking the response.
  • Fan-out to required dependencies is bounded and explicit, not open-ended.
  • The response contract distinguishes accepted from completed wherever the two are not the same moment.
  • Connection, worker, and session limits are sized for the worst case this call's wait can create under saturation.
  • The dependency has a named owner, a known SLO, and an explicit fallback when it is unavailable.

08

Key takeaways

Where synchronous calls help and where they create fragile coupling.

  • A synchronous call means the caller inherits the callee's latency, availability, and capacity limits for the whole wait — that is the cost, not a side effect of it.
  • A deadline is propagated once, end to end; a timeout is a local wait that must fit inside whatever of that deadline remains.
  • A timeout proves the caller stopped waiting, not that the callee's work did not happen — resolve ambiguity through idempotency and durable status, never by guessing.
  • When a result is not required for the immediate decision, an accepted/pending response backed by durable, asynchronous work is safer than forcing a wait that cannot be justified.

Practice

Test what you’ve learned

Work through guided Synchronous request-response and its limits decision scenarios. Every choice becomes a mentoring conversation, so you find out where your reasoning holds up.

Practice

Review

Revisit real examples

A calm, five-minute pass back through the concrete Synchronous request-response and its limits examples in this guide, before they fade.

Review