Integration concepts · 9 min read

Sync, async, or callback? Choosing the right integration style for slow systems

By the MulePrep team · Updated June 2026

Synchronous

  • Caller waits for the response
  • Simple, immediate result and errors
  • Couples caller availability to the callee

Asynchronous

  • Caller continues; work happens later
  • Resilient to spikes and slow downstreams
  • Needs queues, retries, and idempotency

Most "sync vs async" advice gets stuck on the wrong layer. It talks about non-blocking code, reactive threads, and async/await, when the real question is architectural: does the caller need the answer before it can continue? That single question, not your threading model, decides whether an integration is request/reply, fire-and-forget, or a deferred result you fetch later.

This guide builds the decision from first principles. We will name the three shapes an integration can take, give you a decision table to pick one, and then dig into the sharpest trap in the field: a synchronous caller waiting on a backend that takes minutes. The fix is the 202 Accepted plus polling pattern, and once you see why, the rest of the async toolkit (queues, webhooks, callbacks) falls into place. Examples are vendor-neutral, anchored with Mule equivalents where it helps.

The three shapes: request/reply, fire-and-forget, request-acknowledge + callback

Every integration between two systems is one of three communication shapes. Learn the shapes, not the keywords.

Request/reply (synchronous). The caller sends a request and blocks until it gets the answer. An HTTP GET /orders/42 that returns the order in the response is the canonical example. The caller's next line of code depends on the result, so it waits. This is the simplest shape: one round trip, immediate result, immediate errors. In Mule, an HTTP Request inside a flow that returns a payload to its listener is request/reply.

Fire-and-forget (asynchronous, no answer needed). The caller hands off a message and moves on without waiting for any result. "Record this audit event", "send this email", "publish that an order was placed". The caller does not need a return value and does not want to be coupled to whether the downstream is even up right now. In Mule this is publishing to a VM queue (in-app, between flows) or Anypoint MQ (across apps) and returning immediately.

Request-acknowledge plus callback (asynchronous, answer needed later). The caller does need an answer, but not now. The server accepts the work, acknowledges receipt immediately, and the result arrives later through a separate channel: the caller polls a status URL, or the server calls a webhook, or it drops a message on a reply queue. This is the shape that trips people up, because the caller wants a result yet must not block waiting for it.

Producer

Emits an event

Broker / queue

Buffers and routes

Consumer

Reacts independently

The third shape is where most of the design work lives. The first two are easy calls. So the practical skill is recognising when you are actually in the third shape and stopping yourself from forcing it into the first.

Does the caller need the answer before continuing? A decision guide

Ask the questions in order. The first "yes" picks your shape.

QuestionIf yesShapeMule building block
Does the caller need the result to do its very next step, AND the work finishes in well under the request timeout?Request/replySynchronousHTTP Request, sub-flow flow-ref
Does the caller never need a result (it just needs the work to happen eventually)?Fire-and-forgetAsynchronousVM queue, Anypoint MQ publish
Does the caller need a result, but the work may take longer than a safe request timeout?Request-acknowledge + callbackAsynchronousReturn 202 Accepted, then poll / webhook / reply queue

Two cross-cutting questions sharpen the choice:

  • What is the realistic worst-case duration of the downstream work? Not the median. A report that usually renders in two seconds but occasionally takes four minutes is an async problem, because you must design for the four minutes.
  • How tightly do you want the two systems' availability coupled? Synchronous request/reply means the caller fails when the callee is down. Async with a durable queue means the caller succeeds and the work waits for the callee to recover.

The trap is treating duration as a code-performance problem ("make the thread non-blocking") when it is a contract problem ("the caller should not be promised a result on this connection").

Why you must not block a synchronous caller on a multi-minute backend

Holding an HTTP connection open for minutes is not merely slow. It actively breaks in ways that are hard to diagnose, because the failure usually appears on a different machine than the one doing the work.

Connection-pool exhaustion. Every in-flight request consumes a connection (and a worker thread) on the server, and a slot in the client's connection pool. If a backend call takes three minutes and requests arrive steadily, you accumulate hundreds of parked connections. Once the pool is exhausted, new and fast requests start failing too. One slow endpoint takes down the whole app.

Idle and read timeouts you do not control. Between caller and backend sit load balancers, reverse proxies, and API gateways, each with its own idle timeout, often 30 to 120 seconds. When that timeout fires, the intermediary returns a 504 Gateway Timeout to the client while your backend is still happily processing. The client now believes the request failed; the backend does not know the client left. You have produced an outcome where the work both succeeded and "failed".

Duplicate work from client retries. A client that receives a 504 (or just gives up) will typically retry. Because the original request is still running, the retry starts a second copy of the same expensive operation. Without an idempotency key, you have now charged the card twice or created two orders. This is exactly why long-running synchronous calls and idempotency are inseparable topics.

A tempting non-fix: wrap the slow call in a retry scope like Mule's Until Successful. That does not help here, and understanding why is the crux of the whole post. Until Successful is a synchronous, blocking retry: it re-runs its processors in place and the flow does not advance until they succeed or retries are exhausted. So it keeps the caller's connection open for the entire retry window, making the timeout and pool problems worse, not better. Retry-in-flow is still synchronous. The cure is to stop holding the connection at all.

The 202-Accepted + polling pattern as the async default

When the caller needs a result but the work is too slow to return on one connection, the standard answer is HTTP 202 Accepted. It means "I have accepted your request for processing, but it is not done yet." The connection closes immediately; the work continues out of band.

