Ballerina Sync vs Async Service Calls: A Decision Guide for API Integrations
A decision guide to blocking versus future-based calls in Ballerina integrations, with a start/await example and a way to verify the latency change.
10 Jan 2026, 13:53 UTC

What you are actually deciding
In a Ballerina integration service, every outbound call to another HTTP endpoint occupies a strand (Ballerina's unit of concurrent execution, scheduled onto a thread pool) until it completes. The decision is whether the code that follows should wait for that call before continuing, or hold a future handle and resolve it later. The choice is not stylistic: it changes how many in-flight requests a single service instance can sustain when a downstream dependency slows down.
The short version: use a blocking call when the next statement needs the result; use start plus await when two or more calls are independent and their latencies would otherwise add up.
Constraints that should drive the choice
- Dependency shape. If call B needs a value produced by call A, concurrency is impossible without restructuring the logic.
- Latency budget. Sequential calls add up; concurrent calls overlap, so total wait approaches the slowest call rather than the sum.
- Failure granularity. With concurrent calls, one failure can abort the whole response unless each future is resolved and handled separately.
- Runtime version. The
futuretype,start,await, and the strand model described here reflect Ballerina Swan Lake. Confirm syntax and timeout configuration against the documentation for the distribution you deploy.
Comparing the two invocation styles
| Aspect | Blocking call | start + await |
|---|---|---|
| Execution flow | Linear; next statement runs after the response | Returns a future immediately; result collected later |
| Strand usage | Held for the duration of the call | Released while the call is in flight |
| Readability | Easy to trace top to bottom | Requires tracking where each future is resolved |
| Error visibility | Error surfaces at the call site | Error surfaces at the await |
| Best fit | Strictly dependent steps | Independent calls, fan-out patterns |
Where the trade-offs bite in practice
Awaiting inside a loop
Writing start inside a loop and then await-ing the future on the next iteration gives you the syntax of concurrency and the timing of sequential execution. Collect the futures in an array first, then await them after the loop finishes.
Futures that are never resolved
A future that is created and never awaited still holds its result or error until it is collected. If a downstream call hangs and the client has no timeout configured, the future stays pending. Set an explicit timeout on the http:Client configuration rather than relying on defaults.
Error semantics
An error raised inside an asynchronously started function is captured in the future and surfaces when that future is awaited, not at the point of the start. A try/catch wrapped around the start statement will not see it; handle errors where you resolve the future.
A concrete example: two independent lookups
Assume a summary endpoint that needs a profile and an order list from two separate services, and neither result depends on the other. Both clients are configured with timeouts.
import ballerina/http;
http:Client userClient = check new ("http://user-service:9090",
timeout = 2.0);
http:Client orderClient = check new ("http://order-service:9091",
timeout = 2.0);
function fetchProfile(string userId) returns json|error {
http:Response res = check userClient->get("/profiles/" + userId);
return res.getJson();
}
function fetchOrders(string userId) returns json|error {
http:Response res = check orderClient->get("/orders?user=" + userId);
return res.getJson();
}
service /summary on new http:Listener(8080) {
resource function get user(string userId) returns json|error {
future<json|error> profileFuture = start fetchProfile(userId);
future<json|error> ordersFuture = start fetchOrders(userId);
json|error profile = check await profileFuture;
json|error orders = check await ordersFuture;
return {profile: profile, orders: orders};
}
}
The sequential equivalent replaces the two start lines with direct calls and drops the await lines. Everything else stays the same, which makes the two versions easy to compare in a test.
Where to run it: bal run from the project directory, with the Ballerina distribution installed. The process needs outbound network access to the two client hosts and permission to bind port 8080.
Placeholders: user-service:9090 and order-service:9091 stand in for your real endpoints, and the JSON shapes returned by getJson() depend on those services.
Note on check await: the exact interaction between check and await should be confirmed against the language specification for your version. If in doubt, assign the awaited value to a variable and match on the error explicitly.
Verifying that concurrency actually helped
- Stand up two mock listeners that sleep for a fixed delay, for example 500 ms, before responding.
- Point both clients at the mocks.
- Call the endpoint with
curl -w '%{time_total}\n' -o /dev/null -s http://localhost:8080/summary/user/u1. - Repeat against the sequential version of the resource function.
If each mock delays 500 ms, the expectation is roughly 500 ms for the concurrent version and roughly 1000 ms for the sequential one. Treat those as expectations to measure, not as measured results: connection setup, TLS, and serialization add overhead.
For a repeatable check, use the built-in test framework. Start both mocks in test setup, invoke the resource function, and assert on the returned JSON. A test that only asserts the payload is correct will not catch a concurrency regression, so pair it with a timing assertion or a mock that records the order in which requests arrived.
Limitations
- Futures are scoped to the request that created them. They are not a substitute for a message queue for work that must survive a restart.
- Concurrent flows are harder to trace; log a correlation identifier at the
startand at theawaitif you need to reconstruct ordering. - Concurrency does not reduce load on the downstream service; it raises peak concurrent load. Confirm the downstream can absorb it.
- If the two calls share a connection pool or a rate limit, the gain may be smaller than the latency arithmetic suggests.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.