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:

flatMapconcatMapflatMapSequential
Inner subscriptionseager, up to concurrency at onceone at a timeeager, up to maxConcurrency
Output orderinterleaved, arrival ordersource ordersource order
Default concurrency256 (Queues.SMALL_BUFFER_SIZE)1256
Default inner prefetch32 (Queues.XS_BUFFER_SIZE)3232

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 Flux Javadoc for flatMap, concatMap and flatMapSequential (the Javadoc carries the marble diagrams and the default values).
  • reactor.util.concurrent.Queues in reactor-core for 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.onOperatorDebug and the ReactorDebugAgent.