Scatter-gather
The inventory service answered one request with one reply, which is the common case of a single question producing a single answer.
Some questions have several answers. "What would it cost to ship this order?" is one of them. Acme works with three carriers, and each one quotes a different price. The order service wants all three quotes, then picks the cheapest.
That's scatter-gather: fan one request out to every responder, then
gather the replies that come back. This page builds it on shipping.quote
with three quote providers.
Why one request can produce many replies
Recall how request-reply works: the client subscribes to a unique
_INBOX subject and publishes the request carrying that inbox as its
reply subject. Nothing in that mechanism limits the number of responders.
If three providers subscribe to shipping.quote, all three receive the
request (plain publish-subscribe) and all three can reply to the inbox.
The single reply you saw earlier was just one responder plus a client
that stopped after the first answer.
This works only when the responders are not in a queue group — a queue group hands each request to one member, which is load balancing, not scatter-gather. Every responder must subscribe plainly. The CLI demo below makes that explicit, because the CLI's default does the opposite.
Set up three quote providers
A provider subscribes to shipping.quote, reads the order, and replies
with a price. Run three of them, each quoting a different number.
- CLI
- JavaScript/TypeScript
- Go
- Python
- Java
- Rust
- C#/.NET
- C
#!/bin/bash
# Three shipping-quote providers, each answering on shipping.quote.
#
# IMPORTANT: `nats reply` subscribes inside a queue group by default
# (NATS-RPLY-22). Three providers left on that default would share ONE
# queue group, so only one would ever answer. To scatter the request to
# all three, give each provider its OWN queue group name with --queue.
# A queue group of one member behaves like a plain subscriber.
#
# Run each line in its own terminal.
# Terminal 1 — carrier A quotes 1500 cents.
nats reply shipping.quote --queue carrier-a '{"carrier":"carrier-a","quote_cents":1500}'
# Terminal 2 — carrier B quotes 1200 cents.
nats reply shipping.quote --queue carrier-b '{"carrier":"carrier-b","quote_cents":1200}'
# Terminal 3 — carrier C quotes 1800 cents.
nats reply shipping.quote --queue carrier-c '{"carrier":"carrier-c","quote_cents":1800}'
// A shipping-quote provider. Subscribe plainly to shipping.quote (NOT in a
// queue group, so every provider sees each request) and reply with a price.
// Run several copies, each quoting a different number.
const sub = nc.subscribe("shipping.quote");
for await (const msg of sub) {
msg.respond('{"carrier":"carrier-a","quote_cents":1500}');
}
// A shipping-quote provider. Subscribe plainly to shipping.quote (NOT a
// queue group, so every provider sees each request) and reply with a price.
// Run several copies, each quoting a different number.
nc.Subscribe("shipping.quote", func(m *nats.Msg) {
m.Respond([]byte(`{"carrier":"carrier-a","quote_cents":1500}`))
})
# A shipping-quote provider. Subscribe plainly to shipping.quote (NOT in a
# queue group, so every provider sees each request) and reply with a price.
# Run several copies, each quoting a different number.
async with await client.subscribe("shipping.quote") as subscription:
async for message in subscription:
if message.reply:
await client.publish(message.reply, b'{"carrier":"carrier-a","quote_cents":1500}')
// A shipping-quote provider. Subscribe plainly to shipping.quote (NOT in a
// queue group, so every provider sees each request) and reply with a price.
// Run several copies, each quoting a different number.
nc.createDispatcher(msg -> {
if (msg.getReplyTo() != null) {
nc.publish(msg.getReplyTo(),
"{\"carrier\":\"carrier-a\",\"quote_cents\":1500}".getBytes(StandardCharsets.UTF_8));
}
}).subscribe("shipping.quote");
// A shipping-quote provider. Subscribe plainly to shipping.quote (NOT in a
// queue group, so every provider sees each request) and reply with a price.
// Run several copies, each quoting a different number.
let mut sub = client.subscribe("shipping.quote").await?;
while let Some(msg) = sub.next().await {
if let Some(reply) = msg.reply {
client
.publish(
reply,
r#"{"carrier":"carrier-a","quote_cents":1500}"#.into(),
)
.await?;
}
}
// A shipping-quote provider. Subscribe plainly to shipping.quote (NOT in a
// queue group, so every provider sees each request) and reply with a price.
// Run several copies, each quoting a different number.
await foreach (var msg in client.SubscribeAsync<Order>("shipping.quote"))
{
await msg.ReplyAsync(new ShippingQuote(Carrier: "carrier-a", QuoteCents: 1500));
}
// A shipping-quote provider. Subscribe plainly to shipping.quote (NOT a
// queue group, so every provider sees each request) and reply with a
// price. Run several copies, each quoting a different number.
static void
onQuote(natsConnection *nc, natsSubscription *sub, natsMsg *msg, void *closure)
{
if (natsMsg_GetReply(msg) != NULL)
natsConnection_PublishString(nc, natsMsg_GetReply(msg),
"{\"carrier\":\"carrier-a\",\"quote_cents\":1500}");
natsMsg_Destroy(msg);
}
if (s == NATS_OK)
s = natsConnection_Subscribe(&sub, conn, "shipping.quote",
onQuote, NULL);
There's one trap to know about with nats reply. By default, the CLI
subscribes inside a queue group named NATS-RPLY-22. Three
nats reply instances left on that default would form one queue group,
and only one of them would ever answer. That's the load-balancing
behavior from the previous page, not what we want here.
To make each provider an independent responder, give each one its own
queue group name with --queue. A queue group of one member behaves like
a plain subscriber: it receives every matching request. The CLI source
for the snippet above runs the three providers with distinct names
(carrier-a, carrier-b, carrier-c), so all three see each request.
The client library form has no such trap. A library subscribes plainly
unless you ask for a queue group, so three plain subscribers on
shipping.quote already scatter correctly.
Gather by count
Now the gather side. The client sends one request to shipping.quote and
collects replies until it's heard from every provider.
- CLI
- JavaScript/TypeScript
- Go
- Python
- Java
- Rust
- C#/.NET
- C
#!/bin/bash
# Send one request to shipping.quote and gather replies from every provider.
# Gather by count: stop after 3 replies have arrived.
nats request shipping.quote \
'{"order_id":"ord_8w2k","customer":"acme-co","total_cents":4200,"ts":"2026-05-22T10:14:22Z"}' \
--replies 3 --timeout 2s
# Gather by deadline instead: --replies 0 collects every reply that arrives
# during the full --timeout window, then returns. It is a fixed, predictable
# budget; --reply-timeout has no effect in this mode. Use this when you do
# not know how many providers are running.
nats request shipping.quote \
'{"order_id":"ord_8w2k","customer":"acme-co","total_cents":4200,"ts":"2026-05-22T10:14:22Z"}' \
--replies 0 --timeout 2s
// Scatter one request to every shipping-quote provider and gather the replies.
// Subscribe to a private inbox, publish the request with that inbox as the
// reply subject, then collect quotes until they stop arriving and pick the
// cheapest.
const order =
'{"order_id":"ord_8w2k","customer":"acme-co","total_cents":4200,"ts":"2026-05-22T10:14:22Z"}';
const inbox = createInbox();
const sub = nc.subscribe(inbox);
nc.publish("shipping.quote", order, { reply: inbox });
const quotes: string[] = [];
const gather = (async () => {
for await (const msg of sub) {
quotes.push(msg.string());
}
})();
// Stop once no further reply is expected.
setTimeout(() => sub.unsubscribe(), 300);
await gather;
console.log(`gathered ${quotes.length} quotes: ${JSON.stringify(quotes)}`);
// Scatter one request to every shipping-quote provider and gather the
// replies. Subscribe to a private inbox, publish the request with that
// inbox as the reply subject, then collect quotes until they stop arriving
// and pick the cheapest.
order := `{"order_id":"ord_8w2k","customer":"acme-co","total_cents":4200,"ts":"2026-05-22T10:14:22Z"}`
inbox := nats.NewInbox()
sub, _ := nc.SubscribeSync(inbox)
nc.PublishRequest("shipping.quote", inbox, []byte(order))
var quotes []string
for {
msg, err := sub.NextMsg(300 * time.Millisecond)
if err != nil {
break
}
quotes = append(quotes, string(msg.Data))
}
fmt.Printf("gathered %d quotes: %v\n", len(quotes), quotes)
# Scatter one request to every shipping-quote provider and gather the
# replies. Subscribe to a private inbox, publish the request with that inbox
# as the reply subject, then collect quotes until they stop arriving and
# pick the cheapest.
order = '{"order_id":"ord_8w2k","customer":"acme-co","total_cents":4200,"ts":"2026-05-22T10:14:22Z"}'
inbox = f"_INBOX.{uuid.uuid4().hex}"
async with await client.subscribe(inbox) as subscription:
await client.publish("shipping.quote", order.encode(), reply=inbox)
quotes = []
while True:
try:
message = await subscription.next(timeout=0.3)
quotes.append(message.data.decode())
except TimeoutError:
break
print(f"gathered {len(quotes)} quotes: {quotes}")
// Scatter one request to every shipping-quote provider and gather the
// replies. Subscribe to a private inbox, publish the request with that
// inbox as the reply subject, then collect quotes until they stop arriving
// and pick the cheapest.
byte[] order = ("{\"order_id\":\"ord_8w2k\",\"customer\":\"acme-co\","
+ "\"total_cents\":4200,\"ts\":\"2026-05-22T10:14:22Z\"}").getBytes(StandardCharsets.UTF_8);
String inbox = nc.createInbox();
Subscription sub = nc.subscribe(inbox);
nc.publish("shipping.quote", inbox, order);
List<String> quotes = new ArrayList<>();
Message m = sub.nextMessage(Duration.ofMillis(300));
while (m != null) {
quotes.add(new String(m.getData(), StandardCharsets.UTF_8));
m = sub.nextMessage(Duration.ofMillis(300));
}
System.out.println("gathered " + quotes.size() + " quotes");
// Scatter one request to every shipping-quote provider and gather the
// replies. Subscribe to a private inbox, publish the request with that inbox
// as the reply subject, then collect quotes until they stop arriving and
// pick the cheapest.
let order = r#"{"order_id":"ord_8w2k","customer":"acme-co","total_cents":4200,"ts":"2026-05-22T10:14:22Z"}"#;
let inbox = client.new_inbox();
let mut sub = client.subscribe(inbox.clone()).await?;
client
.publish_with_reply("shipping.quote", inbox, order.into())
.await?;
client.flush().await?;
let mut quotes = Vec::new();
while let Ok(Some(msg)) = tokio::time::timeout(Duration::from_millis(300), sub.next()).await {
quotes.push(String::from_utf8_lossy(&msg.payload).to_string());
}
println!("gathered {} quotes: {:?}", quotes.len(), quotes);
// Scatter one request to every shipping-quote provider and gather the
// replies. Subscribe to a private inbox, publish the request with that inbox
// as the reply subject, then collect quotes until they stop arriving and
// pick the cheapest.
var order = new Order(OrderId: "ord_8w2k", Customer: "acme-co", TotalCents: 4200, Timestamp: DateTimeOffset.Parse("2026-05-22T10:14:22Z", CultureInfo.InvariantCulture));
var inbox = client.Connection.NewInbox();
await using var sub = await client.Connection.SubscribeCoreAsync<ShippingQuote>(inbox);
await client.PublishAsync<Order>("shipping.quote", order, replyTo: inbox);
var quotes = new List<ShippingQuote>();
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
try
{
await foreach (var msg in sub.Msgs.ReadAllAsync(cts.Token))
{
quotes.Add(msg.Data!);
}
}
catch (OperationCanceledException)
{
}
output.WriteLine($"gathered {quotes.Count} quotes");
// Scatter one request to every shipping-quote provider and gather the
// replies. Subscribe to a private inbox, publish the request with that
// inbox as the reply subject, then collect quotes until they stop
// arriving and pick the cheapest.
const char *order = "{\"order_id\":\"ord_8w2k\",\"customer\":\"acme-co\","
"\"total_cents\":4200,\"ts\":\"2026-05-22T10:14:22Z\"}";
int quotes = 0;
if (s == NATS_OK)
s = natsInbox_Create(&inbox);
if (s == NATS_OK)
s = natsConnection_SubscribeSync(&sub, conn, (const char *) inbox);
if (s == NATS_OK)
s = natsConnection_PublishRequestString(conn, "shipping.quote",
(const char *) inbox, order);
while (s == NATS_OK)
{
natsMsg *quote = NULL;
// Stop once no further quote arrives within the gap deadline.
s = natsSubscription_NextMsg("e, sub, 300);
if (s != NATS_OK)
break;
printf("quote: %.*s\n",
natsMsg_GetDataLength(quote), natsMsg_GetData(quote));
quotes++;
natsMsg_Destroy(quote);
}
printf("gathered %d quotes\n", quotes);
The CLI uses --replies 3: read from the inbox until three replies
arrive, then stop — or until replies stop arriving, whichever comes
first. With the three providers running, the output shows three quotes,
one per carrier. The client compares the prices and keeps the lowest.
A library does the same thing, and how much you hand-roll depends on the
client. A plain request() returns only the first reply — on its own it
never returns a list, because the client can't know how many responders
exist.
Some clients ship a gather helper that returns many: nats.js has
requestMany and orbit.go has RequestMany (both follow ADR-47,
"Request Many", with count, stall, and sentinel stop conditions), and the
.NET client has RequestManyAsync. Where no helper exists, you build the
loop yourself: subscribe to a fresh inbox, publish the request with that
inbox as the reply subject, then read from the subscription and append
each reply to a list until your count or deadline is reached, then
unsubscribe.
Gather by deadline
Counting replies assumes you know how many providers there are. Often you don't. Carriers come and go; one might be down. Write that gather yourself with no read deadline, and waiting for a fixed count of three blocks forever if only two answer — the read for the third reply never returns.
The safer approach gathers by deadline instead. Collect every reply that arrives within a time budget, then act on whatever you have.
From the CLI, --replies 0 switches to deadline mode: --timeout becomes
the whole collection window. The command reads replies for the full window
and returns when it closes, so --replies 0 --timeout 2s gathers everything
that arrives in those two seconds. It's a fixed, predictable budget, and
--reply-timeout has no effect in this mode — it only bounds the gap
between replies when you gather by count.
nats request shipping.quote \
'{"order_id":"ord_8w2k","customer":"acme-co","total_cents":4200,"ts":"2026-05-22T10:14:22Z"}' \
--replies 0 --timeout 2s
With three providers up, all three quotes arrive within milliseconds, but the
command still holds open for the full two seconds before returning the set.
With one provider down, you get the two quotes that answered and wait out the
same two-second window. If no provider is subscribed at all, the command
returns right away with a no responders signal instead of waiting — though a
provider that's subscribed but slow to answer still costs the full --timeout.
Either way the wait is bounded; it never blocks forever.
A deadline changes the rule from waiting for every responder to waiting only for whoever answers in time, which is the only safe assumption when the responder set isn't fixed.
There's a third way to end a gather: a sentinel. With
--wait-for-empty, the command keeps collecting until a reply arrives with
an empty payload, which a responder sends to mark the end of the set. This
counts replies rather than watching a single deadline, so if the sentinel
never comes the --reply-timeout bounds the gap between replies and the
wait still ends.
Delivery guarantees
Scatter-gather is at-most-once like everything else: a reply dropped in transit is just absent from the gathered set, nothing redelivers it, and arrival order carries no meaning. Treat the set as whatever answers happened to arrive, not a ranked list. That's fine for a shipping quote — re-asking is cheap. When each reply must survive a crash, that's JetStream, not a core gather.
Pitfalls
Taking only the first reply. A plain nats request stops after one
reply, because its --replies flag defaults to 1. Point it at three
providers and you get whichever carrier answered first; the other two
quotes are discarded and you never learn there were more. A plain
request() in a client library does the same. When you mean to gather,
use --replies 0 from the CLI, reach for your client's gather helper
where it has one (nats.js requestMany, orbit.go RequestMany), or
subscribe to the inbox yourself and read in a loop.
- CLI
- C
#!/bin/bash
# The "first reply only" trap, and the fix.
#
# `nats request` defaults to --replies 1: it reads ONE reply and stops.
# With three providers up, you get whichever carrier answered first and
# the other two quotes are silently discarded. That is a single request,
# not a scatter-gather.
# WRONG for scatter-gather — takes only the first quote that lands.
nats request shipping.quote \
'{"order_id":"ord_8w2k","customer":"acme-co","total_cents":4200,"ts":"2026-05-22T10:14:22Z"}'
# RIGHT — gather every quote, then pick the cheapest. --replies 0 collects
# every reply that arrives during the full --timeout window, then returns —
# a fixed, predictable budget. If a carrier is down, its quote is simply
# absent from the set; --reply-timeout has no effect in this mode.
nats request shipping.quote \
'{"order_id":"ord_8w2k","customer":"acme-co","total_cents":4200,"ts":"2026-05-22T10:14:22Z"}' \
--replies 0 --timeout 2s
// The "first reply only" trap: a plain request reads ONE reply and
// stops. With three providers up, you get whichever carrier answered
// first and the other two quotes are silently discarded.
const char *order = "{\"order_id\":\"ord_8w2k\",\"customer\":\"acme-co\","
"\"total_cents\":4200,\"ts\":\"2026-05-22T10:14:22Z\"}";
natsMsg *first = NULL;
if (s == NATS_OK)
s = natsConnection_RequestString(&first, conn, "shipping.quote",
order, 2000);
if (s == NATS_OK)
{
printf("first quote only: %.*s\n",
natsMsg_GetDataLength(first), natsMsg_GetData(first));
natsMsg_Destroy(first);
}
// The fix: gather every quote yourself. Subscribe to a private inbox,
// publish the request with that inbox as the reply subject, and read
// replies until they stop arriving.
int quotes = 0;
if (s == NATS_OK)
s = natsInbox_Create(&inbox);
if (s == NATS_OK)
s = natsConnection_SubscribeSync(&sub, conn, (const char *) inbox);
if (s == NATS_OK)
s = natsConnection_PublishRequestString(conn, "shipping.quote",
(const char *) inbox, order);
while (s == NATS_OK)
{
natsMsg *quote = NULL;
s = natsSubscription_NextMsg("e, sub, 300);
if (s != NATS_OK)
break;
printf("quote: %.*s\n",
natsMsg_GetDataLength(quote), natsMsg_GetData(quote));
quotes++;
natsMsg_Destroy(quote);
}
printf("gathered %d quotes\n", quotes);
No deadline, so you wait for replies that never come. A hand-rolled
gather that reads with no deadline blocks forever if a provider is down —
the read for the missing reply never returns. Bound the wait: gather by
deadline (--replies 0 --timeout 2s) so a missing carrier just leaves its
quote out of the set.
Reading the gathered set as ranked. Arrival order isn't priority and a
short set isn't an error. Compare every reply on its merits (here, the
lowest quote_cents).
Where you are
The Acme ORDERS world now talks in four shapes over one local
nats-server:
notificationsandanalyticseach receive their own copy of everyorders.createdmessage (publish-subscribe).- Regional analytics spans
orders.us.createdandorders.eu.createdwithorders.*.created, and an audit service reads the whole hierarchy onorders.>. - The
inventoryservice answers single requests onorders.inventory.check(request-reply). - A
packersqueue group shares the work onorders.created, one packer per order. - Three providers answer one
shipping.quoterequest, and the client gathers every reply within a deadline and picks the cheapest.
That's the whole of core NATS: subjects, interest, reply inboxes, and queue groups. Everything is ephemeral and at-most-once, and nothing is remembered after it's delivered.
What's next
The next page adds a third piece to every message, beside its subject and payload: headers, a set of key/value metadata. You'll put a request id and a trace id on the orders flow and read the status header the server uses to signal no responders. Continue to Message headers.
See also
- Concepts → Request-reply — the five-minute overview of the pattern this page extends.
- Learn → Services — when many responders become a managed service that aggregates results for you.
- Reference — gather helper signatures per client library.