The full shape, which is the load-bearing part most descriptions skip:

  1. Client sends POST /reports with the job parameters.
  2. Server enqueues the work (VM queue or Anypoint MQ), then returns 202 Accepted with a Location header pointing to a status resource, for example Location: /reports/jobs/9f3c. The body can echo the job id and initial status.
  3. Client polls GET /reports/jobs/9f3c. While the job runs, this returns 200 OK with a body like { "status": "running" }, ideally including a Retry-After header so the client knows how long to wait before polling again.
  4. When the job finishes, the status resource returns the result, either 200 OK with the result body, or 303 See Other redirecting to a separate result resource such as /reports/9f3c/result.
POST /reports HTTP/1.1

HTTP/1.1 202 Accepted
Location: /reports/jobs/9f3c
Retry-After: 5
{ "id": "9f3c", "status": "queued" }

The win is that no single connection lives longer than a fast poll. The backend can take ten minutes and nothing times out, because the client is making short GET calls, not holding one long POST. Your status resource also gives you a natural place to expose progress and a stable job identity, which doubles as the idempotency anchor: a client retrying the original POST with the same key gets the same job back instead of starting a new one.

Polling vs webhook vs message queue for a delayed result

202 Accepted defines the handshake. It does not dictate how the result eventually reaches the caller. There are three delivery mechanisms, and they trade simplicity against efficiency.

MechanismHow the result arrivesBest whenCost
PollingClient repeatedly GETs the status URLCaller is a client that cannot accept inbound calls; simplest to operateWasted requests; result latency bounded by poll interval
WebhookServer POSTs the result to a caller-supplied callback URLCaller is itself a reachable service; you want push, low latencyCaller must expose an endpoint, authenticate it, and tolerate redelivery
Message queueResult published to a reply queue the caller consumesBoth sides are backend services; you want durability and decouplingNeeds a broker (Anypoint MQ); both sides handle async messaging

Polling is the most robust default because it makes no demand on the caller's network position. A browser or a partner behind a firewall can poll; it cannot easily receive a webhook. The downside is efficiency: you either poll too often (waste) or too rarely (stale results). Honor Retry-After to split the difference.

Webhooks invert the direction: the server pushes when ready, so latency is near-zero and there are no wasted calls. The price is that the caller now runs a server. It must expose a public, authenticated endpoint, verify the payload signature, and accept that the same event may be delivered more than once, which again lands you on idempotency.

Message queues (Anypoint MQ for cross-app, a VM queue for in-app) are the right call when both sides are services you control. The result lands on a durable queue; the consumer processes it when ready; neither side has to be up at the same instant. This is the most decoupled option and the most operationally involved.

Coupling and failure isolation: what async buys and what it costs

The reason to reach for async is not "performance". It is coupling and failure isolation. Synchronous request/reply binds the caller's fate to the callee: if the downstream is slow, the caller is slow; if it is down, the caller fails. A durable queue between them severs that link. The caller publishes, gets its acknowledgement, and moves on; the message waits in the broker until the consumer recovers. A backend outage becomes a growing queue depth instead of a wave of caller-side 500s.

That isolation is the genuine prize, and it is why async is the correct default for slow or unreliable downstreams. But it is not free, and the costs are real:

  • You inherit at-least-once delivery. Durable messaging brokers redeliver on failure, so a consumer can see the same message twice. Every async consumer must be idempotent, or duplicates corrupt your data.
  • You lose the easy error path. In request/reply, an exception flows straight back to the caller. In async, a failure happens with no caller listening, so you need dead-letter queues, retry-with-backoff, and alerting to avoid silently dropping work.
  • Debugging spans systems. A single logical operation is now split across a producer, a broker, and a consumer running at different times. Without a correlation id threaded through every hop, tracing one request becomes guesswork.
  • You added moving parts. A broker, a status resource, or a webhook receiver is more infrastructure to run, secure, and monitor than a single synchronous call.

So the honest rule is this. Default to synchronous request/reply, because it is simpler in every dimension, until the work is too slow to return safely on one connection or the coupling to an unreliable downstream is unacceptable. When you cross that line, do not paper over it with a longer timeout or an in-flow retry. Switch shapes: return 202, offload to a queue, and deliver the result by polling, webhook, or reply queue. Want to pressure-test whether you can spot which shape a scenario needs? The free 10-question demo has integration-design questions in exactly this vein.

Frequently asked questions

When should an integration be asynchronous instead of synchronous?
When the caller does not need the result on its next step, or when the work can take longer than a safe request timeout, or when you want to decouple the caller from an unreliable downstream. If holding the connection risks pool exhaustion or gateway timeouts, go asynchronous and return the result later.
What is the 202 Accepted pattern and when do I use it?
HTTP 202 Accepted means the server took the request but has not finished it. The server returns 202 with a Location header pointing to a status resource, then the client polls that URL until it returns the result. Use it when a caller needs an answer but the work is too slow to return on one connection.
Is asynchronous the same as non-blocking?
No. Non-blocking is a threading concern: not tying up a thread while waiting for IO. Asynchronous integration is whether the caller waits for the result at all. A non-blocking HTTP client still gives request/reply semantics. You can be non-blocking and synchronous; the two ideas are orthogonal.
How do I return a result from a long-running async process?
Three options after a 202 acknowledgement: the client polls a status URL, the server posts the result to a caller-supplied webhook, or the result lands on a reply queue the caller consumes. Polling is simplest and demands nothing of the caller; webhooks and queues push the result with lower latency but more setup.
What are the downsides of asynchronous integration?
You inherit at-least-once delivery, so consumers must be idempotent. Errors happen with no caller listening, so you need dead-letter queues and alerting. A single operation spans producer, broker, and consumer, so you need a correlation id to trace it. And a broker or status resource is more infrastructure to run and secure.

Independent study resource - not affiliated with, endorsed by, or connected to MuleSoft or Salesforce; their trademarks belong to their owners. All practice questions are original.