flatMap vs concatMap in Project Reactor
What it is
Both operators take a stream of values and, for each value, produce a new inner publisher whose elements are merged into the output stream. They differ in exactly two properties, and every practical consequence follows from those two:
flatMap | concatMap | flatMapSequential | |
|---|---|---|---|
| Inner subscriptions | eager, up to concurrency at once | one at a time | eager, up to maxConcurrency |
| Output order | interleaved, arrival order | source order | source order |
| Default concurrency | 256 (Queues.SMALL_BUFFER_SIZE) | 1 | 256 |
| Default inner prefetch | 32 (Queues.XS_BUFFER_SIZE) | 32 | 32 |
flatMap runs inners concurrently and emits whatever finishes first.
concatMap runs one inner to completion before subscribing to the next, so the
output preserves source order and there is no concurrency at all.
flatMapSequential is the one people forget: it subscribes eagerly like
flatMap but buffers results so the output order matches the source. You pay
memory for the buffer, and a slow first inner holds back everything behind it.
The confusion worth clearing immediately: map versus flatMap is a different
question. map is synchronous one-to-one transformation. flatMap is for when
the transformation itself returns a Mono or Flux, that is, when it is
asynchronous or one-to-many.
The problem it solves
You have a stream of order IDs and, for each one, you must call an inventory
service. The call returns Mono<Inventory>. If you use map, you get
Flux<Mono<Inventory>>, a stream of unstarted requests, which is useless. You
need to flatten. The only question left is whether the calls may overlap, and
whether the results must come back in the order the IDs arrived.
That question has three answers and Reactor gives you three operators, which is why the interview question exists: picking the wrong one produces a bug that does not show up in a unit test with three elements and does show up in production at 3,000 elements.
Mechanics
The interleaving is easiest to see with deterministic delays.
import reactor.core.publisher.Flux;
import reactor.test.StepVerifier;
import java.time.Duration;
// Inner publisher: item 1 is slow, items 2 and 3 are fast.
static Flux<String> call(int id) {
Duration d = (id == 1) ? Duration.ofMillis(300) : Duration.ofMillis(50);
return Flux.just("r" + id).delayElements(d);
}
// flatMap: all three subscribe immediately; the fast ones finish first.
Flux.range(1, 3).flatMap(Reactor::call)
.as(StepVerifier::create)
.expectNext("r2", "r3", "r1") // ORDER IS COMPLETION ORDER
.verifyComplete(); // total wall clock ~300 ms
// concatMap: subscribe to 1, wait, then 2, then 3.
Flux.range(1, 3).concatMap(Reactor::call)
.as(StepVerifier::create)
.expectNext("r1", "r2", "r3") // SOURCE ORDER
.verifyComplete(); // total wall clock ~400 ms
// flatMapSequential: concurrent like flatMap, ordered like concatMap.
Flux.range(1, 3).flatMapSequential(Reactor::call)
.as(StepVerifier::create)
.expectNext("r1", "r2", "r3")
.verifyComplete(); // total wall clock ~300 ms, r2 and r3
// are held in a buffer until r1 arrives
The three wall-clock numbers are the whole tradeoff. flatMap and
flatMapSequential finish in the time of the slowest inner; concatMap finishes
in the sum. concatMap and flatMapSequential preserve order;
flatMap does not. flatMapSequential buys ordering with an unbounded-ish
buffer of completed-but-not-yet-emittable results.
The concurrency parameter is not optional
// Wrong in production. 256 concurrent HTTP calls the moment the source
// produces 256 items, because that is the default concurrency.
orderIds.flatMap(id -> webClient.get().uri("/inventory/{id}", id)
.retrieve().bodyToMono(Inventory.class));
// Right. Bound concurrency to something the downstream can survive, and
// derive the number from the downstream's capacity, not from taste.
// Little's Law: to sustain 400 rps at 50 ms per call you need 20 in flight.
// Provision the connection pool to match, then set concurrency to it.
orderIds.flatMap(id -> webClient.get().uri("/inventory/{id}", id)
.retrieve().bodyToMono(Inventory.class),
20);
The default of 256 is not a safety limit, it is a buffer-size constant that
happens to be used as the default concurrency. Reactor requests concurrency
items from upstream at subscribe time and replenishes as inners complete, so
flatMap does propagate backpressure, but it propagates it at 256 items of
slack. Against a service sized for 20 concurrent requests, that is a self-inflicted
load test.
concatMap has the opposite failure: concurrency is fixed at one and cannot be
raised. A pipeline that must make 1,000 calls at 50 ms each takes 50 seconds. If
you find yourself adding .parallel() or .subscribeOn around a concatMap to
speed it up, you wanted flatMapSequential with a bounded concurrency.
Errors and cancellation differ too
With flatMap, an error in any inner terminates the whole sequence by default
and cancels the other in-flight inners. If you want the other calls to survive,
you handle the error inside the inner, not outside:
// Per-item error containment. onErrorResume INSIDE the lambda keeps one
// failed call from killing the other 19 in flight.
orderIds.flatMap(id -> inventory(id)
.timeout(Duration.ofMillis(200))
.onErrorResume(e -> Mono.just(Inventory.unknown(id))),
20);
// flatMapDelayError is the alternative: run every inner to completion,
// then emit a composite error at the end.
orderIds.flatMapDelayError(this::inventory, 20, 32);
A worked example
A search service enriches 1,000 result documents by calling a metadata service whose p99 is 40 ms and whose connection pool allows 25 concurrent requests. The endpoint's own SLO is 500 ms p99.
With concatMap: 1,000 sequential calls at 40 ms is 40 seconds. The endpoint
times out. Not viable.
With default flatMap: 256 concurrent calls hit a pool of 25. The other 231
queue on pool acquisition. Little's Law says the pool sustains
25 / 0.040 = 625 requests per second, so 1,000 calls take at least 1.6 seconds,
and the queued requests time out on pool acquisition rather than on the call.
The symptom in production is a spike of PoolAcquireTimeoutException and a
latency graph that looks like the metadata service degraded, when in fact the
caller caused it.
With flatMap(mapper, 25): 25 in flight, matched to the pool. Total is still
1.6 seconds of wall clock, which is over the SLO, so the real fix is upstream:
batch the metadata lookups. Flux.buffer(50).flatMap(batch -> metadataBatch(batch), 5)
turns 1,000 calls into 20 batched calls, 5 concurrent, roughly 4 round trips of
40 ms, about 160 ms. That progression, from operator choice to concurrency bound
to batching, is the answer an interviewer is looking for: the operator question
is real, and it is also a symptom of an N+1 that the operator cannot fix.
Production evidence
Project Reactor is the reactive engine underneath Spring WebFlux, Spring Cloud Gateway and the reactive Spring Data drivers (R2DBC, reactive Redis, reactive Cassandra and Mongo), so this operator choice is being made implicitly in every reactive Spring service. Spring Cloud Gateway's filter chain is a Reactor pipeline, and its routing predicates and filters compose with these operators directly.
The Reactor reference guide documents the concurrency defaults explicitly and
names flatMapSequential as the ordered-eager variant; the Queues class in
reactor-core is where SMALL_BUFFER_SIZE (256) and XS_BUFFER_SIZE (32) are
defined, both overridable with the reactor.bufferSize.small and
reactor.bufferSize.x system properties. Reading that class is the fastest way
to stop guessing about defaults.
The same three-way distinction exists in RxJava (flatMap, concatMap,
concatMapEager) and in Kotlin coroutines' Flow (flatMapMerge,
flatMapConcat), which is worth naming because it shows the distinction is
inherent to the problem rather than a Reactor quirk.
The debate
The default choice should be concatMap, and this is a minority position worth
defending. The argument: concatMap has no concurrency, so it cannot overwhelm a
downstream, cannot reorder, and cannot surprise you. It is the boring, correct
starting point, and you upgrade to bounded flatMap when you have measured that
sequential is too slow and you have a number for how much concurrency the
downstream tolerates.
The counter-argument, which is also correct, is that most enrichment pipelines
are latency-critical and sequential is obviously wrong, so starting from
flatMap with an explicit concurrency argument is more honest about intent.
Where they agree: never use flatMap without the concurrency argument. The
one-argument form is the actual bug. If I am reviewing a PR, an unbounded
flatMap against anything that does I/O is a blocking comment, and the fix is
either a number or a different operator.
flatMapSequential is the wrong choice more often than people think. It looks
like a free lunch (concurrent and ordered) but its buffer is unbounded in the
sense that a single slow inner holds every completed result behind it in memory.
On a stream of 100,000 items with one pathological element, that is a heap
problem. Use it when the stream is short and bounded, not on an unbounded source.
Follow-up Q&A
"Does flatMap respect backpressure?" Yes, but at a granularity of
concurrency. It requests concurrency items from the source at subscribe time
and replenishes one at a time as inners complete, and it applies prefetch to
each inner. So it never pulls the whole source into memory, but it will happily
hold concurrency in-flight operations regardless of what the downstream is
consuming. The confusion arises because people expect the downstream's
request(n) to limit inner subscriptions, and it does not.
"When would concatMap be a correctness requirement rather than a
preference?" When the inners have side effects whose order matters: applying a
sequence of state mutations, writing to an append-only log where order is the
contract, or replaying events for an aggregate. Order in the output stream is
the visible symptom; order of the side effects is the real requirement, and only
concatMap gives you that. flatMapSequential reorders the output correctly
while executing the side effects concurrently, which for this case is exactly
the wrong guarantee.
"You have a flatMap over a paginated API where each page's request needs the
previous page's cursor. Which operator?" None of them. That is
expand or Flux.generate, because the inners are not independent. If you find
yourself trying to make concatMap carry state between inners, you have the
wrong operator entirely.
"How do you debug a reactive pipeline where the stack trace is useless?"
Turn on Hooks.onOperatorDebug() in development (it is expensive; do not ship
it), or use reactor-tools ReactorDebugAgent which does the same instrumentation
with much lower overhead and is safe in production. Add .checkpoint("name") at
the boundaries of each logical stage so assembly-time traces name the stage.
Also add .log() temporarily on the suspect operator: it prints every signal
including request(n), which is how you see the concurrency behaviour you
actually got rather than the one you assumed.
"Your reactive service has worse p99 than the blocking version it replaced.
Where do you look first?" A blocking call on an event-loop thread. Reactor's
BlockHound agent detects this in tests. The second place is exactly this
operator choice: an unbounded flatMap that saturates a downstream, or a
concatMap that serialised work that should have overlapped. The third is
publishOn/subscribeOn placement putting the wrong part of the chain on
boundedElastic.
Common misconceptions
Candidates routinely say "flatMap is asynchronous and concatMap is
synchronous". Both are asynchronous. The difference is concurrency and ordering,
not synchrony. concatMap is fully non-blocking; it just subscribes to one inner
at a time.
The second misconception is that flatMap's default concurrency of 256 is a
deliberate safety limit chosen for I/O workloads. It is a shared buffer-size
constant. Nothing about 256 relates to your downstream's capacity, and treating
it as a default rather than as a value you must set is the origin of most
reactive incidents I have seen.
Interview delivery note
Say this: "flatMap is concurrent and unordered, concatMap is sequential and
ordered, flatMapSequential is concurrent and ordered at the cost of a buffer.
The default concurrency on flatMap is 256, which is a buffer constant rather
than a sensible limit, so I always pass an explicit concurrency derived from the
downstream's capacity: Little's Law gives me the number from the target
throughput and the call latency."
The depth signal is connecting the concurrency argument to Little's Law and to the downstream connection pool. Naming the three operators is senior. Saying "25 concurrent because the pool is 25 and the pool is 25 because 25 over 40 milliseconds is the 625 rps we need" is staff.
Further reading
- Project Reactor reference guide, "Which operator do I need?" and the
FluxJavadoc forflatMap,concatMapandflatMapSequential(the Javadoc carries the marble diagrams and the default values). reactor.util.concurrent.Queuesinreactor-corefor the buffer-size constants and their system properties.- Reactive Streams specification, rule 3.17 and the
request(n)demand protocol, for why backpressure stops at the operator boundary. - Simon Baslé's Reactor debugging material on
checkpoint,Hooks.onOperatorDebugand theReactorDebugAgent.