Project Reactor โ Flux<T>
A visual, runnable guide to Project Reactor operators: animated marble diagrams, ready-to-run Kotlin examples, and predict-the-output challenges.
A Flux is a Reactive Streams Publisher that emits 0 to N elements, and then completes (successfully or with an error). It is the multi-element reactive type in Project Reactor, analogous to Observable in RxJava or Flow in Kotlin Coroutines.
Flux is intended to be used in implementations and return types. Input parameters should keep using raw Publisher as much as possible. If you know the upstream will emit 0 or 1 element, use Mono instead.
In Kotlin with Spring WebFlux, Flux is typically used as the return type for endpoints that stream multiple items, while Mono is used for single-value responses.
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.time.Duration
// Simulate a reactive microservice pipeline
data class Order(val id: String, val product: String, val quantity: Int)
fun fetchOrders(): Flux<Order> = Flux.just(
Order("ORD-1", "Laptop", 1),
Order("ORD-2", "Mouse", 5),
Order("ORD-3", "Monitor", 2)
)
fun main() {
// Build a reactive pipeline: fetch โ filter โ transform โ subscribe
fetchOrders()
.filter { it.quantity > 1 } // keep bulk orders
.map { "${it.id}: ${it.quantity}x ${it.product}" } // format
.doOnNext { println("Processing: $it") } // side-effect logging
.subscribe(
{ value -> println("Fulfilled: $value") },
{ error -> println("Pipeline error: ${error.message}") },
{ println("All orders processed!") }
)
}Creating a Flux
Every reactive pipeline starts with a source. Reactor ships a whole family of factory methods that turn whatever you already have โ literal values, collections, Java Streams, number ranges, a clock, imperative callbacks, even "nothing at all" โ into a Flux. This chapter walks through each factory with a runnable example, the exact output it produces, and the gotchas that bite in real code.
just
Flux.just(vararg data: T): Flux<T> // also Flux.just(data: T): Flux<T>Emits each supplied value synchronously and in argument order to every subscriber, then sends onComplete. It is a cold source, so the same fixed values are replayed independently for each new subscription. Values are captured eagerly when just() is called, not when subscribed, and emission happens on the subscriber's thread with no built-in async or delay.
- Stub or seed data in tests and examples
- Build a small fixed pipeline head to chain map/filter onto
- Provide constant fallbacks via switchIfEmpty or onErrorResume
- Emit a handful of literals already known at call time
Arguments are evaluated eagerly at assembly time, so Flux.just(expensiveCall()) runs the call immediately even if no one ever subscribes, and the same captured value is re-emitted to every subscriber instead of being recomputed. For per-subscription or lazy evaluation use Flux.defer or Mono.fromCallable; just() cannot represent an empty or computed-on-subscribe source.
Flux.just("A", "B", "C")
.subscribe { value -> println("got " + value) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit to the subscriber, in order, before completing?
Flux.just is the front door of Reactor: you hand it values you already have in hand and it wraps them in a Flux that emits each one, in order, and then completes. There is no computation, no delay, no I/O โ just a fixed script that gets replayed for every subscriber.
Reach for it when the data is known at the call site: seeding tests, providing constant fallbacks, or building a tiny pipeline head to chain operators onto.
import reactor.core.publisher.Flux
fun main() {
// A fixed, already-known set of order lifecycle states
Flux.just("PENDING", "PAID", "SHIPPED", "DELIVERED")
.map { status -> "Order #4711 is now $status" }
.subscribe { println(it) }
}We have four order lifecycle states that are known before the stream even exists. just emits them one by one in declaration order; map decorates each state with the order number; finally we subscribe and print each line. After DELIVERED, the Flux signals onComplete.
Order #4711 is now PENDING Order #4711 is now PAID Order #4711 is now SHIPPED Order #4711 is now DELIVERED
Arguments are captured eagerly, at assembly time: Flux.just(expensiveCall()) runs expensiveCall() immediately โ even if nobody ever subscribes โ and every subscriber sees that same captured value. For lazy or per-subscriber evaluation use Flux.defer or Mono.fromCallable.
fromIterable / fromArray / fromStream / from
Flux.fromIterable(it: Iterable<T>) ยท fromArray(arr: Array<T>) ยท fromStream(s: Stream<T>) ยท from(p: Publisher<T>): Flux<T>All four adapt existing data into a Flux that emits each element in order and then completes. fromIterable and fromArray walk their source lazily and are fully cold: a fresh iterator is requested per subscription, so each subscriber replays the whole collection. fromStream drains a Java Stream (one-shot unless you pass a Supplier<Stream>). from(Publisher) adapts any Reactive Streams Publisher โ RxJava Flowables, R2DBC results โ and simply returns the instance when it is already a Flux. Emission respects downstream backpressure in every variant.
- Stream an in-memory List, Set or array you already have
- Drain a Java Stream pipeline (files.lines(), stream collectors) reactively
- Adapt a Publisher from another reactive library (RxJava, R2DBC) via from()
- Fan a collection into per-element async work via flatMap/concatMap
fromIterable captures a reference, not a snapshot: mutating the collection during a lazy subscription emits the mutated contents or throws ConcurrentModificationException. And a Java Stream is consumable exactly once, so fromStream(stream) supports a single subscriber โ a second subscription fails with IllegalStateException; use fromStream { buildStream() } (the Supplier overload) for a cold, re-subscribable source.
val letters = listOf("A", "B", "C", "D")
Flux.fromIterable(letters)
.subscribe(
{ value -> println("next: " + value) },
{ error -> println("error") },
{ println("done") }
)๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. Given a list of letters, what does this Flux emit (and how does it terminate)?
Most streams don't start from literals โ they start from data you already have: a List loaded from a repository, an array, a Java Stream, or a Publisher produced by another reactive library. This family of factories turns each of those into a Flux: fromIterable for collections, fromArray for arrays, fromStream for Java Streams, and from for any Publisher.
All four behave the same way downstream: each element becomes one onNext, in iteration order, followed by a single onComplete.
import reactor.core.publisher.Flux
import java.util.stream.Stream
fun main() {
// 1. fromIterable โ any List, Set or other Iterable
val cart = listOf("Keyboard", "Mouse", "Monitor")
Flux.fromIterable(cart)
.subscribe { println("In cart: $it") }
// 2. fromArray โ a plain array of prices
val prices = arrayOf(49.99, 19.99, 229.0)
Flux.fromArray(prices)
.subscribe { println("Price: $it") }
// 3. fromStream โ a Java Stream (consumable only ONCE!)
Flux.fromStream(Stream.of("card", "paypal", "transfer"))
.subscribe { println("Payment method: $it") }
// 4. from โ adapt any existing Publisher (here another Flux)
val publisher = Flux.just("EUR", "USD")
Flux.from(publisher)
.subscribe { println("Currency: $it") }
}We have a shopping cart as a List, product prices as an Array, payment methods as a Java Stream, and a currency feed that is already a Publisher. Each factory walks its source and emits every element as its own signal โ the subscriber's lambda prints each line as it arrives, and each mini-stream completes right after its last element.
In cart: Keyboard In cart: Mouse In cart: Monitor Price: 49.99 Price: 19.99 Price: 229.0 Payment method: card Payment method: paypal Payment method: transfer Currency: EUR Currency: USD
A Java Stream can be consumed only once, so Flux.fromStream(stream) supports exactly one subscriber โ a second subscription fails. When you need a cold, re-subscribable source, use the supplier variant fromStream { buildStream() } so each subscriber gets a fresh Stream. Also, Flux.from(publisher) is smart about interop: if you pass it something that is already a Flux, it returns it as-is.
range
Flux.range(start: Int, count: Int): Flux<Int>On each subscription, range synchronously emits a contiguous sequence of `count` integers starting at `start` (start, start+1, ..., start+count-1) in strict ascending order, then sends onComplete. It is a cold source, so every subscriber gets its own fresh sequence from the beginning, and emission respects downstream backpressure/demand. If count is 0 it emits nothing and completes immediately.
- Generating fixed-size loops or index sequences reactively instead of a for-loop
- Driving repeated calls, e.g. fetching N pages with range(1, pages).flatMap { fetchPage(it) }
- Creating test/demo data streams with predictable values
- Producing offsets or IDs to zip against another stream
The second argument is a COUNT, not an end/inclusive bound: range(1, 5) emits 1..5, but range(5, 5) emits 5..9, not 5. A negative count throws IllegalArgumentException, and count of 0 completes empty without error.
Flux.range(1, 5)
.subscribe { println("got " + it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit before completing?
range is the reactive for-loop: give it a starting integer and a count, and it emits that many consecutive integers, then completes. There is no collection behind it โ the numbers are generated on demand, as the subscriber requests them.
It shines wherever you need N of something: page numbers to fetch, retry attempt counters, or seat and batch indices to build labels from.
import reactor.core.publisher.Flux
fun main() {
// Generate the 5 seat numbers of row A: no list needed, just start + count
Flux.range(1, 5)
.map { n -> "A$n" }
.subscribe { seat -> println("Seat $seat reserved") }
}We ask for 5 integers starting at 1. map turns each bare number into a seat label for row A, and the subscriber prints one reservation per seat. After seat A5 the stream completes on its own.
Seat A1 reserved Seat A2 reserved Seat A3 reserved Seat A4 reserved Seat A5 reserved
The second argument is a COUNT, not an end bound: range(1, 5) emits 1..5, but range(5, 5) emits 5..9 โ five numbers starting at 5. A count of 0 completes empty without error; a negative count throws IllegalArgumentException.
interval
Flux.interval(period: Duration): Flux<Long> // also interval(delay, period)Starts a cold, single-subscriber timer that emits 0L and then increments by 1 once every fixed period, indefinitely. It never emits onComplete or onError on its own, so the stream runs forever until you cancel it or compose a terminating operator. Ticks are produced on the parallel Scheduler by default, so downstream work runs off the subscribing thread unless you publishOn elsewhere.
- Polling an API or database on a fixed cadence
- Driving periodic UI refreshes, heartbeats or keep-alives
- Emitting a sequence of timestamps/counters for sampling or throttling
- Simulating a clock or steady event source in tests
It never completes, so forgetting take()/takeUntil() leaks a running timer. Also, if a downstream subscriber can't keep up with the tick rate, the default backpressure behavior raises an OverflowException rather than slowing the timer.
val ticks: Flux<Long> = Flux.interval(Duration.ofSeconds(1))
ticks.take(5)
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit?
interval is a clock turned into a stream: after each period it emits the next number of an infinite sequence 0, 1, 2, 3โฆ It is the standard way to poll an API, refresh a dashboard, or drive a heartbeat.
Two things set it apart from every factory above: it is asynchronous (ticks are produced on the parallel Scheduler, not on your thread), and it never completes on its own โ you almost always pair it with take(n) or takeUntil.
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
// Poll a service heartbeat every 200ms, but only 3 times
Flux.interval(Duration.ofMillis(200))
.take(3)
.map { tick -> "heartbeat #${tick + 1}" }
.doOnNext { println(it) }
.blockLast() // interval runs on another thread: wait for it here
}We start a 200 ms clock. take(3) lets only the first three ticks (0, 1, 2) through and then cancels the timer; map renames each tick to a human-friendly heartbeat number. Because ticks arrive on a timer thread, main would exit before the first beat โ blockLast() parks the main thread until the stream finishes.
heartbeat #1 heartbeat #2 heartbeat #3
interval never completes on its own โ forgetting take()/takeUntil() leaks a timer that ticks forever. And if the consumer cannot keep up with the period, backpressure fails fast with an OverflowException instead of slowing the clock down.
empty / error / never
Mono.empty(): Mono<T> ยท Flux.empty(): Flux<T> ยท Mono.error(t: Throwable): Mono<T> ยท Flux.never(): Flux<T>These are degenerate publishers that skip onNext entirely. empty() emits a single onComplete immediately on subscription with zero items; error(e) emits a single onError(e) immediately and propagates that throwable downstream; never() subscribes but never signals onNext, onComplete, or onError, so the stream hangs open forever. error() can also take a Supplier<Throwable> for lazy, per-subscriber exception creation; all three are cold, replaying their fixed behavior to every subscriber.
- Return empty() from switchIfEmpty/flatMap to mean 'no value, just complete'
- Inject error() in tests or guard clauses to simulate failures
- Use never() in tests to verify timeout/cancellation handling
- Provide a neutral placeholder in operators expecting a Publisher
never() never terminates, so a subscriber waiting on it (e.g. block() or a sync chain) will hang indefinitely โ always pair it with timeout() in tests. Also, Mono.error(new RuntimeException()) builds the exception eagerly at assembly time even if never subscribed; use Mono.error(() -> ...) (Supplier) to defer creation and avoid sharing one stacktrace across subscriptions.
Flux.empty<Int>()
.concatWith(Flux.error(RuntimeException("boom")))
.subscribe(
{ println("next: " + it) },
{ println("err: " + it.message) },
{ println("done") }
)๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this snippet print?
Not every stream has data. empty() completes immediately without emitting anything; error(e) fails immediately with your exception; never() does absolutely nothing โ no value, no completion, no error. They sound trivial, but they are the vocabulary for "no result", "failed" and "still pending" in reactive code.
You will return them from functions constantly: empty() as the "not found" of the reactive world, error() to signal failures without throwing, and never() in tests to verify timeout and cancellation behavior.
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
fun main() {
fun lookup(id: Int): Mono<String> = when {
id < 0 -> Mono.error(IllegalArgumentException("bad id")) // failed lookup
id == 0 -> Mono.empty() // no result
else -> Mono.just("user-$id")
}
// error -> fallback, empty -> default; never() would hang, so we never subscribe to it
Flux.just(-1, 0, 2)
.flatMap { id ->
lookup(id)
.onErrorResume { Mono.just("<error>") }
.defaultIfEmpty("<none>")
}
.subscribe { println(it) }
}lookup returns a different degenerate publisher per id: an error for -1, an empty for 0, and a real value for 2. flatMap subscribes to each inner Mono: onErrorResume converts the failure into "<error>", defaultIfEmpty fills the void with "<none>", and user-2 passes through untouched.
<error> <none> user-2
never() never terminates: anything blocking on it (block(), a test awaiting completion) hangs forever โ always pair it with timeout() in tests. Also note that Flux.error(RuntimeException("boom")) builds the exception eagerly at assembly time; use the Supplier overload error { ... } to create it lazily, once per subscriber.
create / push
Flux.create(consumer: (FluxSink<T>) -> Unit, backpressure: OverflowStrategy = BUFFER): Flux<T>You receive a FluxSink and call sink.next(v) to emit values, sink.error(e) or sink.complete() to terminate; here next(10), next(20), next(30) flow downstream in call order and complete() ends the stream. create() lets multiple threads call the sink safely (emissions are serialized), while push() assumes a single producing thread. By default both buffer emitted items (OverflowStrategy.BUFFER) so a slow consumer never loses data unless you pick another strategy.
- Bridge callback or listener APIs (WebSocket, JMS, event bus) into a Flux
- Wrap a legacy push source that calls you with values asynchronously
- Emit from multiple threads into one stream (use create, not push)
- Adapt single-threaded imperative loops with push for less overhead
You MUST eventually call sink.complete() or sink.error(): if you forget, the Flux never terminates and downstream subscribers hang forever. Also register sink.onCancel/onDispose to release your underlying resource, since create/push do not clean up your callback source automatically.
val flux: Flux<Int> = Flux.create { sink ->
sink.next(10)
sink.next(20)
sink.next(30)
sink.complete()
}
flux.subscribe(::println)๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux.create snippet emit and how does it terminate?
Sooner or later you must connect a non-reactive API โ a WebSocket listener, a JMS consumer, a GUI event handler โ to a Flux. create() hands you a FluxSink: an imperative remote control where sink.next(v) emits a value and sink.complete() / sink.error(e) terminate the stream.
push() is the same API with one restriction: only a single thread may call the sink. create() serializes emissions coming from any number of threads; push() skips that safety in exchange for a bit less overhead.
import reactor.core.publisher.Flux
// A tiny callback-based API โ think of a message-broker or WebSocket client
class PriceFeed {
private var listener: ((String) -> Unit)? = null
fun onPrice(callback: (String) -> Unit) { listener = callback }
fun start() {
listOf("BTC = 67,100", "BTC = 67,240", "BTC = 67,180")
.forEach { listener?.invoke(it) }
}
}
fun main() {
val feed = PriceFeed()
// create() hands you a FluxSink: every callback becomes an emission
val prices = Flux.create<String> { sink ->
feed.onPrice { price -> sink.next(price) } // callback -> onNext
feed.start()
sink.complete() // don't forget the terminal!
}
prices.subscribe { println("Tick: $it") }
}PriceFeed is a classic callback API: you register a listener and it calls you back. Inside create we register a listener that forwards every callback into sink.next, start the feed, and signal complete once it is done. The subscriber sees the three callbacks as a perfectly ordinary Flux of ticks.
Tick: BTC = 67,100 Tick: BTC = 67,240 Tick: BTC = 67,180
You must eventually call sink.complete() or sink.error(): forget it and the Flux never terminates. Also register sink.onCancel/onDispose to detach your listener when the subscriber walks away โ create/push do not clean up the underlying resource automatically. If you only need a standalone handle to emit into (no callback API to wrap), see Sinks.many below.
Sinks.many / asFlux
Sinks.many().unicast() / .multicast() / .replay() -> Sinks.Many<T> // tryEmitNext(t): EmitResult ยท asFlux(): Flux<T>Sinks.many() builds a standalone, thread-safe sink that is both a producer handle and a stream: imperative code calls tryEmitNext / tryEmitComplete / tryEmitError from anywhere, and consumers subscribe to sink.asFlux(). The builder picks the delivery contract โ unicast() serves exactly one subscriber and buffers everything until it arrives; multicast().onBackpressureBuffer() serves many live subscribers, buffering pre-subscription values only for the first one; replay().all()/.limit(n)/.latest() re-deliver history to every late subscriber. Each tryEmit* returns an EmitResult instead of throwing, making failure handling explicit.
- Expose a service-level event stream that REST handlers or listeners push into
- Replace deprecated Processors (EmitterProcessor, DirectProcessor, ReplayProcessor)
- Cache the latest state for late subscribers with replay().latest()
- Bridge imperative code to a Flux when there is no callback API for create() to wrap
tryEmitNext never throws โ it returns an EmitResult, and ignoring it silently drops values (FAIL_ZERO_SUBSCRIBER on direct sinks, FAIL_OVERFLOW on full buffers, FAIL_TERMINATED after complete). Concurrent producers can also get FAIL_NON_SERIALIZED: sinks detect racing threads rather than locking. Check the result, or use emitNext(value, EmitFailureHandler) with a retry policy โ FAIL_FAST converts failures into exceptions.
val sink = Sinks.many().multicast().onBackpressureBuffer<String>()
sink.tryEmitNext("e1") // before anyone subscribes
sink.asFlux().subscribe { println("A: " + it) } // subscriber A
sink.tryEmitNext("e2")
sink.asFlux().subscribe { println("B: " + it) } // subscriber B (late)
sink.tryEmitNext("e3")๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. A multicast sink gets emissions before and after each subscriber joins. What does subscriber B print?
create() wraps a callback registration, but often you just want a standalone handle your service can emit into from anywhere: a REST handler pushes a notification, a Kafka listener pushes an event, and whoever is subscribed sees it. That is Sinks โ a thread-safe sink you construct directly, feed with tryEmitNext, and expose as a stream with asFlux(). It is the modern replacement for the deprecated Processor classes (EmitterProcessor, ReplayProcessor and friends).
The builder spells out the delivery contract. Sinks.many().unicast() allows exactly one subscriber and buffers everything until it arrives. .multicast() allows many live subscribers: values emitted before the first one are buffered, but late subscribers only see what is emitted after they join. .replay().all() / .limit(n) / .latest() re-deliver history to every latecomer.
import reactor.core.publisher.Sinks
fun main() {
// A multicast sink: one imperative producer, many live subscribers
val sink = Sinks.many().multicast().onBackpressureBuffer<String>()
val notifications = sink.asFlux()
sink.tryEmitNext("order-1 placed") // no subscriber yet: buffered (warm-up)
notifications.subscribe { println("A got: $it") }
sink.tryEmitNext("order-2 placed") // A is live -> delivered immediately
notifications.subscribe { println("B got: $it") }
sink.tryEmitNext("order-3 placed") // both A and B are live
sink.tryEmitComplete()
// replay().limit(1) keeps history for late subscribers instead
val lastPrice = Sinks.many().replay().limit<Int>(1)
lastPrice.tryEmitNext(101)
lastPrice.tryEmitNext(105)
lastPrice.asFlux().subscribe { println("late subscriber sees: $it") }
}order-1 is emitted while nobody listens: multicast().onBackpressureBuffer() parks it in a warm-up buffer, so subscriber A receives it the moment it subscribes. order-2 finds A live and is delivered immediately. B joins late โ it missed the first two signals โ so when order-3 is emitted both A and B print it, but B never sees order-1 or order-2. The replay().limit(1) sink keeps the last value as history: the late subscriber still gets 105.
A got: order-1 placed A got: order-2 placed A got: order-3 placed B got: order-3 placed late subscriber sees: 105
The tryEmit* methods never throw โ they return an EmitResult you are supposed to check (OK, FAIL_TERMINATED, FAIL_OVERFLOW, FAIL_ZERO_SUBSCRIBERโฆ). A fire-and-forget tryEmitNext silently drops the value on failure. If concurrent producers race you can get FAIL_NON_SERIALIZED: either serialize your producers or use emitNext(value, handler) with a retry policy โ the built-in FAIL_FAST handler turns failures into exceptions.
generate
fun <T, S> Flux.generate(stateSupplier: () -> S, generator: (S, SynchronousSink<T>) -> S): Flux<T>generate builds a cold Flux from a mutable state value and a SynchronousSink: each downstream request invokes your generator function once, and you may call sink.next() at most once per call, optionally returning the next state. The marble shows state s=0..4 each producing one element 0..4 in strict order, fully driven by backpressure (one emit per request), and the stream ends when you call sink.complete() (or sink.error()), yielding the terminal C. Because emission is synchronous and one-at-a-time, generate never overflows and threading is dictated by whoever requests/subscribes.
- Generating sequences with backpressure (counters, ranges, paginated cursors)
- Wrapping a pull-based/synchronous source like an Iterator or JDBC cursor
- Producing potentially infinite streams safely without overflow
- Carrying state between emissions (e.g. Fibonacci, accumulators)
Calling sink.next() more than once in a single generator invocation throws IllegalStateException โ generate is strictly one-signal-per-call, unlike create() which can emit many. If your state is mutable (a collection, a cursor) you must use the stateSupplier/consumer overload so each subscriber gets its own fresh state; reusing shared mutable state breaks the cold/replayable contract.
val flux: Flux<Int> = Flux.generate({ 0 }) { state, sink ->
sink.next(state)
if (state == 4) sink.complete()
state + 1
}
flux.subscribe { print("$it ") }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit when subscribed?
generate is create's disciplined sibling: instead of an open sink you may hammer at will, it calls your function once per downstream request, and you may emit at most one value per call. That makes it perfectly backpressure-safe โ it physically cannot outrun the subscriber.
Its superpower is the state parameter: each invocation receives the state returned by the previous one, which is exactly what you need for counters, pagination cursors, and mathematical sequences.
import reactor.core.publisher.Flux
fun main() {
// Stateful generate: a lazy Fibonacci sequence, one value per invocation
Flux.generate<Long, Pair<Long, Long>>({ 0L to 1L }) { state, sink ->
val (a, b) = state
sink.next(a) // at most ONE next() per call
b to (a + b) // return the state for the next round
}
.take(8) // generate is infinite: demand decides when to stop
.subscribe { println(it) }
}The state starts at (0, 1). On each request the lambda destructures the pair, emits the first component, and returns (b, a + b) as the state for the next round. The sequence is infinite on purpose: take(8) requests exactly eight values and then cancels, so only the first eight Fibonacci numbers are ever computed.
0 1 1 2 3 5 8 13
Calling sink.next() more than once per invocation throws IllegalStateException โ one signal per call is the whole contract (create() is the one that can emit many). If your state is mutable (a cursor, a connection), use the (stateSupplier, generator, stateConsumer) overload so every subscriber gets fresh state and the final consumer can close the resource.
defer
Flux.defer(supplier: () -> Publisher<T>): Flux<T>defer holds nothing until subscription: the supplier lambda runs once per Subscriber, building a brand-new source for that subscriber at subscribe time, then relaying its signals transparently. In the marble, subscriber A gets its own instance emitting 10, 20, complete; a later subscriber B triggers the supplier again and gets a fresh instance emitting 30, 50, complete. Nothing is captured or shared at assembly time, so each subscription sees independently evaluated values and its own terminal signal.
- Capture per-subscriber state like the current timestamp, request context, or a fresh UUID at subscribe time
- Wrap eager/blocking source construction so it only runs when actually subscribed
- Force re-evaluation on retry() or repeat() so each attempt rebuilds the source
- Avoid sharing a mutable source instance across multiple subscribers
defer only defers the construction of the Publisher, not the evaluation of its arguments. If you write defer { Mono.just(compute()) }, compute() still runs eagerly each time the lambda fires, but any value computed BEFORE defer (outside the lambda) is captured once and shared โ a classic mistake is putting the work outside the lambda and getting the same stale value for every subscriber.
var counter = 0
val flux = Flux.defer { Flux.just("v" + (++counter)) }
flux.subscribe { println(it) } // subscriber A
flux.subscribe { println(it) } // subscriber B๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. Each subscriber resubscribes to the same Flux below. What does the SECOND subscriber print, and why?
defer makes sources lazy: it holds a supplier and runs it once per subscriber, at subscription time. Whatever Publisher the supplier returns is what that particular subscriber consumes โ so every subscription can see freshly computed values.
It is the standard fix for the eager-capture trap of just: anything computed inside the defer lambda happens per subscription, not once at assembly.
import reactor.core.publisher.Flux
import java.time.LocalTime
fun main() {
// Eager: the timestamp is captured ONCE, when the Flux is assembled
val eager = Flux.just(LocalTime.now().toString())
// Lazy: the supplier runs again for EVERY subscriber
val lazy = Flux.defer { Flux.just(LocalTime.now().toString()) }
eager.subscribe { println("Eager 1: $it") }
Thread.sleep(1000)
eager.subscribe { println("Eager 2: $it") } // same value!
lazy.subscribe { println("Lazy 1: $it") }
Thread.sleep(1000)
lazy.subscribe { println("Lazy 2: $it") } // one second later!
}Both eager subscriptions print the same timestamp: LocalTime.now() ran exactly once, when Flux.just was assembled. The lazy Flux runs its supplier on each subscribe, so Lazy 1 and Lazy 2 โ one second apart โ print different times. Same code shape, completely different evaluation model.
Eager 1: 11:51:16.169791 Eager 2: 11:51:16.169791 Lazy 1: 11:51:17.243813 Lazy 2: 11:51:18.248844
defer defers the construction of the Publisher, not the evaluation of your arguments: defer { Flux.just(compute()) } runs compute() fresh per subscriber, but a value computed OUTSIDE the lambda is captured once and shared โ quietly reintroducing the staleness you were trying to avoid. defer also pairs beautifully with retry()/repeat(), which resubscribe and therefore rebuild the source on every attempt.
Transforming
Transformation operators reshape the elements flowing through a Flux: they turn raw strings into domain objects, fan a user out into their orders, or fold a stream of transactions into a running balance. They are the bread-and-butter of reactive programming โ almost every real pipeline starts with one of these.
The map family is easy to mix up, so here is the mental model up front:
- map โ synchronous and 1-to-1: one value in, exactly one value out.
- flatMap โ async fan-out: merges inner publishers as they emit; fastest, but order is not guaranteed.
- concatMap โ async, one inner at a time: strict source order, at the cost of latency.
- flatMapSequential โ async fan-out with ordered output: subscribes eagerly, buffers to restore source order.
- switchMap โ latest wins: each new value cancels the previous inner publisher.
map / mapNotNull
map is the operator you will reach for most often: it takes each value that flows through the Flux and applies a plain synchronous function to it โ one value in, exactly one value out, same order. Think of a conveyor belt where every box gets a label stuck on it as it passes. mapNotNull is the pragmatic sibling: it applies the function and silently drops any null results, fusing a map and a filter into a single step.
fun <T, R> Flux<T>.map(mapper: (T) -> R): Flux<R>map applies a synchronous 1-to-1 function to every onNext value, emitting exactly one output per input in the same order ([1,2,3,4,5] -> [10,20,30,40,50]). It never adds, drops, or reorders elements, so the count is preserved and the terminal onComplete signal passes straight through. The mapper runs inline on the publishing thread; if it throws, the error is translated into an onError signal that terminates the sequence.
- Convert DTOs to domain/entity objects in a stream
- Extract or reshape a field from each emitted item
- Apply pure math or formatting transforms (e.g. * 10, toUpperCase)
- Normalize types before a downstream operator
map is for synchronous transforms only โ if your mapper returns a Publisher (Mono/Flux) or does async/blocking I/O, use flatMap/concatMap instead, otherwise you get a Flux<Mono<R>> or block the event loop. If the mapper returns null, map throws a NullPointerException (use mapNotNull to filter nulls safely).
Flux.just(1, 2, 3, 4, 5)
.map { it * 10 }
.subscribe(::println)๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit, in what order, and how does it terminate?
import reactor.core.publisher.Flux
data class User(val name: String, val age: Int)
fun main() {
// 1-to-1 transform: square each number
Flux.just(1, 2, 3, 4, 5)
.map { it * it }
.subscribe { println("Squared: $it") }
// Map raw lines into domain objects
Flux.just("Alice:30", "Bob:25", "Charlie:35")
.map { line ->
val (name, age) = line.split(":")
User(name, age.toInt())
}
.subscribe { println("User: ${it.name}, Age: ${it.age}") }
// mapNotNull: transform and drop nulls in one step
Flux.just("1", "two", "3", "four", "5")
.mapNotNull { it.toIntOrNull() }
.subscribe { println("Parsed: $it") }
}We run three small pipelines. The first squares each number: 1 through 5 go in, their squares come out, one for one, in order. The second takes raw "name:age" strings and maps each line into a typed User object โ the classic DTO-to-domain conversion. The third tries to parse each string with toIntOrNull(): "two" and "four" produce null, so mapNotNull drops them and only 1, 3 and 5 survive. Finally we subscribe and print every result:
Squared: 1 Squared: 4 Squared: 9 Squared: 16 Squared: 25 User: Alice, Age: 30 User: Bob, Age: 25 User: Charlie, Age: 35 Parsed: 1 Parsed: 3 Parsed: 5
map is for synchronous transforms only. If your function returns a Mono or Flux (an HTTP call, a database query), map would hand you an awkward Flux<Mono<R>> โ use flatMap instead. And if the mapper returns null, map throws a NullPointerException: reach for mapNotNull whenever nulls are expected.
flatMap
flatMap is how you do async work per element: it maps each value to a Publisher (usually a network or database call), subscribes to all of those inner publishers eagerly, and merges their results into a single Flux as they arrive. It is the fastest of the async mapping operators precisely because it never waits โ whoever answers first gets emitted first.
Two useful extras: an optional second argument caps how many inners run at once โ flatMap(mapper, 4) keeps at most four calls in flight โ and the flatMapDelayError variant keeps merging the remaining inners when one fails, delivering the error only at the very end.
fun <T, R> Flux<T>.flatMap(mapper: (T) -> Publisher<R>): Flux<R>flatMap maps each source element to an inner Publisher and subscribes to those inners eagerly and concurrently (up to a configurable concurrency, default 256). Values from the inner publishers are merged into one Flux as they arrive, so emissions can interleave and the output order does NOT follow source order. The result completes only after the source and every inner publisher complete; any inner error is propagated and (by default) terminates the whole stream.
- Fire many independent async calls (HTTP/DB) in parallel and merge results
- Maximize throughput when per-element order does not matter
- Expand each item into a stream of child items (one-to-many)
- Limit in-flight concurrency via the concurrency parameter
Order is NOT preserved: because inners run concurrently and interleave, output order is non-deterministic. If you need source order use concatMap (sequential) or flatMapSequential (concurrent fetch, ordered emission). Also, unbounded inner work can be throttled by the concurrency limit, causing surprising backpressure.
fun inner(x: String): Flux<String> = Flux.just(x + "1", x + "2")
Flux.just("A", "B")
.flatMap { inner(it) }
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. Given inner(x) emits two items, what does this pipeline emit?
import reactor.core.publisher.Flux
import java.time.Duration
// Each user's orders arrive from a (simulated) remote service
fun fetchOrders(user: String, latencyMs: Long): Flux<String> =
Flux.just("$user-order-1", "$user-order-2")
.delayElements(Duration.ofMillis(latencyMs))
fun main() {
Flux.just("ana", "luis", "sofia")
.flatMap { user ->
val latency = when (user) {
"ana" -> 120L // slowest service
"luis" -> 80L
else -> 50L // fastest service
}
fetchOrders(user, latency)
}
.doOnNext { println(it) }
.blockLast()
}We fan three users out to a fake order service where each user's service has a different latency: sofia answers every 50 ms, luis every 80 ms and ana every 120 ms. flatMap subscribes to all three inner Flux immediately, so the merged output is ordered by arrival time, not by source order โ sofia's first order beats everyone even though she was the last user emitted. We block on the last element so main does not exit early. One real run printed:
sofia-order-1 luis-order-1 sofia-order-2 ana-order-1 luis-order-2 ana-order-2
If downstream code depends on receiving results in source order, flatMap is the wrong tool: use concatMap (strictly sequential) or flatMapSequential (parallel fetch, ordered emission). Reach for flatMap when throughput matters more than order.
flatMapSequential
flatMapSequential answers the question "can I have flatMap's speed with concatMap's ordering?". It subscribes to every inner publisher eagerly, so the async work runs in parallel โ but it buffers results from inners that finish early and replays everything strictly in source order. Best of both worlds, paid for with memory.
fun <T, R> Flux<T>.flatMapSequential(mapper: (T) -> Publisher<R>): Flux<R>Subscribes to all inner publishers eagerly and concurrently (like flatMap), so the slow work runs in parallel, but it buffers each inner's emissions and replays them strictly in the order the source emitted the originating elements. So inner(1) always drains fully before inner(2), even if inner(2) finished first. The result completes only after the source and every inner have completed, and any inner error is propagated.
- Fan out parallel I/O (HTTP/DB calls) but need results in input order
- Enrich an ordered list of IDs concurrently without reordering the output
- Want concatMap's ordering guarantee but can't afford its sequential latency
- Batch processing where speed matters but downstream assumes source order
Order is preserved at the cost of buffering: if an early inner is slow, values from later inners that already finished are held in memory and cannot be emitted downstream until the earlier one completes, which can grow memory and add latency. It does NOT reduce concurrency like concatMap does, so it gives no backpressure-driven serialization of the actual subscription work.
fun inner(n: Int): Flux<String> =
Flux.just(n.toString() + "a", n.toString() + "b")
Flux.just(1, 2, 3)
.flatMapSequential { inner(it) }
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. Given the snippet, what does the subscriber print and in what order?
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.time.Duration
fun main() {
val start = System.currentTimeMillis()
Flux.just("slow", "fast", "medium")
.flatMapSequential { item ->
val delay = when (item) {
"slow" -> 300L
"fast" -> 50L
else -> 150L
}
Mono.just(item.uppercase()).delayElement(Duration.ofMillis(delay))
}
.doOnNext { println("$it after ${System.currentTimeMillis() - start} ms") }
.blockLast()
println("total: ${System.currentTimeMillis() - start} ms")
}Three tasks with very different latencies: slow (300 ms), fast (50 ms) and medium (150 ms). All three delayed Monos start at the same time. FAST is ready after about 50 ms, but flatMapSequential holds it back until SLOW โ the first source element โ has emitted. That is why all three lines print together around the 300 ms mark, and the whole pipeline takes roughly 300 ms instead of 500 ms (the sum), proving the work actually ran concurrently:
SLOW after 369 ms FAST after 370 ms MEDIUM after 370 ms total: 370 ms
The ordering guarantee costs memory: while an early inner is still running, results from later inners that already finished sit in a buffer. With many elements and skewed latencies that buffer can grow large โ keep an eye on it for big fan-outs.
concatMap
concatMap is the strict sequencer: it maps each element to an inner publisher but subscribes to them one at a time, fully draining each inner before touching the next. No interleaving, guaranteed source order โ ideal when each step must complete before the next begins, like ordered database writes or API calls that build on each other.
fun <T, V> Flux<T>.concatMap(mapper: (T) -> Publisher<out V>): Flux<V>For each source element, concatMap subscribes to the inner Publisher returned by the mapper, fully drains it, and only then subscribes to the inner for the next element. Because inners run strictly one at a time, output order exactly mirrors source order and there is no interleaving (A's emissions a1,a2 all precede B's b1,b2). The downstream onComplete is emitted only after the source completes and the last inner finishes; an error in any inner aborts the sequence immediately (use concatMapDelayError to defer errors until the end).
- Ordered async work where element N must finish before N+1 (sequential HTTP calls, paginated fetches)
- Database writes or migrations that must apply in strict source order
- Avoiding interleaving when each inner emits multiple values
- Limiting load by guaranteeing only one inner subscription is active at a time
concatMap has no concurrency: each inner blocks the next, so a single slow inner stalls everything and overall latency is the sum of all inner latencies. If you need ordered results but parallel execution, use flatMapSequential instead (runs inners concurrently, reorders output to source order).
fun inner(name: String): Flux<String> =
Flux.just(name + "1", name + "2")
Flux.just("a", "b")
.concatMap { inner(it) }
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. Given this Reactor pipeline, what does the subscriber print and in what order?
import reactor.core.publisher.Flux
fun main() {
// Each order goes through two warehouse steps, strictly one order at a time
Flux.just("burger", "fries")
.concatMap { item ->
Flux.just("$item: picked", "$item: packed")
}
.subscribe { println(it) }
}We have a stream of two kitchen orders. concatMap maps each one to an inner Flux with its two warehouse steps: picked, then packed. Because concatMap drains each inner completely before subscribing to the next, the burger is picked and packed before the fries are even started โ the emissions can never interleave:
burger: picked burger: packed fries: picked fries: packed
The order guarantee has a price: latency adds up. Here each enrichment call takes 100 ms and concatMap runs them strictly back-to-back:
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.time.Duration
fun enrich(sku: String): Mono<String> =
Mono.just("$sku [enriched]").delayElement(Duration.ofMillis(100))
fun main() {
val start = System.currentTimeMillis()
Flux.just("A", "B", "C")
.concatMap { enrich(it) }
.doOnNext { println(it) }
.blockLast()
println("total: ${System.currentTimeMillis() - start} ms")
}A [enriched] B [enriched] C [enriched] total: 470 ms
flatMapIterable
Sometimes each element already carries its children in memory โ an order with its line items, a post with its tags. flatMapIterable takes each value, asks it for an Iterable, and flattens all of its elements into the stream synchronously and in order. No inner publishers, no concurrency, no surprises. concatMapIterable is an alias with the concat naming โ for in-memory iterables the behavior is the same.
fun <T, R> Flux<T>.flatMapIterable(mapper: (T) -> Iterable<R>): Flux<R>Each source value is passed to the mapper, which returns an Iterable; that iterable is walked synchronously and every element is emitted downstream before the next source value is processed. Because the flattening is sequential and synchronous, the output order strictly follows source order (A's items, then B's items): there is no concurrency, no interleaving, and no inner Publisher to subscribe to. The source's terminal signals pass through โ onComplete fires only after the last source value's iterable has been fully drained, and an onError (from the source or thrown inside the mapper/iteration) terminates the Flux.
- Expand each entity into its in-memory child collection (order.items, post.tags)
- Flatten a Flux of lists/arrays into a Flux of individual elements
- Split a synchronous value (a String into chars, a CSV row into fields) without async overhead
- Fan out to a collection when the children are already loaded, not fetched reactively
The mapper must return a plain Iterable, not a Publisher โ if your children are produced by an async/reactive call (DB, HTTP), flatMapIterable will not subscribe to anything; use flatMap/concatMap instead. Also, the entire iterable for each value is pulled and buffered eagerly, so a huge or infinite Iterable can dominate emission and inflate memory.
data class Order(val items: List<String>)
Flux.just(Order(listOf("a1", "a2")), Order(listOf("b1", "b2")))
.flatMapIterable { it.items }
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. Given the snippet below, what does the resulting Flux emit (in order)?
import reactor.core.publisher.Flux
data class Order(val id: Int, val items: List<String>)
fun main() {
Flux.just(
Order(1, listOf("book", "pen")),
Order(2, listOf("lamp"))
)
.flatMapIterable { it.items }
.subscribe { println("item: $it") }
// Works with any Iterable: split sentences into words
Flux.just("hello world", "reactive kotlin")
.flatMapIterable { it.split(" ") }
.map { it.uppercase() }
.subscribe { println(it) }
}The first pipeline is a stream of two orders, each holding its line items as a plain List. flatMapIterable pulls the items out of each order and emits them one by one: both of order 1's items come out before order 2's, always. The second pipeline shows it works with any Iterable โ each sentence is split into words, which then flow through the rest of the chain as individual elements:
item: book item: pen item: lamp HELLO WORLD REACTIVE KOTLIN
The mapper must return a plain Iterable, not a Publisher. If the children come from an async call (database, HTTP), flatMapIterable is the wrong operator โ use flatMap or concatMap so the inner publisher actually gets subscribed.
switchMap
switchMap models "only the latest matters": each new source value cancels the inner publisher launched by the previous one and switches to a fresh inner. It is THE operator for search-as-you-type, autocomplete and live filtering โ anywhere results for an outdated query are worse than no results at all.
Its static cousin switchOnNext does the same switching over a Flux of publishers you already have; and if what you need is to branch the whole pipeline based on the first element, that is switchOnFirst, covered next.
fun <T, V> Flux<T>.switchMap(mapper: (T) -> Publisher<out V>): Flux<V>For each source element, the mapper produces an inner Publisher and switchMap subscribes to it, but the moment the next source element arrives it cancels the currently-subscribed inner before subscribing to the new one. Only values from the most recent inner reach downstream, so emissions follow source order but any in-flight (and not-yet-emitted) values from superseded inners are dropped. The outer completes only after the source completes AND the last inner completes; an error from either path propagates downstream.
- Search-as-you-type where only the latest query matters
- Autocomplete or live filtering tied to fast user input
- Reloading data on the newest selection (tab, route, filter)
- Any 'latest wins' stream where stale results must be discarded
switchMap cancels the previous inner the instant a new source element arrives, so slow inners may emit nothing at all and their side effects (HTTP calls, DB writes) can be aborted mid-flight โ never use it when every inner must complete; reach for flatMap or concatMap instead.
fun inner(n: Int): Flux<String> =
Flux.just(n.toString() + "a", n.toString() + "b").delayElements(Duration.ofMillis(50))
Flux.just(1, 2, 3)
.switchMap { inner(it) }
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. Each source value maps to an inner Flux emitting "<n>a" then "<n>b" with a delay. What does the pipeline print?
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.time.Duration
fun search(query: String): Mono<String> =
Mono.just("results for '$query'").delayElement(Duration.ofMillis(150))
fun main() {
// Three keystrokes: two in quick succession, then a pause before the last
val keystrokes = Flux.concat(
Mono.just("re"),
Mono.just("rea").delayElement(Duration.ofMillis(80)),
Mono.just("react").delayElement(Duration.ofMillis(300))
)
keystrokes
.switchMap { query -> search(query) }
.doOnNext { println(it) }
.blockLast()
}We simulate three keystrokes: "re" immediately, "rea" 80 ms later, then a 300 ms pause before "react". Every keystroke fires a 150 ms search. The search for "re" is still in flight when "rea" arrives at 80 ms, so switchMap cancels it โ "re" never produces results. The search for "rea" finishes at ~230 ms, before "react" arrives at ~380 ms, so its results DO make it through. Finally "react" runs its search undisturbed:
results for 'rea' results for 'react'
Cancellation aborts the inner's side effects mid-flight. Never use switchMap for work that must complete โ payments, writes, message publishing โ because a faster next element will silently kill it. For those, use flatMap or concatMap.
switchOnFirst
fun <T, V> Flux<T>.switchOnFirst(transformer: (Signal<T>, Flux<T>) -> Publisher<V>): Flux<V>When the first signal of the sequence arrives (onNext, onComplete or onError), switchOnFirst invokes your transformer exactly once, passing that Signal plus a Flux representing the WHOLE sequence โ first element included. Whatever Publisher the transformer returns becomes the downstream pipeline: subscribe to the given Flux to keep processing the elements (skipping or reusing the first as you wish), or return something entirely different to abort, fall back or reroute. From then on values stream through the chosen branch with no additional buffering.
- Parse a CSV/NDJSON stream whose header row defines how to read the rest
- Route by protocol/version byte: the first frame decides the decoder for the connection
- Fail fast or fall back when the first element reveals an empty or invalid feed
- Apply per-stream setup (schema, auth context) derived from the first message
The first signal is not always a value: for an empty source it is onComplete and for an immediate failure it is onError, so check signal.hasValue() before calling get(). Also, the transformer must return a pipeline that subscribes to the provided Flux at most once โ and remember the sequence it receives INCLUDES the first element, so skip(1) it if you already consumed it from the Signal.
Flux.just("id,name", "1,Ana", "2,Luis")
.switchOnFirst { first, rows ->
if (first.get()?.startsWith("id") == true)
rows.skip(1).map { "row: $it" }
else
rows.map { "raw: $it" }
}
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. The first element decides the branch. What does this pipeline print?
switchOnFirst lets you look at the first signal of a sequence and decide, right there, what the rest of the pipeline should be. Your transformer receives two things: the first Signal (a value, a completion or an error) and the whole sequence โ first element included โ and returns the Publisher to continue with. The classic use case: a CSV stream where the header row defines how every following row must be parsed.
import reactor.core.publisher.Flux
fun main() {
// A CSV file as a stream: first the header row, then the data rows
val csv = Flux.just(
"id,name,city",
"1,Ana,Bogota",
"2,Luis,Lima"
)
csv.switchOnFirst { first, rows ->
// `first` is the header signal; `rows` is the WHOLE sequence (header included)
val columns = first.get()?.split(",") ?: emptyList()
rows.skip(1).map { line ->
columns.zip(line.split(","))
.joinToString(", ") { (col, value) -> "$col=$value" }
}
}.subscribe { println(it) }
}The stream's first element is the header "id,name,city". switchOnFirst hands our lambda that first signal plus the complete sequence of rows. We split the header into column names and then build the branched pipeline: skip(1) drops the header itself, and map zips each data row with the column names to produce labeled records. The decision happens once, when the first element arrives โ after that the data rows flow straight through the new pipeline:
id=1, name=Ana, city=Bogota id=2, name=Luis, city=Lima
The transformer runs once per subscription, when the first signal arrives โ not once per element. That first signal is not always a value: if the source completes empty or errors immediately, first.hasValue() is false, so guard for it (here the ?: emptyList() fallback). And if all you need is "is this the first element?", index() or a simple flag is enough โ switchOnFirst is for when the SHAPE of the pipeline depends on the first value.
handle
handle is the Swiss-army transform: for every element you receive a SynchronousSink and decide what happens โ call sink.next(v) to emit a (possibly transformed) value, do nothing to silently drop it, or call sink.complete() / sink.error() to end the stream right there. It is map, filter and early termination fused into a single operator.
fun <T, R> Flux<T>.handle(handler: (T, SynchronousSink<R>) -> Unit): Flux<R>For each upstream value, your BiConsumer runs and may call sink.next(x) to emit a (possibly transformed) value, do nothing to silently drop it, or call sink.complete()/sink.error() to terminate the sequence. It is one-to-zero-or-one and strictly sequential: values are processed in order on the upstream thread, and the sink may emit at most one item per invocation. In the marble, 1,2,3,4,5 map to 10,30,50 (evens 2 and 4 are dropped) and an explicit sink.complete() ends the stream early.
- Map and filter in a single pass when the keep/drop decision and the transform are coupled
- Emit a value only when parsing/validation succeeds, dropping invalid inputs
- Stop the stream early on a sentinel value via sink.complete()
- Turn a recoverable failure into an onError without throwing from a map
The sink is synchronous and single-shot per call: you may invoke sink.next() at most once per element (a second call throws), and it cannot be passed to another thread for async emission. To emit zero/one asynchronously, use flatMap with a Mono instead.
Flux.just(1, 2, 3, 4, 5)
.handle<Int> { v, sink ->
if (v % 2 != 0) sink.next(v * 10)
}
.subscribe { println("got " + it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit (in order) before completing?
import reactor.core.publisher.Flux
fun main() {
// handle: map + filter + early completion in a single operator
Flux.range(1, 10)
.handle<String> { value, sink ->
when {
value > 7 -> sink.complete() // stop the stream early
value % 2 == 0 -> sink.next("Even: $value") // emit transformed
// odd values below 7: no sink.next -> silently dropped
}
}
.subscribe { println(it) }
}We have the numbers 1 through 10. For each one the sink decides: values above 7 trigger sink.complete(), ending the stream before 8, 9 and 10 are ever emitted; even values are transformed and emitted; odd values below 7 simply fall through the when without a sink.next, so they are dropped. The subscriber only ever sees the transformed evens:
Even: 2 Even: 4 Even: 6
handle shines in parse-or-drop pipelines, where a failed transform simply means "skip this one":
import reactor.core.publisher.Flux
fun main() {
Flux.just("12.50", "abc", "8.00", "xyz")
.handle<Double> { raw, sink ->
val price = raw.toDoubleOrNull()
if (price != null) sink.next(price) // parse or silently drop
}
.subscribe { println("price: $$it") }
}price: $12.5 price: $8.0
The sink is synchronous and single-shot: you may call sink.next() at most once per element (a second call throws), and you cannot hand the sink to another thread for async emission. If you need to emit zero-or-one asynchronously, use flatMap returning a Mono instead.
cast / ofType
fun <U> Flux<*>.ofType(clazz: Class<U>): Flux<U> // cast(clazz): Flux<U>ofType(type) inspects each onNext value with type.isInstance() and re-emits only the items that are assignable to the given type, silently dropping the rest while preserving the original order; here "a" and "b" are filtered out and 1, 2, 3 pass through. The onComplete and onError terminal signals are always propagated unchanged, and nothing about scheduling or threading is altered. cast(type) is the unchecked sibling: it lets every item through but reinterprets its type, throwing ClassCastException at runtime on any mismatch.
- Narrowing a Flux<Object> or heterogeneous event stream to one concrete subtype
- Reacting to specific event classes in an event-sourcing or message bus pipeline
- Safely downcasting after a polymorphic source instead of a manual filter + cast
- Discarding unrelated items by type without writing a predicate
ofType silently drops non-matching items, so a typo in the expected type can make a stream go mysteriously empty with no error. cast does the opposite: it never filters and will blow up with ClassCastException at the first mismatched element, so prefer ofType when the source is genuinely mixed. Also note neither handles autoboxing surprises (e.g. ofType(Integer.class) won't match a Long).
Flux.just(1, "a", 2, "b", 3)
.ofType(Integer::class.java)
.subscribe { value -> println(value) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. Given this Flux of mixed types, what does the subscriber receive?
When a Flux carries mixed types โ a polymorphic event bus, a legacy Flux<Any> โ ofType(clazz) keeps only the elements that are instances of the given class and casts them for you, silently dropping everything else. cast(clazz) is the unchecked sibling: it lets every element through and merely reinterprets the type, throwing a ClassCastException at the first mismatch.
import reactor.core.publisher.Flux
open class Animal(val name: String)
class Dog(name: String, val breed: String) : Animal(name)
class Cat(name: String, val indoor: Boolean) : Animal(name)
fun main() {
val animals: Flux<Animal> = Flux.just(
Dog("Rex", "Shepherd"),
Cat("Whiskers", true),
Dog("Buddy", "Labrador"),
Cat("Luna", false)
)
// ofType: filter and cast in one safe step
animals.ofType(Dog::class.java)
.subscribe { println("Dog: ${it.name}, Breed: ${it.breed}") }
animals.ofType(Cat::class.java)
.subscribe { println("Cat: ${it.name}, Indoor: ${it.indoor}") }
}A shelter stream mixes Dogs and Cats behind the common Animal type. The first subscription narrows it to Dogs โ the two Cats are dropped, and downstream code can read .breed without any manual cast. Because animals is a cold Flux, the second subscription replays all four animals and narrows to Cats instead. Each subscriber sees only its own species, in source order:
Dog: Rex, Breed: Shepherd Dog: Buddy, Breed: Labrador Cat: Whiskers, Indoor: true Cat: Luna, Indoor: false
The same trick tidies up untyped event streams โ pick out just the commands and ignore the numeric noise:
import reactor.core.publisher.Flux
fun main() {
val events: Flux<Any> = Flux.just(42, "deploy", 3.14, "rollback", 7)
// Keep only the String commands โ everything else is silently dropped
events.ofType(String::class.java)
.map { it.uppercase() }
.subscribe { println("command: $it") }
}command: DEPLOY command: ROLLBACK
ofType drops silently, so a wrong target type makes the stream go mysteriously empty with no error โ while cast never filters and blows up with ClassCastException on the first mismatch. Prefer ofType for genuinely mixed sources, cast only when you know every element already has the target type. Watch out for boxing too: ofType(Integer::class.java) will not match a Long.
scan
scan is reduce with a running commentary: it folds the stream with an accumulator function but emits every intermediate result, not just the final one. That makes it perfect for running totals, live statistics, or reducer-style state streams. With a seed, the seed itself is emitted first; the scanWith variant takes a supplier instead, so every subscriber gets a fresh, lazily created seed.
fun <T, A> Flux<T>.scan(initial: A, accumulator: BiFunction<A, in T, A>): Flux<A>scan emits the initial seed value immediately, then for each source value it applies accumulator(currentAccumulator, value) and emits the new accumulator. So a 3-element source produces the seed plus 3 emissions (4 total when seeded). With seed 0 and acc+x over [1,2,3] it emits 1, 3, 6 โ each running total in order โ then propagates onComplete. Errors from the source or the accumulator function terminate the sequence with onError. It is purely sequential and stateful, running on whichever thread delivers the upstream signals.
- Running totals or live sums of a stream of values
- Accumulating UI state from a stream of events/actions (reducer pattern)
- Building progressively a growing list or aggregate snapshot
- Tracking moving/cumulative metrics over time
With a seed, scan emits the seed value first even if the source is empty โ so the output always has one more element than the source (the marble shows the 0 appearing before any input arrives). Use scanWith for a lazy per-subscriber seed, and avoid mutating a shared accumulator object since it is reused across emissions.
Flux.just(1, 2, 3)
.scan(0) { acc, x -> acc + x }
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit?
import reactor.core.publisher.Flux
fun main() {
// A stream of account transactions: deposits and withdrawals
Flux.just(100, -30, -20, 50)
.scan(0) { balance, tx -> balance + tx }
.subscribe { println("balance: $it") }
}We fold a stream of four account transactions over the seed 0. scan emits the seed immediately โ that is the first "balance: 0" โ and then one updated balance per transaction: +100 gives 100, โ30 gives 70, โ20 gives 50, and +50 brings it back to 100. The subscriber watches the balance evolve instead of only learning the final figure:
balance: 0 balance: 100 balance: 70 balance: 50 balance: 100
Without a seed, the first element itself becomes the initial accumulator and passes through as-is:
import reactor.core.publisher.Flux
fun main() {
// Without a seed, the first element passes through as-is
Flux.just(3, 1, 4, 1, 5, 9, 2, 6)
.scan { a, b -> maxOf(a, b) }
.subscribe { println("max so far: $it") }
}max so far: 3 max so far: 3 max so far: 4 max so far: 4 max so far: 5 max so far: 9 max so far: 9 max so far: 9
With a seed, scan emits the seed even when the source is empty, so the output always has one element more than the input. Also avoid mutating a shared accumulator object โ it is carried across emissions; produce a new value each time.
expand / expandDeep
expand and expandDeep recursively grow a stream: every emitted value is fed back into your function to produce its children, until a branch returns an empty publisher. expand walks the graph breadth-first (level by level), expandDeep goes depth-first (down each branch before moving to the next). They are the reactive answer to trees, org charts and "fetch the next page until there is none".
fun <T> Flux<T>.expand(expander: (T) -> Publisher<out T>): Flux<T>expand emits each source value, then applies the expander function to it to produce a Publisher of children, which are merged back into the stream and themselves expanded recursively. Traversal is breadth-first: a value is emitted, then all of its direct children are visited before grandchildren. The expander runs once per emitted element; recursion stops when it returns an empty Publisher, and the whole Flux completes once every branch is exhausted.
- Walking tree or graph structures like file systems or org charts
- Following paginated APIs where each page yields the next page token
- Crawling links level by level from a root node
- Flattening nested category or comment hierarchies
expand has no built-in cycle detection: if the graph contains loops or the expander never returns an empty Publisher, it recurses forever and can exhaust memory. Always make sure branches terminate, and prefer expandDeep only when you truly need depth-first order.
Flux.just(1)
.expand { n -> if (n < 100) Flux.just(n * 10 + 1, n * 10 + 2) else Flux.empty() }
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. Given this Reactor pipeline, what sequence does subscribe print?
import reactor.core.publisher.Flux
data class TreeNode(val name: String, val children: List<TreeNode> = emptyList())
fun main() {
val tree = TreeNode("root", listOf(
TreeNode("A", listOf(TreeNode("A1"), TreeNode("A2"))),
TreeNode("B", listOf(TreeNode("B1")))
))
// expand: breadth-first (level by level)
println("Breadth-first:")
Flux.just(tree)
.expand { Flux.fromIterable(it.children) }
.map { it.name }
.subscribe { println(" $it") }
// expandDeep: depth-first (down each branch)
println("Depth-first:")
Flux.just(tree)
.expandDeep { Flux.fromIterable(it.children) }
.map { it.name }
.subscribe { println(" $it") }
}One tree: root has children A and B; A has A1 and A2, and B has B1. With expand, root is emitted first, then the whole next level (A, B), then the grandchildren (A1, A2, B1) โ level by level. With expandDeep the walk dives instead: root, then A and everything under A, and only then B and its subtree. Same nodes, two different orders:
Breadth-first: root A B A1 A2 B1 Depth-first: root A A1 A2 B B1
expand has no cycle detection: if the graph loops or the expander never returns an empty publisher, the recursion is infinite and will eventually exhaust memory. Make sure every branch terminates โ for pagination, that means returning Flux.empty() (or Mono.empty()) when there is no next page.
index
index decorates each element with its position: it turns a Flux<T> into a Flux of Tuple2<Long, T>, where T1 is a 0-based counter and T2 the original value. Nothing is filtered or reordered โ it simply makes arrival order explicit, which is handy for numbering rows, chunking, or treating the first element specially. An overload, index { i, v -> ... }, maps the pair straight into your own type.
fun <T> Flux<T>.index(): Flux<Tuple2<Long, T>>index() preserves and exposes source ordering by wrapping each emitted value in a Tuple2 whose T1 is a 0-based, monotonically incrementing Long index and T2 is the original value. It maps one-to-one and synchronously on the upstream thread, never reordering, filtering, or dropping elements, so item N always carries index N. Terminal signals (onComplete/onError) pass straight through unchanged after the last indexed value. A two-argument overload index(indexMapper) lets you transform the (index, value) pair into a custom type.
- Numbering rows or items for display, logging, or CSV/report generation
- Implementing pagination or chunking logic based on element position
- Detecting the first element (index == 0) for header/init handling
- Correlating each value with its arrival order before a flatMap that may reorder
The index counts emissions within this Flux subscription only; it resets to 0 on every resubscribe/retry and is not a stable global ID. If you place index() after order-disrupting operators like flatMap, the Long reflects the (possibly interleaved) emission order, not the original source order โ index immediately upstream of such operators to capture the true position.
Flux.just("a", "b", "c")
.index()
.subscribe { (i, v) -> println("$i:$v") }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit?
import reactor.core.publisher.Flux
fun main() {
Flux.just("alpha", "beta", "gamma", "delta")
.index()
.subscribe { (i, value) -> println("#$i: $value") }
// Custom index mapper (1-based numbering)
Flux.just("first", "second", "third")
.index { i, v -> "${i + 1}. $v" }
.subscribe { println(it) }
}The first pipeline indexes four words: each one comes out as a Tuple2 that Kotlin destructures into (i, value), printing #0 through #3 in order. The second pipeline uses the mapper overload to build 1-based labels directly, skipping the tuple entirely. We subscribe and print both numberings:
#0: alpha #1: beta #2: gamma #3: delta 1. first 2. second 3. third
Filtering
Filtering operators decide which elements of a Flux actually reach your subscriber. Some judge each value with a predicate (filter, takeWhile), some count positions (take, skip, elementAt), some remember what they have already seen (distinct), some watch the clock (sample), and a couple enforce how many elements are even allowed to exist (single, ignoreElements). They all share one trait: they never transform values โ they only let them through or drop them.
They also compose beautifully. Here is a small monitoring pipeline that combines three of them: a boiler sensor repeats readings, we silence the repeats, keep only alarming temperatures, and stop after two alerts.
import reactor.core.publisher.Flux
fun main() {
data class Reading(val sensor: String, val celsius: Int)
Flux.just(
Reading("boiler", 71), Reading("boiler", 71),
Reading("boiler", 84), Reading("boiler", 95), Reading("boiler", 84)
)
.distinctUntilChanged() // drop repeated readings
.filter { it.celsius > 80 } // keep only alarming temps
.take(2) // two alerts are enough
.subscribe { println("ALERT: ${it.sensor} at ${it.celsius}ยฐC") }
}ALERT: boiler at 84ยฐC ALERT: boiler at 95ยฐC
filter / filterWhen
fun <T> Flux<T>.filter(predicate: (T) -> Boolean): Flux<T>filter applies the synchronous predicate to each onNext value as it arrives; values returning true are re-emitted downstream in their original order, while values returning false are silently dropped (no placeholder, no error). The completion and error signals pass straight through untouched, and filtering runs on whatever thread is currently emitting, so it adds no scheduling. With in=[1,2,3,4,5,6,|] only even values survive, yielding out=[2,4,6,|].
- Drop nulls, empties, or invalid items before downstream processing
- Apply a cheap in-memory business rule (status == ACTIVE, amount > 0)
- Reduce a stream to a relevant subset before mapping or aggregating
- Guard against values that would break a later operator
The predicate must be synchronous and non-blocking; if your test needs a DB or HTTP call returning a Publisher<Boolean>, use filterWhen instead, since calling .block() inside filter will stall the reactive pipeline. Also, filter can drop every element, leaving an empty (but completed) Flux, so do not assume at least one value survives.
Flux.just(1, 2, 3, 4, 5, 6)
.filter { it % 2 == 0 }
.subscribe { println("got " + it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit?
filter() is the workhorse of this category: it evaluates every element against a synchronous predicate and forwards only the ones that return true, in their original order. If you have used filter on Kotlin collections, this is the same idea โ except the elements arrive over time instead of sitting in a list.
import reactor.core.publisher.Flux
fun main() {
data class Order(val id: String, val total: Double)
Flux.just(
Order("A-1041", 250.0),
Order("A-1042", 40.0),
Order("A-1043", 500.0),
Order("A-1044", 99.99)
)
.filter { it.total >= 100.0 }
.subscribe { println("High-value order ${it.id}: $${it.total}") }
}We have a stream of four orders. filter() tests each one as it arrives: A-1041 (250.0) passes, A-1042 (40.0) is dropped silently, A-1043 (500.0) passes, and A-1044 misses the cut by one cent. Finally we subscribe and print each surviving order:
High-value order A-1041: $250.0 High-value order A-1043: $500.0
But what if the test itself is asynchronous โ a database lookup, an HTTP call, a cache check? That is what filterWhen() is for: its predicate returns a Publisher<Boolean>, and the first boolean it emits decides the element's fate.
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
fun isInStock(item: String): Mono<Boolean> =
Mono.just(item.length > 3) // stand-in for an async inventory lookup
fun main() {
Flux.just("TV", "Laptop", "USB", "Monitor", "SSD")
.filterWhen { item -> isInStock(item) }
.subscribe { println("Available: $it") }
}We have a stream of five product names. For each one, filterWhen() subscribes to the Mono returned by isInStock() and waits for its verdict โ here only "Laptop" and "Monitor" get a true. An empty inner publisher counts as false, so "no answer" means "drop it". The output is:
Available: Laptop Available: Monitor
Never block inside filter(): calling .block() or doing I/O in its predicate stalls the whole pipeline. If the decision needs a database or HTTP round-trip, that is exactly the job of filterWhen โ keep filter() for cheap in-memory checks.
distinct / distinctUntilChanged
fun <T> Flux<T>.distinct(): Flux<T>distinct() keeps an ever-growing backing set of every value already emitted and forwards an element downstream only the first time it is seen, dropping any later duplicate regardless of how far apart they appear in the stream. Original arrival order of the surviving (first-seen) elements is preserved, and the terminal onComplete/onError passes through untouched. Overloads let you supply a keySelector and even a custom Collection supplier to control equality.
- Deduplicating IDs or events across an entire stream
- Removing repeated records from a batch read before persisting
- Collecting the unique set of categories/tags emitted
- Filtering out values already processed earlier in a pipeline
distinct() retains every distinct key in memory for the lifetime of the subscription, so on an unbounded or very large/hot stream the backing set grows without bound and can leak memory; use distinctUntilChanged() (only drops consecutive duplicates, constant memory) when global dedup is not required.
Flux.just(1, 2, 2, 3, 1)
.distinct()
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit (in order) before completing?
distinct() is your deduplicator: it remembers every value it has already let through and drops any later repeat, no matter how far apart the duplicates are. Its cheaper sibling distinctUntilChanged() only compares each value with the immediately previous one, so it collapses runs of consecutive repeats while still allowing a value to come back later.
import reactor.core.publisher.Flux
fun main() {
// Page visits during one session โ people refresh and come back
Flux.just("ana", "luis", "ana", "sofia", "luis", "ana")
.distinct()
.subscribe { println("unique visitor: $it") }
}We have a stream of six page visits, but only three people. distinct() lets "ana", "luis" and "sofia" through the first time it sees them and silently swallows the three repeat visits. Finally we subscribe and print each first-time visitor:
unique visitor: ana unique visitor: luis unique visitor: sofia
Two variants worth knowing: distinct(keySelector) lets you define what "equal" means (below: case-insensitive names), and distinctUntilChanged() is the classic tool for sensor streams โ it suppresses repeated readings but re-emits a value when it changes back.
import reactor.core.publisher.Flux
fun main() {
// distinct with a key selector: case-insensitive dedup
Flux.just("Alice", "Bob", "ALICE", "Charlie", "BOB")
.distinct { it.lowercase() }
.subscribe { print("$it ") }
println()
// distinctUntilChanged: only drops CONSECUTIVE duplicates
Flux.just(20, 20, 21, 21, 21, 19, 19, 20)
.distinctUntilChanged()
.subscribe { print("$it ") }
println()
}In the first stream, "ALICE" and "BOB" map to keys already seen, so they are dropped. In the second, the temperature readings 20, 20, 21, 21, 21, 19, 19, 20 collapse to each change point โ and note the final 20 IS emitted again, because distinctUntilChanged only remembers the previous value, not the whole history:
Alice Bob Charlie 20 21 19 20
distinct() keeps every key it has seen in memory for the whole subscription. On an infinite or very large stream that set grows without bound โ a slow memory leak. When you only need to silence consecutive repeats (the usual case for sensors, states, prices), prefer distinctUntilChanged(): it stores exactly one element.
take / takeLast / takeUntil / takeWhile
fun <T> Flux<T>.take(n: Long): Flux<T>take(n) re-emits the first n elements from the source in their original order, then sends an onComplete and cancels the upstream subscription, so no further elements are produced. With take(2) on [1,2,3,4,5] you get 1, 2, then complete โ 3, 4, 5 are never requested. By default (Reactor 3.5+) the bound is applied via request limiting; the optional take(n, limitRequest=false) variant instead lets upstream run and just stops relaying after n.
- Preview the first few results of a large or expensive stream
- Cap an infinite/hot source (Flux.interval, event feed) to N items
- Implement pagination-style 'first page' limits
- Short-circuit early once enough data is collected
take completes normally (onComplete) after n items even if the source has more โ it never errors and never waits for the source to finish. take(0) completes immediately without emitting anything, and on a hot/shared source take cancels upstream, which can stop side effects others depend on.
Flux.just(1, 2, 3, 4, 5)
.take(2)
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit to the subscriber?
take(n) answers a very common question: "I only need the first N โ can we stop after that?" It re-emits the first n elements, then sends onComplete and cancels the upstream subscription, so nothing else is even produced. That cancellation is the superpower: it turns infinite or expensive sources into cheap, bounded ones.
import reactor.core.publisher.Flux
fun main() {
// The search service streams matches as it finds them โ we only show the top 3
Flux.just(
"Reactor in Action", "Reactive Spring", "Kotlin in Depth",
"RxJava 3 Cookbook", "Spring Boot Recipes"
)
.take(3)
.subscribe { println("result: $it") }
}We have a stream of five search results arriving in relevance order. take(3) forwards the first three untouched, then completes the downstream and cancels upstream โ the last two books are never requested. Finally we subscribe and print the preview:
result: Reactor in Action result: Reactive Spring result: Kotlin in Depth
The family has three more members. takeLast(n) keeps only the final n elements (it must wait for completion to know which those are). takeWhile(predicate) emits while the condition holds and stops at the first failure โ exclusive, the failing element is dropped. takeUntil(predicate) emits until the condition matches โ inclusive, the matching element is the last one out.
import reactor.core.publisher.Flux
fun main() {
val source = Flux.range(1, 10)
// take: first N
source.take(3).subscribe { print("$it ") }
println()
// takeLast: last N (needs the source to complete)
source.takeLast(3).subscribe { print("$it ") }
println()
// takeWhile: while the predicate holds โ EXCLUSIVE, 5 is not emitted
source.takeWhile { it < 5 }.subscribe { print("$it ") }
println()
// takeUntil: until the predicate matches โ INCLUSIVE, 5 is emitted
source.takeUntil { it == 5 }.subscribe { print("$it ") }
println()
}Same source, four different cuts. Note the classic exclusive/inclusive trap in the last two lines: takeWhile { it < 5 } stops BEFORE 5, takeUntil { it == 5 } stops AT 5:
1 2 3 8 9 10 1 2 3 4 1 2 3 4 5
There is also takeUntilOther(publisher): instead of a predicate, it takes values until another Publisher emits anything โ perfect for "stream events until the shutdown signal fires" or "collect results until the timeout Mono ticks".
Since Reactor 3.5, take(n) also CAPS THE UPSTREAM REQUEST at n by default (the behavior of the old, now-deprecated limitRequest operator). Before 3.5 it requested unbounded demand and merely cancelled after n. If you relied on the old prefetching behavior, use take(n, false).
skip / skipLast / skipUntil / skipWhile
fun <T> Flux<T>.skip(n: Long): Flux<T>skip(n) discards the first n elements emitted by the source and then relays every subsequent element in order, unchanged. The source is still subscribed immediately and those first n items are consumed and dropped (not buffered), so nothing is re-ordered. The terminal signal (onComplete or onError) always passes through, even if fewer than n items were emitted โ in which case the result simply completes empty.
- Drop a header row or first record before processing a stream
- Ignore an initial warm-up or stale value (paired with skip + take for paging)
- Discard the first N items of a sequence you already handled elsewhere
- Implement simple offset pagination together with take(n)
skip does NOT reduce upstream work: the source still produces and the operator requests/consumes the first n items, only dropping them โ it is not a way to avoid expensive emissions. Also, with a time-based skip(Duration) the dropping is based on the clock on a parallel scheduler, not on item count, so the number skipped is non-deterministic.
Flux.just(1, 2, 3, 4, 5)
.skip(2)
.subscribe { println("got=" + it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit, in order?
The skip family is take's mirror image: instead of keeping the front of the stream, it throws it away. skip(n) drops the first n elements and relays everything after; skipLast(n) trims the tail; skipWhile and skipUntil drop based on a predicate. A classic real-world job: skipping header lines in an exported file.
import reactor.core.publisher.Flux
fun main() {
val lines = listOf(
"#exported 2026-07-06", "#format v2",
"alice,admin", "bob,editor", "carol,viewer"
)
Flux.fromIterable(lines)
.skipWhile { it.startsWith("#") }
.subscribe { println("record: $it") }
}We have a stream of file lines where the first two are metadata comments. skipWhile { it.startsWith("#") } drops lines as long as the condition holds; the moment "alice,admin" fails the test, the gate opens permanently โ even a later "#" line would now pass through. Finally we subscribe and print the real records:
record: alice,admin record: bob,editor record: carol,viewer
Here are all four flavors side by side, plus the classic skip + take combo that gives you offset/limit pagination over any stream:
import reactor.core.publisher.Flux
fun main() {
val source = Flux.range(1, 10)
// skip: drop the first N
source.skip(7).subscribe { print("$it ") }
println()
// skipLast: drop the last N
source.skipLast(7).subscribe { print("$it ") }
println()
// skipWhile: drop while the predicate holds
source.skipWhile { it < 5 }.subscribe { print("$it ") }
println()
// skipUntil: drop until the predicate matches (the match IS emitted)
source.skipUntil { it == 8 }.subscribe { print("$it ") }
println()
// skip + take = offset/limit pagination: page 2, page size 3
source.skip(3).take(3).subscribe { print("$it ") }
println()
}Same 1..10 source again. skipUntil is inclusive from the matching element onward (8 is emitted), and the last line reads like SQL's OFFSET 3 LIMIT 3 โ it yields the second page, 4 5 6:
8 9 10 1 2 3 5 6 7 8 9 10 8 9 10 4 5 6
The companion-publisher variant exists here too: skipUntilOther(publisher) discards everything until another Publisher emits โ think "ignore market ticks until the opening-bell signal fires".
skip does NOT save upstream work. The source still produces the first n elements and skip consumes and discards them โ it is a downstream sieve, not an upstream optimizer. If producing elements is expensive, push the offset into the source itself (e.g. the database query).
elementAt
fun <T> Flux<T>.elementAt(index: Int): Mono<T> // also elementAt(index, defaultValue): Mono<T>elementAt(n) subscribes to the source, counts emissions in order (0-based), drops every value before index n, and as soon as the value at position n arrives it emits exactly that one item and then completes โ cancelling the upstream. From [A,B,C,D,E], elementAt(2) yields C followed by onComplete, ignoring D and E entirely. If the source completes before reaching index n, it signals onError(IndexOutOfBoundsException) instead of completing empty.
- Pick the Nth item from an ordered stream (e.g. the 3rd page or record)
- Grab a known fixed position without buffering the whole sequence
- Short-circuit a stream once the wanted index is reached
- Use the default-value overload when the sequence may be too short
Without a default value, a sequence shorter than or equal to index n does NOT complete empty โ it terminates with IndexOutOfBoundsException. Also the index is strictly positional (0-based) over emission order, so it depends on upstream ordering; with operators like flatMap that interleave results the 'index' may not be the element you expect.
val result: Mono<String> = Flux.just("A", "B", "C", "D", "E")
.elementAt(2)
result.subscribe { value -> println(value) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Reactor pipeline emit?
elementAt(index) plucks exactly one element out of the stream by position โ 0-based, like array indexing โ and hands it to you as a Mono. It counts emissions as they pass, ignores everything before the target index, and the instant the wanted element arrives it emits it, completes, and cancels the upstream.
import reactor.core.publisher.Flux
fun main() {
val finishers = listOf("Ana", "Bruno", "Carla", "Diego", "Elena")
// Index 2 = third across the line = bronze
Flux.fromIterable(finishers)
.elementAt(2)
.subscribe { println("Bronze goes to: $it") }
// A default value saves you from IndexOutOfBoundsException
Flux.fromIterable(finishers)
.elementAt(9, "nobody")
.subscribe { println("9th place: $it") }
}We have a stream of five race finishers in arrival order. The first pipeline waits for index 2 โ Ana is 0, Bruno is 1, Carla is 2 โ emits "Carla" and immediately completes, so Diego and Elena are never consumed. The second pipeline asks for index 9, which the five-element stream never reaches, so the default "nobody" is emitted instead of an error. The output:
Bronze goes to: Carla 9th place: nobody
Without a default value, a too-short sequence does not complete empty โ it fails with IndexOutOfBoundsException. And the "index" is emission order, not any inherent key: after operators that reorder or interleave (like flatMap), position 2 may not be the element you think it is.
single / singleOrEmpty
fun <T> Flux<T>.single(): Mono<T>single() subscribes to the source and expects it to emit exactly one onNext, then complete. It re-emits that lone item and completes the resulting Mono. If the source completes empty it signals NoSuchElementException; if a second onNext arrives it immediately signals IndexOutOfBoundsException and cancels the source. The variant single(T default) substitutes a default for empty sources, while singleOrEmpty() simply completes empty (and still errors on 2+).
- Asserting a query or lookup returns exactly one row/record
- Enforcing a strict contract where 0 or duplicate results are a bug
- Adapting a Flux you know is single-valued into a Mono
- Failing fast on unexpected cardinality instead of silently taking the first
single() is strict: an empty source errors with NoSuchElementException and two-or-more emits errors with IndexOutOfBoundsException โ it does NOT take the first item like next()/take(1). Use singleOrEmpty() to tolerate empty, or single(default) to supply a fallback, but neither tolerates 2+.
val mono: Mono<Int> = Flux.just(7).single()
mono.subscribe(
{ value -> println("onNext: " + value) },
{ err -> println("onError: " + err) },
{ println("onComplete") }
)๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this snippet emit when subscribed?
single() is an assertion disguised as an operator: it converts a Flux to a Mono while enforcing that the source emits EXACTLY one element. Zero elements? NoSuchElementException. Two or more? IndexOutOfBoundsException, signalled the moment the second value shows up. Use it when "more than one" or "none" is a bug you want surfaced loudly, not silently absorbed.
import reactor.core.publisher.Flux
fun main() {
// Exactly one match: the value flows through as a Mono
Flux.just("root@corp.io", "dev@corp.io", "guest@mail.com")
.filter { it == "root@corp.io" }
.single()
.subscribe(
{ println("the one admin: $it") },
{ println("error: ${it.message}") }
)
// Empty source: singleOrEmpty completes without error
Flux.empty<String>()
.singleOrEmpty()
.subscribe(
{ println("got: $it") },
{ println("error: ${it.message}") },
{ println("empty is fine") }
)
// More than one element: single() fails fast
Flux.just(1, 2, 3)
.single()
.subscribe(
{ println("value: $it") },
{ println("error: ${it.message}") }
)
}Three scenarios. In the first, the filter leaves exactly one admin, so single() happily emits it. In the second, the source is empty, but singleOrEmpty() treats that as acceptable and completes without a value. In the third, the source has three elements โ single() errors as soon as 2 arrives, and our error callback prints the reason:
the one admin: root@corp.io empty is fine error: Source emitted more than one item
Do not confuse single() with next(): next() takes the first element and cancels the rest without complaint, while single() drains the source policing its cardinality. There is also single(defaultValue), which substitutes a fallback for the empty case โ but nothing forgives a second element.
next / last
fun <T> Flux<T>.next(): Mono<T> // first element, or empty Mono if source is empty
fun <T> Flux<T>.last(): Mono<T> // last element; ERRORS (NoSuchElementException) on empty
fun <T> Flux<T>.last(defaultValue: T): Mono<T> // last, or default if emptynext() subscribes, emits the very first onNext value as a Mono and immediately cancels the upstream, so for [A,B,C,|] it yields A and never sees B or C. last() instead drains the whole sequence, buffering only the most recent value, and emits it (C) at the moment the source completes โ it must wait for the terminal onComplete. Both collapse a multi-element Flux into a single-value Mono and propagate onError unchanged.
- next(): grab just the first result of a query/stream and stop early (short-circuit)
- next(): convert a Flux you expect at least one item from into a Mono without caring about the rest
- last(): get the final state after a sequence of updates completes (e.g. last price tick)
- last(default): safe terminal value when the source might be empty
They behave very differently on empty and infinite sources. On an empty Flux, next() emits an empty Mono (no error), but last() (no-arg) signals NoSuchElementException โ use last(default) to avoid it. On an infinite/hot Flux, last() never completes (it waits forever for onComplete), while next() returns the first emission immediately.
Flux.just("A", "B", "C")
.last()
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this snippet print?
next() and last() are the bookends of a Flux. next() gives you a Mono of the very first element and cancels the upstream immediately โ it does not care what comes after. last() is the patient one: it rides the stream to the end, remembering only the most recent value, and emits it when the source completes.
import reactor.core.publisher.Flux
fun main() {
val ticks = listOf(101.2, 101.9, 102.4, 101.7)
// next(): the first quote, then upstream is cancelled
Flux.fromIterable(ticks)
.next()
.subscribe { println("first quote: $it") }
// last(): waits for completion, then emits the final value
Flux.fromIterable(ticks)
.last()
.subscribe { println("closing price: $it") }
// last(default): safe when the stream might be empty
Flux.empty<Double>()
.last(0.0)
.subscribe { println("no trades today: $it") }
}We have a stream of four price ticks. next() grabs 101.2 the instant it arrives and stops listening. last() consumes all four ticks and only when the stream completes does it emit the final 101.7. The third pipeline shows why last(default) exists: on an empty market day, plain last() would error, but the default gives us a graceful 0.0. The output:
first quote: 101.2 closing price: 101.7 no trades today: 0.0
Their edge cases differ sharply. On an empty Flux, next() just completes empty, but no-arg last() errors with NoSuchElementException. On an infinite Flux, next() returns instantly while last() NEVER completes โ it is waiting for an onComplete that never comes.
sample / sampleFirst / sampleTimeout
fun <T> Flux<T>.sample(timespan: Duration): Flux<T> // also sample(Publisher<U>)sample() periodically samples the source on a recurring window and emits only the most recent value seen since the previous tick, dropping any earlier values that arrived within that same window. Each sampling boundary is driven either by a Duration (an internal interval running on the parallel Scheduler) or by the onNext signals of a companion Publisher in the sample(Publisher) overload. When the source completes, the very last value is emitted (if it hasn't been sampled yet) before the completion signal propagates, which is why in=[1..7,|] yields out=[2,4,7,|]: 2 and 4 are the latest values at the first two ticks and 7 is flushed on completion.
- Throttling high-frequency sensor or telemetry streams to a fixed reporting cadence
- Driving UI updates at a steady refresh rate without rendering every intermediate value
- Periodically snapshotting the latest price/quote from a fast market feed
- Rate-limiting progress or position updates to avoid downstream overload
sample() is lossy by design: it silently drops every value except the latest in each window, so it must never be used where every element matters. Also, if no new value arrives during a window, that tick emits nothing at all (it does not re-emit the previous value), and the timed variant signals on the parallel Scheduler, shifting downstream work off the source thread.
val sampled: Flux<Int> = Flux.just(1, 2, 3, 4, 5, 6, 7)
.sample(tick) // tick fires 3 times: after 2, after 4, after 7
.doOnNext { println(it) }
// what gets printed, and how does it terminate?๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. Given the marble timing where the source emits 1..7 and the sampler fires three ticks capturing the latest value each time, what does this Flux emit?
Sampling operators are deliberate data-droppers for streams that produce faster than you want to consume. sample(period) looks at the source every period and emits only the LATEST value seen since the previous look. sampleFirst(period) is the opposite mood: it emits the FIRST value, then goes deaf for the period โ classic click-throttling. sampleTimeout(fn) emits a value only if no newer one arrives within its companion window (the debounce pattern).
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
// A sensor pushes a reading every 100 ms; we only report every 300 ms
Flux.interval(Duration.ofMillis(100))
.sample(Duration.ofMillis(300))
.take(4)
.subscribe { println("report: $it") }
Thread.sleep(2000)
}We have a sensor emitting 0, 1, 2, 3... every 100 ms. Every 300 ms the sampler wakes up and forwards only the newest reading, so roughly every third value survives. take(4) caps the experiment, and Thread.sleep keeps the JVM alive while the timers run (interval emits on a background scheduler). One real run printed:
report: 1 report: 4 report: 7 report: 10
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
// Rate-limit clicks: accept the first, ignore repeats for 500 ms
Flux.interval(Duration.ofMillis(100))
.sampleFirst(Duration.ofMillis(500))
.take(3)
.subscribe { println("accepted click: $it") }
Thread.sleep(2200)
}Here the roles flip: sampleFirst accepts click 0 immediately, then discards everything for 500 ms, accepts the next one after the window reopens, and so on. It protects a backend from double-submits and button mashing. One real run printed:
accepted click: 0 accepted click: 5 accepted click: 11
All sampling is lossy by contract โ never put it in a pipeline where every element matters (payments, commands, acknowledgements). Also note a quiet window emits nothing at all: sample does not repeat the previous value when no new one arrived.
ignoreElements
fun <T> Flux<T>.ignoreElements(): Mono<T> // sibling: then(): Mono<Void>ignoreElements() subscribes to the source with unbounded demand and swallows every onNext signal, relaying only the terminal event: when the source completes, the resulting Mono<T> completes empty; when it errors, the error propagates unchanged. Crucially the values are dropped at THIS operator, not at the source โ every element still travels through all upstream operators, so side effects like doOnNext, saves and logs run for each one. With in=[A,B,C,|] the output is just [|].
- Run a stream purely for its side effects and only await its completion
- Adapt a Flux<T> into the Mono<T> shape an API requires, without caring about values
- Gate a follow-up step on "everything finished" (pair with then(nextMono))
- Turn a batch write/import pipeline into a single done-or-failed signal
The resulting Mono<T> NEVER emits a value, so chaining map/flatMap onto it is dead code โ only doOnSuccess(null-tolerant), then-style chaining or the completion callback make sense. If you actually need the final value plus completion, use last() or collectList() instead; and if you only want the Mono<Void> "done" type, then() says it more idiomatically.
Flux.just("a", "b", "c")
.doOnNext { println("seen " + it) }
.ignoreElements()
.subscribe(
{ v -> println("value: " + v) },
{ e -> println("error") },
{ println("done") }
)๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this snippet print?
ignoreElements() is the most radical filter of all: it drops EVERY value and keeps only the terminal signal, returning a Mono<T> that completes when the source completes (or errors when it errors). Why would you want that? Because very often you run a stream purely for its side effects โ writing rows, sending emails, warming a cache โ and the only thing the caller needs to know is "it finished" or "it blew up".
import reactor.core.publisher.Flux
fun main() {
// Import three records โ we only care that the whole import finished
Flux.just("row-1", "row-2", "row-3")
.doOnNext { println("importing $it") }
.ignoreElements()
.subscribe(
{ println("value: $it") }, // never called
{ println("error: ${it.message}") },
{ println("import finished") }
)
}We have a stream of three rows being imported. Each row still travels through the upstream pipeline โ doOnNext logs all three, proving the work happens โ but ignoreElements() swallows every onNext, so the value callback never fires. When the source completes, that completion passes through and our onComplete callback announces the import is done:
importing row-1 importing row-2 importing row-3 import finished
Its natural partner is then(), which does the same job but returns a Mono<Void> โ the idiomatic "I finished, now chain the next step" type in Reactor. Reach for ignoreElements() when an API demands a Mono<T> of the source's type; reach for then() when you just want to sequence what happens after.
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
fun main() {
val saveAll: Mono<Void> = Flux.just("user-1", "user-2")
.doOnNext { println("saving $it") }
.then() // like ignoreElements(), but typed Mono<Void>
saveAll.subscribe(
{ },
{ println("error: ${it.message}") },
{ println("all users saved โ safe to run the next step") }
)
}Same idea, different type: saveAll is a Mono<Void> you can hand to callers, chain with .then(nextMono), or return from a WebFlux handler. When it completes, both users are guaranteed saved:
saving user-1 saving user-2 all users saved โ safe to run the next step
The Mono<T> returned by ignoreElements() NEVER emits a value โ mapping it or flatMapping it is dead code. If you need the completion AND a value, look at last(), collectList(), or reduce() instead.
Combining
Real systems rarely have just one stream. You have a cache and a database; two sensors reporting at once; a user's profile and their orders arriving from different services. Combining operators take several Publishers and weave them into a single Flux โ and every member of the family answers two questions differently: when does each source get subscribed, and in what order do its values reach the output?
A quick map of the family before we dive in:
- concat โ one source at a time, order guaranteed
- merge โ all sources at once, values interleave by arrival time
- mergeSequential โ subscribe like merge, emit like concat
- zip / combineLatest / withLatestFrom โ combine values across sources: by index, by freshness, or driven by one side
- firstWithSignal / firstWithValue โ race the sources and keep only the fastest
- startWith / switchIfEmpty โ prepend values in front, or swap in a fallback when the source turns out empty
concat / concatWith
concat is the polite combiner: it glues sources together end to end. It subscribes to the first source, forwards everything it emits, waits for its complete signal โ and only then subscribes to the next one. The sources are never active at the same time, so the output order is exactly the declaration order. The instance variants concatWith(publisher) and concatWithValues(v1, v2, โฆ) append to an existing Flux instead of building from the static factory.
fun <T> Flux.concat(vararg sources: Publisher<out T>): Flux<T>Flux.concat subscribes to each source strictly one at a time, in declaration order: it fully drains source A and waits for its onComplete before even subscribing to source B, so the inner sources are never active concurrently. Every element is forwarded in sequence, preserving global ordering, and the resulting Flux only completes after the last source completes. Because sources are subscribed lazily on demand, a source's side effects don't run until the previous one has terminated.
- Emit a cached/local snapshot first, then append fresh network results in that exact order
- Run dependent or sequenced steps where stage B must not start until stage A finishes
- Build paginated streams by concatenating page 1, page 2, ... in strict order
- Prepend a header/initial value before a main stream (concatWith / startWith)
concat is fully sequential, so a slow or infinite first source blocks all later sources forever โ B never gets subscribed until A completes, and any error in a source aborts the whole chain immediately (use concatDelayError to defer errors until the end). For concurrency use merge/flatMap; for ordered concurrency use concatMap or flatMapSequential.
val a = Flux.just(1, 2, 3)
val b = Flux.just(4, 5)
Flux.concat(a, b)
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. Given two sources, what does this Flux emit (in order)?
A classic use: serve what's already in the cache first, then append whatever the database returns:
import reactor.core.publisher.Flux
fun main() {
val cachedUsers = Flux.just("user:1 (cache)", "user:2 (cache)")
val dbUsers = Flux.just("user:3 (db)", "user:4 (db)")
Flux.concat(cachedUsers, dbUsers)
.subscribe { println("resolved: $it") }
// Instance variants: append a Publisher, or literal values
Flux.just("A", "B")
.concatWith(Flux.just("C", "D"))
.concatWithValues("E")
.subscribe { print("$it ") }
println()
}We have two sources: a cache that resolves user:1 and user:2, and a database lookup holding user:3 and user:4. Flux.concat drains the cache completely and waits for its completion before touching the database source, so subscribers always see cached rows first. The second pipeline shows the instance flavor: A, B is followed by the C, D publisher via concatWith, and concatWithValues tacks a literal E onto the very end:
resolved: user:1 (cache) resolved: user:2 (cache) resolved: user:3 (db) resolved: user:4 (db) A B C D E
concat is fully sequential: a slow or infinite first source blocks every later source forever, and an error in any source kills the whole chain on the spot (concatDelayError defers errors until the end). Need concurrency? merge. Need concurrency AND order? mergeSequential or concatMap.
merge / mergeWith
merge is concat's impatient sibling. It subscribes to every source immediately, and whatever value shows up first โ from any source โ goes straight to the output. The relative order within each single source is preserved, but across sources everything interleaves by pure arrival time. The instance version, a.mergeWith(b), does the same for two publishers.
fun <T> Flux.merge(vararg sources: Publisher<out T>): Flux<T>merge eagerly subscribes to all source Publishers at once and forwards each value downstream the moment it arrives, so emissions from different sources interleave in real arrival order rather than source order. It does not buffer one source to wait for another, and it preserves the relative order of values within a single source. The merged Flux completes only after every source has completed; if any source errors, that error terminates the merged stream immediately and the rest are cancelled.
- Combine independent event streams (e.g. clicks plus websocket messages) into one feed
- Fan-in results from several parallel calls where arrival order matters more than source order
- Aggregate live updates from multiple sensors or topics concurrently
- Run several already-built Publishers together without sequencing them
Because subscription is eager and concurrent, output order is non-deterministic across sources, so never rely on it to preserve source ordering, use concat for that. Also, by default merge has bounded concurrency (Queues.SMALL_BUFFER_SIZE, 256) so subscribing to more sources than that will queue the extras rather than start them all at once.
val a = Flux.interval(Duration.ofMillis(50)).map { "a$it" }.take(3)
val b = Flux.interval(Duration.ofMillis(80)).map { "b$it" }.take(3)
Flux.merge(a, b)
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. Two interval-based sources are merged. Which statement about the output is GUARANTEED?
Two sensors report at different rates; merge turns them into one live event feed:
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
val sensorA = Flux.interval(Duration.ofMillis(50))
.map { "sensorA-$it" }
.take(3)
val sensorB = Flux.interval(Duration.ofMillis(80))
.map { "sensorB-$it" }
.take(3)
Flux.merge(sensorA, sensorB)
.doOnNext { println("event: $it") }
.blockLast()
}sensorA ticks every 50 ms and sensorB every 80 ms; both are subscribed at the same instant. Each reading drops into the merged stream the moment it fires, so the output mixes the two lanes by timestamp, not by source. The merged Flux completes only after both sensors have delivered all three readings:
event: sensorA-0 event: sensorB-0 event: sensorA-1 event: sensorB-1 event: sensorA-2 event: sensorB-2
mergeSequential
mergeSequential grants a very specific wish: "start all my sources NOW, but don't scramble the output." Like merge, it subscribes to every source eagerly, so slow sources warm up in parallel. Like concat, it emits in subscription order: every value of source 1, then every value of source 2 โ values that arrive early from a later source wait in a buffer until their turn.
Flux.mergeSequential(p1: Publisher<T>, p2: Publisher<T>): Flux<T>mergeSequential subscribes to all sources eagerly and concurrently (like merge), so their work runs in parallel, but it buffers and replays emissions strictly in subscription order: every element of A is emitted before any element of B. The result completes only after all sources have completed, and errors are propagated according to the delayError setting. Because inner values are queued until their turn, ordering is preserved at the cost of holding buffered elements in memory.
- Fetch several remote pages or shards in parallel but present results in a deterministic order
- Warm up slow sources concurrently while still emitting them ordered for the consumer
- Combine paginated API calls eagerly without interleaving page boundaries
- Replace concat when you need its ordering but can't afford its sequential subscription latency
Unlike merge, output order is guaranteed, but unlike concat the sources are subscribed immediately, so a fast later source (B) must be buffered in memory while a slow earlier source (A) is still producing. This can cause unbounded memory growth and back-pressure pressure if A is slow and B is large; concat avoids this by subscribing lazily one at a time.
val a = Flux.just("a1", "a2", "a3")
val b = Flux.just("b1", "b2", "b3")
Flux.mergeSequential(a, b)
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this snippet print, in order?
This example makes both behaviors visible at once: the doOnNext on each page logs when a value is produced, while row: logs when the merged stream actually emits it:
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
val page1 = Flux.just("order-1", "order-2")
.delayElements(Duration.ofMillis(100))
.doOnNext { println(" (page1 produced $it)") }
val page2 = Flux.just("order-3", "order-4")
.delayElements(Duration.ofMillis(30))
.doOnNext { println(" (page2 produced $it)") }
Flux.mergeSequential(page1, page2)
.doOnNext { println("row: $it") }
.blockLast()
}page2 is faster (30 ms vs 100 ms per element), so it produces order-3 and order-4 before page1 has emitted its first row โ that's the eager subscription at work. But mergeSequential holds those early values back: the output emits page1's rows first, and only once page1 completes do the buffered order-3 and order-4 flush out:
(page2 produced order-3) (page2 produced order-4) (page1 produced order-1) row: order-1 (page1 produced order-2) row: order-2 row: order-3 row: order-4
The ordering guarantee costs memory: a fast later source sits fully buffered while an earlier slow one is still producing. If sources are large or unbounded, prefer concat โ it subscribes lazily, one source at a time, and buffers nothing.
mergeComparing / mergePriority
mergeComparing is the merge step of merge-sort, reactive edition: hand it several sources that are each already sorted, plus a Comparator, and it interleaves them into one globally sorted Flux. At every step it peeks at the next pending value of each source and emits the smallest. mergePriority is the eager cousin: same comparator, but it picks among the values available right now instead of waiting for one from every lane.
fun <T> mergeComparing(comparator: Comparator<T>, vararg sources: Publisher<out T>): Flux<T>mergeComparing subscribes to all sources eagerly but, on each step, it buffers one pending value per source and emits the smallest according to the comparator โ so as long as every source keeps producing, the merged output is globally sorted. It must hold one outstanding element from each live source before it can pick, so a slow or silent source stalls emission. It completes only after every source completes, and errors as soon as any source errors.
- Merging several already-sorted streams (e.g. time-ordered event logs) into one sorted stream
- Combining sorted pages from multiple shards or partitions in order
- Ordered fan-in from per-key sources where each is monotonically increasing
- K-way merge of sorted database/file cursors
It only yields a globally sorted result if EACH source is itself already sorted by the same comparator โ it does not sort within a source. Because it waits for one value from every source before choosing, a source that is slow or never emits will block all output (head-of-line blocking); use mergePriorityComparing/mergePriority if you want to emit as values arrive without waiting.
val a = Flux.just(1, 4, 7, 9)
val b = Flux.just(2, 3, 6, 8)
Flux.mergeComparing(Comparator.naturalOrder(), a, b)
.subscribe { print(it.toString() + " ") }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. Given two already-sorted Flux lanes, what does this snippet print?
Two vendors stream their offers in ascending price order; we want a single ascending stream:
import reactor.core.publisher.Flux
fun main() {
val cheapVendor = Flux.just(3, 8, 15) // each feed is already sorted
val premiumVendor = Flux.just(1, 9, 12)
Flux.mergeComparing(compareBy<Int> { it }, cheapVendor, premiumVendor)
.subscribe { println("next best price: \$$it") }
}Each feed is sorted on its own: 3, 8, 15 and 1, 9, 12. mergeComparing compares the two heads, emits the smaller one ($1 from premiumVendor beats $3 from cheapVendor), advances that lane, and repeats โ weaving both lanes into one fully sorted price ladder that completes when both feeds are exhausted:
next best price: $1 next best price: $3 next best price: $8 next best price: $9 next best price: $12 next best price: $15
The output is only globally sorted if EVERY input is already sorted by the same comparator โ mergeComparing never sorts within a source. And because it must hold one pending value per lane before choosing, a slow or silent source stalls all output (head-of-line blocking); mergePriority trades away the strict ordering to avoid exactly that wait.
zip / zipWith
zip is the pairing combiner: it takes the 1st element from every source and combines them, then the 2nd from every source, and so on โ strictly by index, like a zipper closing. Each combination comes out as a Tuple, or as whatever your combinator lambda builds. The pace is set by the slowest source, and the whole stream completes as soon as the shortest source runs out. The instance form is names.zipWith(ages); and when one side is just an in-memory collection, zipWithIterable pairs against it directly without wrapping it in a Flux.
fun <T1, T2> zip(p1: Publisher<T1>, p2: Publisher<T2>): Flux<Tuple2<T1, T2>>zip emits in lockstep: it buffers one element from each source and only emits the Nth combined Tuple once every source has produced its Nth element, so the overall pace is bound by the slowest source. Elements are paired strictly by index (a1+b1, a2+b2), never interleaved or reordered, and a combinator function can replace the default Tuple. The zipped Flux completes as soon as any source completes, discarding any already-buffered but unpaired elements from the faster sources; an error from any source propagates immediately.
- Joining two parallel API calls into a single combined result object
- Pairing values positionally, e.g. zipping a stream of items with a stream of indices or timestamps
- Coordinating independent async sources that must advance together step by step
- Combining a request stream with a rate-limiting delay source (zipWith Flux.interval)
zip is gated by the slowest source and completes when the SHORTEST one completes, so sources of unequal length silently drop the leftover tail elements of the longer source โ the result length equals the shortest input. It is not combineLatest: it never re-emits on a single source's new value, it strictly waits for one fresh element from every source per output.
val letters = Flux.just("a", "b", "c")
val numbers = Flux.just(1, 2)
Flux.zip(letters, numbers) { l, n -> l + n }
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit?
Three parallel streams describe the same three people โ zip stitches the columns back into rows:
import reactor.core.publisher.Flux
fun main() {
val names = Flux.just("Alice", "Bob", "Charlie")
val ages = Flux.just(30, 25, 35)
val cities = Flux.just("NYC", "London", "Tokyo")
// Two sources -> Tuple2 (destructured in Kotlin)
Flux.zip(names, ages)
.subscribe { (name, age) -> println("$name is $age years old") }
// Three sources -> Tuple3
Flux.zip(names, ages, cities)
.map { t -> "${t.t1} (${t.t2}) from ${t.t3}" }
.subscribe { println(it) }
// zipWith: instance method with a combinator lambda
names.zipWith(ages) { name, age -> "$name: $age" }
.subscribe { println(it) }
}names, ages and cities emit independently. Flux.zip(names, ages) waits until it has one name AND one age, then emits the pair โ Kotlin destructures the Tuple2 right in the lambda. The three-source overload produces Tuple3s, which we format with map. Finally, zipWith shows the instance form where a combinator lambda replaces tuples entirely:
Alice is 30 years old Bob is 25 years old Charlie is 35 years old Alice (30) from NYC Bob (25) from London Charlie (35) from Tokyo Alice: 30 Bob: 25 Charlie: 35
Sources of different lengths lose data silently: zip completes when the shortest source completes and drops the longer sources' unpaired leftovers โ the result is always as long as the shortest input. If you want to keep combining after one side goes quiet, that's combineLatest's job, not zip's.
combineLatest
combineLatest keeps a "latest value" slot for every source, and each time ANY source emits, it recombines that fresh value with the latest cached value from all the others. It emits nothing until every source has spoken at least once, and after that it follows the rhythm of the most active source. That makes it the operator for live dashboards and derived state โ "recompute whenever anything changes".
fun <T1, T2, V> combineLatest(source1: Publisher<T1>, source2: Publisher<T2>, combinator: (T1, T2) -> V): Flux<V>combineLatest caches the most recently emitted value of every source and, whenever ANY source emits, re-runs the combinator using that fresh value plus the latest cached value from each of the others โ so output frequency tracks the fastest/most active source. It produces nothing until each source has emitted at least once (no priming value is waited for beyond the first), and it completes only after ALL sources complete, while any source error propagates immediately. In the marble in=[1,2,3] / out=[1A,2A,2B,3B,3C], source1 emits 1,2,3 and source2 emits A,B,C interleaved; every new arrival pairs with the latest seen on the other side.
- Recompute a derived value whenever any input changes (live form validation across fields)
- Merge latest readings from independent sensors/streams into one snapshot
- Reactive UI/config state where the newest value of each dependency matters
- Combine a slow-changing setting with a fast event stream
Unlike zip, it does NOT pair values one-to-one: a fast source's emissions are combined repeatedly against the same stale cached value from a slow source, so you can both drop and duplicate-pair values, and if any source never emits, the whole combined Flux emits nothing (it stays empty until every source has produced at least one value).
val nums = Flux.interval(Duration.ofMillis(300))
.map { it + 1 }.take(3) // 1, 2, 3
val letters = Flux.interval(Duration.ofMillis(500))
.map { "ABC"[it.toInt()] }.take(3) // A, B, C
Flux.combineLatest(nums, letters) { n, l -> "$n$l" }
.subscribe(::println)๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. nums emits 1, 2, 3 every 300 ms; letters emits A, B, C every 500 ms. What does combineLatest print?
A temperature probe reports every 300 ms and a humidity probe every 500 ms; the dashboard should refresh whenever either one updates:
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
val temperature = Flux.interval(Duration.ofMillis(300))
.map { "${20 + it}C" }
.take(4)
val humidity = Flux.interval(Duration.ofMillis(500))
.map { "${40 + it * 5}%" }
.take(3)
Flux.combineLatest(temperature, humidity) { t, h -> "$t / $h" }
.doOnNext { println("dashboard: $it") }
.blockLast()
}The first temperature (20C at 300 ms) has to wait โ humidity hasn't spoken yet, so nothing is emitted. At 500 ms humidity reports 40% and the dashboard renders its first frame, 20C / 40%. From then on every new reading from either probe re-fires with the other probe's cached value: 21C and 22C reuse the stale 40%, then 45% arrives and pairs with the cached 22C, and so on until both probes complete:
dashboard: 20C / 40% dashboard: 21C / 40% dashboard: 22C / 40% dashboard: 22C / 45% dashboard: 23C / 45% dashboard: 23C / 50%
firstWithSignal / firstWithValue
Sometimes you don't want to combine sources at all โ you want the fastest one, alone. Flux.firstWithSignal(a, b, โฆ) subscribes to all candidates, waits for the very first signal of any kind (a value, a completion, even an error), then mirrors that winning source exactly while cancelling all the others. firstWithValue runs the same race but only an actual value can win โ sources that complete empty or blow up are skipped. There is also an instance alias: a.or(b) is exactly firstWithSignal for two sources.
fun <T> Flux.firstWithSignal(vararg sources: Publisher<out T>): Flux<T> // also firstWithValue(...)firstWithSignal subscribes to all sources at once and picks the very first one to produce ANY signal (an onNext, onComplete, or onError). That winner is mirrored verbatim โ every subsequent signal it emits is replayed downstream โ while all other sources are immediately cancelled. firstWithValue is stricter: a source only wins by emitting an actual onNext value, so sources that complete-empty or error first are skipped rather than allowed to win. The instance method a.or(b) is a two-source alias for the signal race.
- Race a primary and a fallback endpoint and keep whichever responds first
- Hedged requests across replicas or regions to cut tail latency
- Pick the fastest of several caches/mirrors for the same data
- Use firstWithValue when an empty or failing source must not be allowed to win the race
With firstWithSignal the winner is decided by the FIRST signal of any kind, so a source that errors or completes-empty fastest wins and that error/empty becomes the whole result โ the slower source that would have produced data is cancelled. If you want to ignore empty/error sources and only race on real data, use firstWithValue (which errors only if every source fails).
val a = Flux.just("A1", "A2", "A3")
val b = Flux.just("B1", "B2")
Flux.firstWithSignal(a, b)
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. A and B are both subscribed at the same time. A emits its first value A1 slightly before B emits B1. What does the resulting Flux emit?
Race two mirrors of the same catalog and serve whichever answers first:
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
val euMirror = Flux.just("EU: catalog", "EU: prices")
.delayElements(Duration.ofMillis(300))
val usMirror = Flux.just("US: catalog", "US: prices")
.delayElements(Duration.ofMillis(100))
Flux.firstWithSignal(euMirror, usMirror)
.doOnNext { println("served -> $it") }
.blockLast()
}Both mirrors are subscribed simultaneously. The US mirror's first element lands after ~100 ms, well before the EU mirror's ~300 ms, so the US mirror wins: the result mirrors it verbatim โ both of its values, then its completion โ and the EU subscription is cancelled on the spot:
served -> US: catalog served -> US: prices
The trap with firstWithSignal is in its name: ANY signal wins, including an unhelpful one. A source that completes empty immediately "wins" with its complete signal, and the whole result is empty โ even though another source had real data on the way. firstWithValue exists precisely for that:
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
val slowButFull = Flux.just("backup-1", "backup-2")
.delayElements(Duration.ofMillis(200))
val instantButEmpty = Flux.empty<String>()
// The empty complete is the FIRST signal -> it wins, result is empty
Flux.firstWithSignal(slowButFull, instantButEmpty)
.doOnNext { println("signal race: $it") }
.blockLast()
println("firstWithSignal finished with no values")
// firstWithValue only lets real values win -> the backup gets through
Flux.firstWithValue(slowButFull, instantButEmpty)
.doOnNext { println("value race: $it") }
.blockLast()
}In the first race, the empty source's complete arrives instantly and beats the delayed backup โ blockLast returns with no values ever emitted. In the second, firstWithValue ignores the empty completion and keeps waiting for a real value, so the backup's two elements win through:
firstWithSignal finished with no values value race: backup-1 value race: backup-2
startWith
startWith prepends values in front of a Flux: subscribers receive the prefix immediately, then the source's own elements in their original order. It's the tool for seeding initial state โ a protocol handshake before payload frames, a "loadingโฆ" placeholder before data arrives, the current value before a stream of updates. You can prepend literal values, an Iterable, or a whole Publisher.
fun <T> Flux<T>.startWith(vararg values: T): Flux<T> // also startWith(Iterable<T>), startWith(Publisher<T>)startWith prepends the given value(s) or Publisher to the front of the source, emitting them first and only then replaying every element of the original Flux in order. The source's terminal signal is preserved: the combined sequence completes (or errors) exactly when the source does, after the prepended items and all source items have been delivered. If you prepend a Publisher, its elements are emitted to completion before the source is subscribed, so it is effectively a concat with the prefix on the left.
- Seed a stream with an initial/default value so subscribers get immediate state
- Emit a loading or placeholder value before async data arrives
- Prime a scan/reduce pipeline with a starting accumulator value
- Provide a default before a possibly-empty or slow upstream source
startWith always emits the prefix, even if the source later errors or is empty โ it does not replace or suppress source emissions, it only adds in front. Note the static Flux.startWith does not exist as a builder; use the instance method (or Flux.concat) โ and remember it prepends, the mirror operator concatWith appends.
val flux: Flux<Int> = Flux.just(1, 2, 3, 4)
.startWith(0)
flux.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit (in order) when subscribed?
A tiny wire protocol โ every connection must announce itself before data flows โ plus the cached-then-live pattern with a Publisher prefix:
import reactor.core.publisher.Flux
fun main() {
Flux.just("payload-a", "payload-b")
.startWith("HELLO", "AUTH-OK") // handshake frames go out first
.subscribe { println(it) }
// Prepend a whole Publisher: cached rows before live rows
val cached = Flux.just("cached-1", "cached-2")
Flux.just("live-1", "live-2")
.startWith(cached)
.subscribe { println(it) }
}The source Flux only knows about payload frames; startWith("HELLO", "AUTH-OK") injects the handshake in front, so the subscriber sees both handshake frames before any payload. The second pipeline prepends an entire Publisher: every cached row is replayed to completion before the live rows begin โ startWith(publisher) is effectively concat with the prefix on the left:
HELLO AUTH-OK payload-a payload-b cached-1 cached-2 live-1 live-2
Chained startWith calls stack in reverse: flux.startWith(1).startWith(0) emits 0, 1, โฆ because each call prepends in front of everything before it. And startWith always emits its prefix โ even if the source then errors or turns out empty; it adds, never replaces.
switchIfEmpty
switchIfEmpty is the reactive "if no resultsโฆ" clause. While the source emits anything at all, it is a transparent pass-through. But if the source completes without a single element, it subscribes to your fallback Publisher and relays that instead. Unlike defaultIfEmpty (which injects one fallback value), the fallback here is a full Publisher โ another query, a remote call, or a Mono.error that turns emptiness into a domain error.
fun <T> Flux<T>.switchIfEmpty(alternate: Publisher<out T>): Flux<T>While the source emits, switchIfEmpty is transparent: every element and an onError pass straight through. Only when the source terminates with onComplete having emitted zero elements does Reactor subscribe to the alternate Publisher and relay its signals instead. The fallback is cold and lazy โ it is only subscribed on the empty path, so for a non-empty source it is never touched.
- Return cached or default data when a query yields no rows
- Fall back to a remote service after an empty local lookup
- Emit a sensible default value instead of an empty stream
- Raise a domain error via Mono.error when nothing is found
switchIfEmpty only triggers on a truly empty completion โ if the source emits even one element and then errors, the alternate is NOT used; the error propagates. Also, because the fallback expression is evaluated when you build the chain, an eager call like switchIfEmpty(Mono.just(fetchFromDb())) runs fetchFromDb() up front every time; wrap side-effecting fallbacks in Mono.defer to keep them lazy.
val empty: Flux<String> = Flux.empty()
val alt = Flux.just("A", "B", "C")
empty.switchIfEmpty(alt)
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit to the subscriber?
A product search with a friendly empty-state message:
import reactor.core.publisher.Flux
fun main() {
val products = Flux.just("laptop", "tablet", "phone")
// A search that matches nothing -> the fallback kicks in
products
.filter { it.startsWith("z") }
.switchIfEmpty(Flux.just("no products found"))
.subscribe { println("search 'z': $it") }
// A search that matches -> the fallback is never subscribed
products
.filter { it.startsWith("t") }
.switchIfEmpty(Flux.just("no products found"))
.subscribe { println("search 't': $it") }
}The first search filters for names starting with "z" โ nothing matches, so the filtered Flux completes empty and switchIfEmpty switches to the fallback, emitting no products found. The second search does match (tablet), so the source emits normally and the fallback is never even subscribed:
search 'z': no products found search 't': tablet
The fallback ARGUMENT is evaluated eagerly when you assemble the chain: switchIfEmpty(Mono.just(expensiveCall())) runs expensiveCall() on every assembly, even when the source has data. Wrap side-effecting fallbacks in Mono.defer { โฆ } to keep them lazy. Also, only a truly empty completion triggers the switch โ a source that emits once and then errors keeps its error.
withLatestFrom
withLatestFrom is combineLatest with a steering wheel: only ONE side โ the Flux you call it on โ can trigger output. Every time that driver emits, its value is combined with the most recent value seen from the other Publisher; the other side alone never fires anything, it just keeps its "latest" slot fresh. The classic use is enriching each user event with the current state of something โ the active config, the form contents, a feature flag โ at the exact moment the event happens.
fun <T, U, R> Flux<T>.withLatestFrom(other: Publisher<U>, combinator: (T, U) -> R): Flux<R>The source Flux A is the pacemaker: every time A emits, it is paired with the most recent value seen from B and the combinator produces one output. Values from B are sampled, not queued โ if B emits several times between two A values, only the last is kept; if B has not emitted yet when A fires, that A value is silently dropped (no output). Output completes when A completes; B completing does not end the stream, and an error from either side propagates.
- Tag each event with current state, e.g. a click sampled with the latest form value
- Enrich a request stream with the newest config/feature-flag snapshot
- Throttle a fast secondary stream down to the cadence of a primary trigger
- Annotate sensor/UI events with the latest cached lookup value
Early A values are dropped โ until B has emitted at least once, every A emission produces nothing, so the output can start later (and be shorter) than the source. This is asymmetric: B's pace and completion are irrelevant; only A drives and terminates the result.
val clicks = Flux.interval(Duration.ofMillis(300))
.map { "click-$it" }.take(4)
val mode = Flux.just("A", "B")
.delayElements(Duration.ofMillis(500))
.concatWith(Flux.never())
clicks.withLatestFrom(mode) { c, m -> "$c/$m" }
.subscribe(::println)๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. Clicks arrive every 300 ms; the mode stream emits A at ~500 ms and B at ~1000 ms, then stays open. What is printed?
Tag every click with whatever UI mode is active when the click happens:
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
val clicks = Flux.interval(Duration.ofMillis(300))
.map { "click-$it" }
.take(5)
val mode = Flux.just("mode-A", "mode-B")
.delayElements(Duration.ofMillis(500))
.concatWith(Flux.never()) // keep the latest value alive
clicks.withLatestFrom(mode) { click, m -> "$click in $m" }
.doOnNext { println(it) }
.blockLast()
}Clicks arrive every 300 ms; the mode stream produces mode-A at 500 ms and mode-B at 1000 ms, then stays silent-but-alive thanks to Flux.never, so its latest value remains available. click-0 (at 300 ms) is dropped โ there is no mode yet to pair with. click-1 and click-2 get stamped with mode-A, clicks 3 and 4 with mode-B, and the output completes when the driver (clicks) completes โ the mode stream's lifecycle doesn't matter:
click-1 in mode-A click-2 in mode-A click-3 in mode-B click-4 in mode-B
Error Handling
Errors in Reactor are terminal: the moment an exception escapes an operator, the sequence stops, the subscription is cancelled upstream, and the onError signal races down to your subscriber. No more elements will ever arrive. That sounds harsh, but it keeps failure handling honest โ and Reactor gives you a whole family of operators to decide, declaratively, what happens next.
Think of them as the reactive equivalents of a try/catch block, each one encoding a different recovery strategy:
- onErrorReturn โ replace the error with one fallback value, then complete
- onErrorResume โ switch to a whole fallback Publisher (a cache, a secondary service)
- onErrorMap โ translate the error into a clearer, domain-specific exception
- onErrorContinue / onErrorStop โ drop the failing element and keep going (with fine print)
- onErrorComplete โ swallow the error and end the stream normally
- retry / retryWhen โ resubscribe from scratch, optionally with exponential backoff
onErrorResume
fun <T> Flux<T>.onErrorResume(fallback: (Throwable) -> Publisher<out T>): Flux<T>All onNext values emitted before the error pass through unchanged (1, 2, 3). When the upstream signals onError, the error is caught, the fallback function is invoked with the Throwable, and the operator subscribes to the returned Publisher, emitting its items (A, B) seamlessly in sequence. The fallback's terminal signal becomes the stream's terminal: a normal completion replaces the original error, so downstream never sees the original exception.
- Return cached or default data when a remote call fails
- Branch recovery on error type (retryable vs fatal) via instanceof checks
- Fall back to a secondary service or degraded response
- Convert a failing reactive call into a graceful, non-erroring stream
It only intercepts onError, never onComplete, and the values already emitted before the failure are NOT rolled back or replayed, so consumers may see partial output (1, 2, 3) followed by the fallback. If you only want a single static value use onErrorReturn; onErrorResume needs a whole Publisher. The fallback lambda runs eagerly building the Publisher even when no error occurs unless you wrap creation inside Mono.defer/Flux.defer.
val fallback = Flux.just("A", "B")
Flux.just(1, 2, 3)
.concatWith(Flux.error(RuntimeException("boom")))
.onErrorResume { fallback }
.subscribe(::println)๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. Given this Reactor pipeline, what does the subscriber receive?
onErrorResume is the plan B of reactive pipelines. You give it a function that receives the error and returns a fallback Publisher; when the source fails, the operator cancels it, calls your function, and seamlessly subscribes the downstream to whatever it returned. The subscriber never sees the original error โ it just keeps receiving values, now coming from the fallback. It is the reactive cousin of a catch block that returns a backup result: "if the live price feed dies, serve the cached prices".
import reactor.core.publisher.Flux
fun main() {
val livePrices = Flux.just(101.5, 102.25)
.concatWith(Flux.error(IllegalStateException("price feed down")))
val cachedPrices = Flux.just(99.0, 99.5)
livePrices
.onErrorResume { e ->
println("switching to cache: ${e.message}")
cachedPrices
}
.subscribe(
{ println("price: $it") },
{ println("error: ${it.message}") },
{ println("stream completed") }
)
}We have a live price feed that emits two quotes and then dies with an IllegalStateException, plus a Flux of cached prices standing by. The first two prices flow through untouched. When the error signal arrives, onErrorResume catches it, prints a message, and subscribes to cachedPrices โ the subscriber receives 99.0 and 99.5 as if nothing had happened, and since the fallback completes normally, the whole stream completes normally too.
price: 101.5 price: 102.25 switching to cache: price feed down price: 99.0 price: 99.5 stream completed
There is also an overload that takes an exception type (or a predicate), so you recover only from the failures you expect and let everything else propagate:
import reactor.core.publisher.Flux
fun main() {
Flux.just("valid", "BOOM", "also-valid")
.map {
if (it == "BOOM") throw IllegalArgumentException("bad input")
it
}
.onErrorResume(IllegalArgumentException::class.java) {
Flux.just("recovered-from-bad-input")
}
.subscribe { println(it) }
}valid recovered-from-bad-input
Choosing a fallback: onErrorReturn when one static value is enough; onErrorResume when you need a whole Publisher โ a cache lookup, a second service, or even Flux.empty() to just end quietly.
onErrorReturn
fun <T> Flux<T>.onErrorReturn(fallbackValue: T): Flux<T>Passes every onNext through unchanged until an onError arrives. When the source errors, the error signal is swallowed and replaced by a single emission of the fallback value, immediately followed by onComplete โ so the stream terminates normally instead of failing. Items emitted before the error are kept; nothing after the error is ever produced.
- Return a safe default (0, empty list, cached value) when a call fails
- Make a non-critical pipeline degrade gracefully instead of propagating the error
- Provide a sentinel value downstream so subscribers never see onError
- Guard optional enrichment steps where any failure should just yield a neutral result
It replaces the error with one fixed value computed up front โ it cannot inspect the exception or call another publisher. The fallback only fires on error, never on an empty source (use defaultIfEmpty/switchIfEmpty for that). The overload with a Class/Predicate filter rethrows errors that don't match instead of returning the fallback.
Flux.just(1, 2, 3)
.concatWith(Flux.error(RuntimeException("boom")))
.onErrorReturn(0)
.subscribe { value -> println("emitted=" + value) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit to the subscriber?
onErrorReturn is the simplest recovery tool in the box: when any error arrives, emit one pre-baked fallback value and complete. There is no lambda and no fallback publisher โ just a constant you decided on up front. It is ideal for the "if this calculation fails, call it -1 / zero / empty" class of problems.
import reactor.core.publisher.Flux
fun main() {
Flux.just(200, 50, 0, 25)
.map { 1000 / it } // 1000 / 0 throws ArithmeticException
.onErrorReturn(-1)
.subscribe(
{ println("units per pallet: $it") },
{ println("error: ${it.message}") },
{ println("done") }
)
}We are dividing 1,000 units of stock across different pallet sizes. The first two divisions succeed (5 and 20), but the third pallet size is 0 and the division throws ArithmeticException. onErrorReturn swallows it, emits the sentinel -1, and completes the stream. Notice that the fourth pallet size (25) is never processed โ the error had already terminated the source; the fallback only replaces the terminal signal, it does not resume the sequence.
units per pallet: 5 units per pallet: 20 units per pallet: -1 done
Like onErrorResume, it has class- and predicate-filtered overloads โ the fallback only fires for matching errors, anything else propagates untouched:
import reactor.core.publisher.Flux
fun main() {
Flux.just("ok", "fail")
.map {
if (it == "fail") throw IllegalStateException("boom")
it
}
.onErrorReturn(IllegalStateException::class.java, "fallback")
.subscribe { println(it) }
}ok fallback
onErrorMap
fun <T> Flux<T>.onErrorMap(mapper: (Throwable) -> Throwable): Flux<T>onErrorMap intercepts the onError terminal signal and runs your mapper to replace the original Throwable with a new one, then re-emits onError carrying that new exception. All onNext values already emitted before the error pass through untouched and in order; only the error type/message changes, so the sequence still terminates exceptionally (it never recovers or emits a fallback value). An overload accepts a Class/Predicate to remap only matching exception types and let others propagate unchanged.
- Wrap low-level exceptions (IOException, SQLException) into a domain AppException
- Add context or a clearer message before the error reaches subscribers
- Translate third-party/library errors into your own exception hierarchy
- Normalize error types so downstream onErrorResume/retry can match them
onErrorMap does NOT recover: the stream always ends with an error. If you want to keep going with a fallback value or stream, use onErrorReturn/onErrorResume instead. Also, if your mapper itself throws, that thrown exception becomes the propagated error; and always chain the original cause (new AppException(e)) so you don't lose the stack trace.
Flux.just(1, 2, 3)
.concatWith(Mono.error(IOException("disk")))
.onErrorMap { e -> AppException(e) }
.subscribe(
{ v -> println("next: " + v) },
{ err -> println("error: " + err.javaClass.simpleName) }
)๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. A Flux emits 1, 2, 3 and then fails with an IOException. With .onErrorMap wrapping the error in AppException, what does the stream emit?
onErrorMap does not recover from anything โ it translates. The sequence still fails, but instead of leaking a low-level IllegalStateException or SQLException to every caller, you wrap it into an exception your domain understands. It is the reactive equivalent of catch (e) { throw OrderException(..., e) }, and it keeps your error contracts clean at module boundaries.
import reactor.core.publisher.Flux
class OrderException(msg: String, cause: Throwable) : RuntimeException(msg, cause)
fun main() {
Flux.just("ord-1", "ord-2", "bad-row")
.map { if (it == "bad-row") throw IllegalStateException("corrupt row") else it }
.onErrorMap { e -> OrderException("order import failed: ${e.message}", e) }
.subscribe(
{ println("processed $it") },
{ println("caught ${it::class.simpleName}: ${it.message} (cause: ${it.cause?.let { c -> c::class.simpleName }})") }
)
}We import three order rows; the third one is corrupt, so map throws an IllegalStateException. onErrorMap intercepts the error signal on its way downstream and replaces the exception with an OrderException, keeping the original as its cause โ never drop the cause, or you lose the stack trace. The two rows already emitted pass through untouched, then the subscriber's error handler fires with the domain exception, not the low-level one.
processed ord-1 processed ord-2 caught OrderException: order import failed: corrupt row (cause: IllegalStateException)
onErrorContinue / onErrorStop
fun <T> Flux<T>.onErrorContinue(errorConsumer: (Throwable, Any?) -> Unit): Flux<T> // fun <T> Flux<T>.onErrorStop(): Flux<T>When a fluent operator upstream (like map or filter) throws while processing a single element, onErrorContinue catches it, drops that one offending element, invokes your BiConsumer with the error and the value that caused it, and lets the stream keep emitting subsequent elements instead of terminating. In the marble, 2 throws inside an upstream operator, so it is dropped and the consumer is notified, while 1, 3, 4 pass through and the stream still completes normally. It does not retry or substitute a value โ the element simply disappears from the output. Its companion onErrorStop does the opposite: placed in the chain, it resets the hook so operators above it regain normal terminal-error semantics even when a downstream onErrorContinue is present.
- Batch processing where a few malformed records shouldn't abort the whole pipeline
- Parsing or mapping a stream of mixed-quality data, logging bad items as you go
- Best-effort transformations where dropping failures is acceptable
- onErrorStop: shield library-internal segments from a caller's onErrorContinue
onErrorContinue is an unusual, somewhat unsafe operator: it changes the behavior of UPSTREAM operators rather than acting at its own position, and only works with operators that explicitly support it (map, filter, flatMap, etc.). Operators that wrap inner sources may not propagate it as expected, and the implicit upstream coupling makes pipelines fragile and hard to reason about. Reactor maintainers recommend handling errors locally (onErrorResume returning Mono.empty() inside a flatMap's inner publisher) instead โ and guarding exposed sequences with onErrorStop when you don't want callers altering them.
Flux.just(1, 2, 3, 4)
.map { n -> if (n == 2) throw RuntimeException("bad") else n }
.onErrorStop()
.onErrorContinue { _, _ -> }
.subscribe(
{ println(it) },
{ println("error: " + it.message) }
)๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. This pipeline mixes onErrorStop with a downstream onErrorContinue. What does the subscriber see?
Everything so far treats errors as terminal โ one bad element kills the stream, and all you choose is what happens after. onErrorContinue is different: it reaches back into compatible upstream operators (map, filter, flatMapโฆ) and changes how they fail, so a throwing element is dropped, reported to your callback, and the stream simply keeps going. For a batch of mixed-quality data โ say, parsing prices out of a CSV โ that per-element tolerance is exactly what you want.
import reactor.core.publisher.Flux
fun main() {
Flux.just("12", "7", "x9", "20", "3z")
.map { it.toInt() } // "x9" and "3z" throw NumberFormatException
.onErrorContinue { err, bad ->
println("skipping bad token '$bad': ${err.message}")
}
.reduce(0) { acc, n -> acc + n }
.subscribe { println("total = $it") }
}We parse five tokens into integers. "x9" and "3z" both throw NumberFormatException inside map, but the downstream onErrorContinue instructs map to hand each failure to our callback instead of terminating, and the failing tokens are dropped. The valid numbers (12, 7, 20) continue down to reduce, which sums them to 39.
skipping bad token 'x9': For input string: "x9" skipping bad token '3z': For input string: "3z" total = 39
The magic has a flip side: onErrorContinue acts at a distance. Any operator upstream of it โ including ones inside code you do not own โ may silently change behavior. onErrorStop is the antidote: placed in the chain, it resets the semantics so the operators above it fail normally again, no matter what a downstream onErrorContinue says.
import reactor.core.publisher.Flux
fun main() {
// Without onErrorStop: the downstream onErrorContinue reaches back
// into map, so the bad element is dropped and the stream survives.
Flux.just(1, 2, 0, 4)
.map { 10 / it }
.onErrorContinue { e, v -> println("A: dropped $v (${e.message})") }
.subscribe({ println("A: $it") }, { println("A: error ${it.message}") })
// With onErrorStop: the segment above it keeps normal error semantics โ
// the division by zero terminates the stream despite onErrorContinue.
Flux.just(1, 2, 0, 4)
.map { 10 / it }
.onErrorStop()
.onErrorContinue { e, v -> println("B: dropped $v (${e.message})") }
.subscribe({ println("B: $it") }, { println("B: error ${it.message}") })
}Both pipelines divide 10 by each element and hit a division by zero. In pipeline A, onErrorContinue reaches up into map: the 0 is dropped and 4 still produces 2. In pipeline B, the onErrorStop between them acts as a firewall โ map keeps its normal semantics, so the error terminates the stream and 4 is never processed.
A: 10 A: 5 A: dropped 0 (/ by zero) A: 2 B: 10 B: 5 B: error / by zero
Caveat: onErrorContinue is operator-support-dependent โ it only works where the upstream operator explicitly implements it โ and it silently changes upstream behavior, even inside third-party code. The Reactor team's recommendation is to handle errors locally with onErrorResume inside flatMap instead. And if you build a Flux inside a library, consider guarding it with onErrorStop so a caller's onErrorContinue cannot rewrite your internals.
Here is that preferred pattern in action โ the same "skip the bad ones" result, but with local, explicit recovery that works with every operator:
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
fun main() {
// Same "skip the bad ones" result, but the recovery is local and explicit:
// each element gets its own tiny pipeline with its own error handling.
Flux.just("12", "7", "x9", "20", "3z")
.flatMap { token ->
Mono.fromCallable { token.toInt() }
.doOnError { println("could not parse '$token': ${it.message}") }
.onErrorResume { Mono.empty() } // skip just this element
}
.subscribe { println("parsed: $it") }
}Each token becomes its own tiny inner pipeline: Mono.fromCallable attempts the parse, doOnError logs the failure, and onErrorResume swaps the failed Mono for Mono.empty(), so the element just vanishes from the merged output. Nothing reaches across operator boundaries โ every piece of the recovery is visible right where it happens.
parsed: 12 parsed: 7 could not parse 'x9': For input string: "x9" parsed: 20 could not parse '3z': For input string: "3z"
onErrorComplete
fun <T> Flux<T>.onErrorComplete(): Flux<T> // also onErrorComplete(Class<? extends Throwable>) / onErrorComplete(Predicate<? super Throwable>)All onNext values pass through unchanged and in order. The moment an onError signal arrives, onErrorComplete swallows the throwable and emits onComplete in its place, so the downstream sees a normal, successful termination instead of the error. The overloads let you complete only for errors matching a given exception type or predicate; non-matching errors are still propagated.
- Treat an expected/recoverable failure as a clean end of stream
- Stop a polling or retry loop quietly when a specific exception occurs
- Make best-effort side-effect pipelines terminate normally on benign errors
- Complete only on a known exception type while letting fatal errors surface
It hides the error entirely: downstream never learns a failure happened and you emit zero fallback values, so a consumer expecting at least one item may silently get an empty completion. Use the type/predicate overloads to avoid masking unexpected fatal errors.
Flux.just(1, 2, 3)
.concatWith(Mono.error(RuntimeException("boom")))
.onErrorComplete()
.subscribe(::println)๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit to its subscriber?
Sometimes the most graceful way to fail is to pretend you finished. onErrorComplete swallows the error signal and replaces it with onComplete, so the subscriber sees a clean end of stream instead of an exception. It suits best-effort side streams โ heartbeats, notifications, metrics โ where a failure should quietly close the pipe rather than crash anything.
import reactor.core.publisher.Flux
fun main() {
Flux.just("ping", "ping", "FAIL", "ping")
.map { if (it == "FAIL") throw IllegalStateException("sensor lost") else it }
.onErrorComplete() // swallow the error, complete gracefully
.subscribe(
{ println("heartbeat: $it") },
{ println("error: $it") },
{ println("stream closed cleanly") }
)
}The sensor emits two heartbeats and then throws when the connection is lost. onErrorComplete intercepts the error: the subscriber's error handler never fires, and the completion handler prints "stream closed cleanly" instead. Note that the fourth "ping" is never seen โ the source had already terminated; onErrorComplete only rewrites the terminal signal, it does not resurrect the stream. Type- and predicate-filtered overloads let you complete only on expected errors while fatal ones still surface.
heartbeat: ping heartbeat: ping stream closed cleanly
retry / retryWhen
fun <T> Flux<T>.retry(numRetries: Long): Flux<T> // fun <T> Flux<T>.retryWhen(spec: Retry): Flux<T>On an onError signal, retry discards the error and resubscribes to the source from scratch, re-emitting everything from the beginning (the source is cold, so values already seen are replayed). It allows up to N resubscriptions; if the source errors more than N times, the final error is propagated downstream. A normal onComplete passes through untouched and never triggers a retry.
- Recovering from transient network or I/O failures (timeouts, flaky HTTP calls)
- Retrying idempotent operations like GET requests or DB reads
- Wrapping unreliable upstream services where a quick re-attempt usually succeeds
- Use retryWhen with Retry.backoff for spaced-out retries under load
retry replays from the source, so any values already emitted before the error are emitted AGAIN โ downstream sees duplicates (in=[1,x,1,2,ok] yields out=[1,1,2,ok]). It only works on cold publishers; on a hot source resubscribing won't replay past data. Also, immediate retries with no delay can hammer a failing service โ prefer retryWhen(Retry.backoff(...)) for backoff.
val attempt = AtomicInteger(0)
Flux.defer {
if (attempt.getAndIncrement() == 0)
Flux.concat(Flux.just(1), Flux.error(RuntimeException("boom")))
else
Flux.just(1, 2)
}.retry(1)
.subscribe(
{ println("next=" + it) },
{ println("error") },
{ println("complete") }
)๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux print, and how does it terminate?
retry embodies the most optimistic error strategy: just try again. On error, it throws away the failed subscription and resubscribes to the source from scratch, up to N times. Because the source restarts from the beginning, any values it emitted before failing are emitted again โ worth remembering for anything that is not idempotent.
import reactor.core.publisher.Flux
var attempt = 0
fun flakyOrderFeed(): Flux<String> = Flux.defer {
attempt++
println("connecting to order feed (attempt $attempt)")
if (attempt < 3) Flux.error(RuntimeException("connection reset"))
else Flux.just("order-701", "order-702")
}
fun main() {
flakyOrderFeed()
.retry(3) // up to 3 extra subscriptions
.subscribe(
{ println("received: $it") },
{ println("gave up: ${it.message}") },
{ println("feed completed") }
)
}Flux.defer builds a fresh feed for every subscription; the shared counter makes the first two attempts fail with "connection reset". retry(3) silently consumes both errors and resubscribes each time. The third attempt succeeds, streams both orders, and completes. Had the feed failed more than three times, the last error would have reached the subscriber's "gave up" handler.
connecting to order feed (attempt 1) connecting to order feed (attempt 2) connecting to order feed (attempt 3) received: order-701 received: order-702 feed completed
Retrying immediately can hammer a service that is already struggling. retryWhen accepts a Retry spec that controls how many times and โ crucially โ when to retry: Retry.backoff gives you exponential backoff with optional jitter, a maximum delay, and an error filter.
import reactor.core.publisher.Flux
import reactor.util.retry.Retry
import java.time.Duration
var attempt = 0
fun flakyOrderFeed(): Flux<String> = Flux.defer {
attempt++
println("connecting (attempt $attempt) at ${System.currentTimeMillis() - t0}ms")
if (attempt < 3) Flux.error(RuntimeException("connection reset"))
else Flux.just("order-701", "order-702")
}
val t0 = System.currentTimeMillis()
fun main() {
flakyOrderFeed()
.retryWhen(
Retry.backoff(5, Duration.ofMillis(100)) // 100ms, 200ms, 400ms...
.maxBackoff(Duration.ofSeconds(2))
.filter { it is RuntimeException } // only retry these
)
.subscribe(
{ println("received: $it") },
{ println("gave up: ${it.message}") },
{ println("feed completed") }
)
Thread.sleep(2000) // keep the JVM alive while the backoff timers run
}Same flaky feed, but now each retry waits โ about 100 ms before the second attempt, about 200 ms before the third โ until it succeeds. The filter restricts retrying to RuntimeException; anything else would propagate immediately. When the retry budget runs out, retryWhen wraps the last failure in a "retries exhausted" error instead of retrying forever.
connecting (attempt 1) at 183ms connecting (attempt 2) at 302ms connecting (attempt 3) at 510ms received: order-701 received: order-702 feed completed
Peeking / Side Effects
Every operator you have met so far changes the stream somehow โ mapping values, filtering them out, merging sources. The doOn* family does the opposite: it lets you watch a pipeline without touching it. Whatever you log, count or measure inside these hooks, the exact same values keep flowing to the subscriber, in the exact same order.
There is a hook for every lifecycle event: doOnSubscribe (someone subscribed), doOnRequest (demand arrived), doOnNext (a value passed by), doOnComplete / doOnError (how it ended), doOnCancel (someone hung up) and doFinally (it is over, no matter how). Together they are Reactor's observability toolkit โ logging, metrics, tracing and resource cleanup all live here.
import reactor.core.publisher.Flux
fun main() {
Flux.just("pay-001", "pay-002", "pay-003")
.doOnSubscribe { println("[lifecycle] payment stream started") }
.doOnNext { println("[audit] processing $it") }
.doOnComplete { println("[lifecycle] all payments processed") }
.subscribe { println("charged $it") }
}We subscribe to a stream of three payments. doOnSubscribe fires once when the subscription is established, doOnNext fires for every payment just before the subscriber receives it, and doOnComplete fires once at the very end. The subscriber itself only prints "charged โฆ" โ the hooks observed everything and altered nothing.
[lifecycle] payment stream started [audit] processing pay-001 charged pay-001 [audit] processing pay-002 charged pay-002 [audit] processing pay-003 charged pay-003 [lifecycle] all payments processed
doOnNext
fun <T> Flux<T>.doOnNext(onNext: (T) -> Unit): Flux<T>doOnNext registers a side-effect Consumer that runs for every onNext signal as it travels downstream. For each value the Consumer is invoked first, then the exact same item is propagated unchanged to subscribers โ values are never modified, dropped, or reordered. It is transparent to terminal signals: onComplete and onError pass straight through without triggering the callback (use doOnComplete/doOnError for those), and the callback runs on whatever thread emits the value.
- Logging or tracing each emitted value during a pipeline
- Updating metrics or counters per item without altering the stream
- Debugging to inspect what flows through a stage
- Triggering fire-and-forget side effects (e.g. caching, auditing)
doOnNext is for side-effects only โ it ignores its return value, so it cannot transform items (use map for that). If the Consumer throws, the error is propagated as an onError signal that cancels the sequence, so keep the callback safe and non-blocking; blocking work here stalls the emitting thread.
Flux.just(1, 2, 3, 4)
.doOnNext { log(it) }
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. Given this Reactor pipeline, what does the Flux emit downstream?
doOnNext is the workhorse of the family: it runs a callback for every element that travels through that point of the pipeline, then forwards the exact same element downstream. Think of it as a wiretap โ ideal for logging an order on its way to persistence, incrementing a metric, or feeding an audit trail.
Because it sits between two operators, placement matters: a doOnNext before a map sees the raw values, one after it sees the transformed ones. That also makes it a great debugging probe for any stage of a chain.
import reactor.core.publisher.Flux
fun main() {
Flux.just("order-1", "order-2", "order-3")
.doOnNext { println("Processing: $it") }
.map { it.uppercase() }
.doOnNext { println("Transformed: $it") }
.subscribe { println("Final: $it") }
}We have a stream of three order ids. The first doOnNext sees each raw id, map uppercases it, and the second doOnNext sees the transformed value just before the subscriber prints the final result. Same three elements at every stage โ only observed, never modified.
Processing: order-1 Transformed: ORDER-1 Final: ORDER-1 Processing: order-2 Transformed: ORDER-2 Final: ORDER-2 Processing: order-3 Transformed: ORDER-3 Final: ORDER-3
A more realistic setup: audit every user event while only reacting to one of them.
import reactor.core.publisher.Flux
fun main() {
Flux.just("login", "view_cart", "checkout", "logout")
.doOnNext { event -> println("[audit] user event -> $event") }
.filter { it == "checkout" }
.subscribe { println("triggering payment for: $it") }
}Every event passes through the audit hook, including the ones filter later drops. The hook sits above the filter, so logout is still audited even though the subscriber only ever sees checkout.
[audit] user event -> login [audit] user event -> view_cart [audit] user event -> checkout triggering payment for: checkout [audit] user event -> logout
doOnNext ignores the lambda's return value, so it cannot transform elements โ use map for that. And if the callback throws, the exception is propagated as an onError that terminates the sequence, so keep hooks cheap, safe and non-blocking.
doOnError
fun <T> Flux<T>.doOnError(onError: (Throwable) -> Unit): Flux<T>doOnError registers a side-effect callback that fires exactly when the source terminates with an onError signal, receiving the Throwable just before it is propagated downstream. It is a transparent peek: it never swallows, replaces, or recovers from the error, so the same exception still flows to the next operator and ultimately to the subscriber's error handler. The callback runs synchronously on whatever thread emitted the error, and onNext/onComplete signals pass through untouched.
- Logging or recording the exception before it bubbles up
- Emitting metrics or incrementing error counters
- Triggering cleanup or alerting side-effects on failure
- Adding tracing/breadcrumbs without altering the error flow
doOnError only observes; it does NOT handle or recover. The error still terminates the stream and reaches the subscriber, so an unhandled exception thrown inside the callback itself is fatal and gets combined (via Exceptions.addThrowable) with the original. Use onErrorResume/onErrorReturn/onErrorComplete to actually recover.
Flux.just(1, 2)
.concatWith(Flux.error(RuntimeException("boom")))
.doOnError { log(it) }
.subscribe(
{ v -> println("next=" + v) },
{ e -> println("error=" + e.message) }
)๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. Given the Flux below, what does the subscriber observe?
doOnError is the error-side wiretap: when the sequence terminates with an exception, the callback sees the Throwable an instant before it continues downstream. It never swallows the error โ after your log line or metric increment, the exact same exception keeps falling toward the subscriber (or toward a recovery operator like onErrorReturn).
There is also a class-filtered overload โ doOnError(IllegalStateException::class.java) { โฆ } โ that only observes errors of a matching type.
import reactor.core.publisher.Flux
fun main() {
Flux.just(1, 2, 0, 4)
.map { 10 / it }
.doOnError { println("Error detected: ${it.message}") }
.onErrorReturn(-1)
.subscribe { println("Result: $it") }
// Filter by error type
Flux.error<String>(IllegalStateException("bad state"))
.doOnError(IllegalStateException::class.java) {
println("IllegalState caught: ${it.message}")
}
.onErrorReturn("recovered")
.subscribe { println(it) }
}The first pipeline divides 10 by each element and blows up on the 0. doOnError logs the ArithmeticException, then onErrorReturn(-1) โ placed below the hook โ turns the failure into a fallback value. The second pipeline shows the class-filtered variant observing only IllegalStateException before recovering.
Result: 10 Result: 5 Error detected: / by zero Result: -1 IllegalState caught: bad state recovered
In production the typical pairing is metrics + fallback: record the failure, then let a recovery operator keep the stream alive.
import reactor.core.publisher.Flux
fun main() {
Flux.just(100, 50, 0, 25)
.map { 1000 / it } // divide-by-zero on the 0
.doOnError { err -> println("[metrics] recording failure: ${err.message}") }
.onErrorReturn(-1)
.subscribe { println("quota = $it") }
}We compute 1000 / n for each quota entry. When the 0 arrives, map throws, doOnError records the failure for metrics, and onErrorReturn emits -1 โ so the subscriber sees two healthy quotas, then the fallback. Note the last element (25) is never processed: the error had already terminated the source.
quota = 10 quota = 20 [metrics] recording failure: / by zero quota = -1
Observing is not recovering: after doOnError the stream is still dead. To keep going you need onErrorReturn / onErrorResume / retry โ and they must sit downstream of the hook, otherwise the error never reaches it.
doOnComplete / doOnTerminate / doAfterTerminate
fun <T> Flux<T>.doOnComplete(onComplete: () -> Unit): Flux<T> // also doOnTerminate(() -> Unit), doAfterTerminate(() -> Unit)doOnComplete registers a side-effect callback that fires exactly once when the upstream emits its onComplete signal, after the last value has passed through. It is purely observational: every value flows downstream unchanged and the completion signal is propagated as-is. It is skipped entirely if the sequence ends with onError or is cancelled. Its two siblings widen the trigger: doOnTerminate fires on completion OR error, just before the terminal signal is forwarded downstream, while doAfterTerminate fires on both endings but only after the subscriber has already received the terminal signal.
- Logging or metrics when a stream finishes successfully
- Releasing or closing a resource only on clean completion
- Triggering a follow-up action after all items were emitted
- Debugging to confirm a sequence actually completed
doOnComplete does NOT run on error or cancellation, so it is unreliable for cleanup. If you must run logic on every termination path, use doFinally (covers complete, error, and cancel); use doOnTerminate for complete-or-error only. Also, an empty Flux still completes, so the callback fires even when zero values were emitted.
Flux.just("A", "B", "C", "D")
.doOnComplete { log("done") }
.subscribe { value -> log("next: " + value) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit to its subscriber, and what does doOnComplete do?
Three hooks watch how a sequence ends. doOnComplete fires only on a clean, successful completion. doOnTerminate fires on either completion or error, just before the terminal signal is forwarded downstream. doAfterTerminate also covers both endings, but runs after the subscriber has already received the terminal signal โ handy when the side effect must not delay the notification itself.
None of the three fires on cancellation. If your logic must run when someone hangs up mid-stream, you want doOnCancel or doFinally.
import reactor.core.publisher.Flux
fun main() {
Flux.just("invoice-1", "invoice-2")
.doOnComplete { println("1. doOnComplete โ success only") }
.doOnTerminate { println("2. doOnTerminate โ before the terminal signal is forwarded") }
.doAfterTerminate { println("3. doAfterTerminate โ after the subscriber saw it") }
.subscribe(
{ println("processed $it") },
{ err -> println("failed: ${err.message}") },
{ println("subscriber onComplete") }
)
}Both invoices are processed, then the source completes. Watch the ordering in the output: doOnComplete and doOnTerminate run before the subscriber's own completion handler, while doAfterTerminate runs after it โ the terminal signal has already been delivered by the time it fires.
processed invoice-1 processed invoice-2 1. doOnComplete โ success only 2. doOnTerminate โ before the terminal signal is forwarded subscriber onComplete 3. doAfterTerminate โ after the subscriber saw it
The classic use case for doOnComplete is a one-time action after a batch finishes successfully:
import reactor.core.publisher.Flux
fun main() {
Flux.just("row1.csv", "row2.csv", "row3.csv")
.doOnNext { println("importing $it") }
.doOnComplete { println("import finished, flushing to DB") }
.subscribe()
}We import three CSV rows; when the source completes, doOnComplete runs the one-time flush. Had any row failed, the flush would have been skipped entirely โ completion hooks are success-only, which is exactly what you want for a commit-style step.
importing row1.csv importing row2.csv importing row3.csv import finished, flushing to DB
doOnSubscribe / doFirst
fun <T> Flux<T>.doOnSubscribe(onSubscribe: (Subscription) -> Unit): Flux<T> // also doFirst(runnable: () -> Unit): Flux<T>doOnSubscribe runs your callback exactly once, the moment a downstream subscriber establishes the Subscription, before any onNext/onComplete/onError flows back โ it receives the Subscription so you can inspect or even request from it. It is purely a side-effect peek: every original signal (A, B, C, complete/error) passes through untouched in order. doFirst is similar but fires even earlier โ it is the very first thing executed when subscription begins, running synchronously on the subscribing thread; chaining multiple doFirst calls runs them in reverse (bottom-up) order, whereas multiple doOnSubscribe run top-down.
- Logging or starting a timer/metric exactly when a stream goes live
- Initializing or acquiring a resource (open connection, set a flag) at subscribe time
- Tracing 'who subscribed and when' for debugging cold-publisher behavior
- Manually calling subscription.request(n) for custom backpressure demand
doOnSubscribe runs on the thread that triggers the subscription (the subscribing thread), NOT on the thread where elements are later emitted โ subscribeOn affects emission/request threading but the subscribe-time callback ordering can still surprise you. Also, since it is a side-effect hook it must never throw or block; an exception here is treated as a fatal/bubbled error rather than a normal onError.
Flux.just("A", "B", "C")
.doOnSubscribe { log("go") }
.subscribe { value -> log(value) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. Given this Reactor pipeline, what does it emit downstream, and when does "go" get logged?
Nothing happens in a cold Flux until somebody subscribes โ and these two hooks let you mark that exact moment. doOnSubscribe runs when the Subscription is established (it even receives the Subscription object), making it the classic place to log "query started" or start a latency timer. doFirst runs even earlier: before the subscription signal travels upstream, so it is literally the first code executed in the chain.
One famous quirk: multiple doFirst calls execute bottom-up (reverse declaration order), while multiple doOnSubscribe hooks run top-down. The example makes it visible.
import reactor.core.publisher.Flux
fun main() {
Flux.just(1, 2, 3)
.doFirst { println("3. doFirst (closest to subscribe)") }
.doFirst { println("2. doFirst (middle)") }
.doFirst { println("1. doFirst (furthest)") }
.doOnSubscribe { println("4. Subscribed!") }
.subscribe { println("Value: $it") }
}Subscribing walks the chain from the bottom up, so the doFirst declared last ("1. furthest") executes first and the one closest to the source runs last. Then doOnSubscribe fires once the Subscription is established, and only after all of that do the values start flowing.
1. doFirst (furthest) 2. doFirst (middle) 3. doFirst (closest to subscribe) 4. Subscribed! Value: 1 Value: 2 Value: 3
In a real system the pair reads like a setup script: open the connection, then announce the subscription, then stream.
import reactor.core.publisher.Flux
fun main() {
Flux.just("AAPL", "GOOG", "MSFT")
.doFirst { println("opening market-data connection") }
.doOnSubscribe { sub -> println("subscribed, requesting quotes") }
.subscribe { println("quote for $it") }
}The market-data connection opens first (doFirst), the subscription is announced next (doOnSubscribe), and only then do the three quotes flow to the subscriber โ untouched, as always with this family.
opening market-data connection subscribed, requesting quotes quote for AAPL quote for GOOG quote for MSFT
doOnCancel / doOnRequest
fun <T> Flux<T>.doOnCancel(onCancel: () -> Unit): Flux<T> // also doOnRequest(consumer: (Long) -> Unit): Flux<T>doOnCancel registers a callback that fires when the downstream cancels the subscription at that point in the chain โ the silent third ending that operators like take, timeout and switchMap, or a Disposable.dispose() call, trigger. The hook only observes: the cancellation still propagates upstream and stops the source, and no data signal is added or removed. doOnRequest is the mirror hook for the other control signal, running your consumer with every request(n) demand that flows upstream (including the initial request made at subscription time), which makes backpressure visible without altering it.
- Closing cursors, connections or file handles when a limited consumer (take, timeout) hangs up early
- Logging or counting cancellations to detect clients that abandon requests
- Debugging why a source stops producing โ was it completed, errored, or cancelled?
- Inspecting request(n) demand with doOnRequest to diagnose backpressure and prefetch behavior
doOnCancel fires ONLY on cancellation โ it stays silent on both onComplete and onError, so it is not a general cleanup hook; combine it with doFinally when the resource must be released on every ending. Placement matters too: hooks below the cancelling operator (e.g. below take) never see the cancel, because take converts it into a clean onComplete for its downstream.
Flux.range(1, 100)
.doOnCancel { println("cursor closed") }
.take(2)
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. This Flux is a 100-element range with a doOnCancel hook, limited by take(2). What is printed?
Cancellation is the third way a stream can end โ and the least visible one. take, timeout, switchMap and Disposable.dispose() all cancel upstream subscriptions silently: no onComplete, no onError, just a subscription that stops. doOnCancel gives you a hook on that exact event, so you can close the cursor, release the connection, or log who hung up. Its sibling doOnRequest makes the other invisible signal observable: the request(n) demand that backpressure sends upstream.
Both hooks observe control signals rather than data, which makes them the go-to debugging tools when a pipeline stops early or requests less than you expected.
import reactor.core.publisher.Flux
fun main() {
Flux.range(1, 100) // pretend this is a big DB cursor
.doOnRequest { n -> println("[demand] downstream requested $n rows") }
.doOnCancel { println("[cancel] closing DB cursor early") }
.limitRate(4)
.take(5) // cancels upstream after 5 rows
.subscribe { println("row $it") }
}The source pretends to be a 100-row database cursor. limitRate(4) splits the demand into batches: doOnRequest logs the initial request of 4 rows and, once those drain, a 3-row top-up (limitRate replenishes at 75% by default). take(5) forwards five rows and then cancels the upstream subscription โ the remaining 95 rows are never produced, and doOnCancel closes the cursor.
[demand] downstream requested 4 rows row 1 row 2 row 3 row 4 [demand] downstream requested 3 rows row 5 [cancel] closing DB cursor early
doOnCancel fires only on cancellation โ it stays silent when the stream completes or errors. If your cleanup must run on every ending, use doFinally instead: it receives the SignalType (ON_COMPLETE, ON_ERROR or CANCEL), so you can still tell the paths apart.
doOnEach
fun <T> Flux<T>.doOnEach(signalConsumer: (Signal<T>) -> Unit): Flux<T>doOnEach invokes your consumer once for every signal the upstream emits, wrapping each as a Signal object: every onNext, plus the single terminal onComplete or onError. The values and terminal signals are passed through completely unchanged downstream โ it is a pure side-effect peek that never alters, filters, or reorders the sequence. The callback runs synchronously on whatever thread emits the signal, and you inspect the Signal via isOnNext()/isOnComplete()/isOnError() to branch on type.
- Unified logging or tracing of every event (values, completion, error) in one callback
- Capturing or restoring the Reactor Context, which Signal exposes via getContextView()
- Metrics that need to count both emissions and the terminal outcome together
- Debugging a pipeline where you want a single observation point for all signal types
The callback receives a Signal wrapper, not a raw value โ calling get() on an onComplete or onError signal returns null, so you must guard with signal.isOnNext() before reading the value or you will log spurious nulls. Also, doOnEach is purely observational: throwing inside it or doing blocking work will disrupt the pipeline, and it does not give you a way to transform the signal.
Flux.just("A", "B", "C", "D")
.doOnEach { s -> log(s) }
.subscribe { v -> print(v) }
// log is called for each onNext signal plus the onComplete signal๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit downstream, and what does doOnEach do?
doOnEach is the all-in-one peek: a single callback that receives every signal โ each onNext, plus the terminal onComplete or onError โ wrapped in a Signal object you can inspect. Instead of stacking doOnNext + doOnComplete + doOnError, you get one observation point, which is exactly what structured-logging and tracing layers want.
import reactor.core.publisher.Flux
fun main() {
Flux.just(1, 2, 3)
.doOnEach { signal ->
when {
signal.isOnNext -> println("Signal: onNext(${signal.get()})")
signal.isOnComplete -> println("Signal: onComplete")
signal.isOnError -> println("Signal: onError(${signal.throwable?.message})")
}
}
.subscribe()
}For each of the three values the callback receives an onNext Signal; when the source finishes it receives one final onComplete Signal. We branch with isOnNext / isOnComplete / isOnError to print each kind โ three value signals and one terminal signal, four callback invocations in total.
Signal: onNext(1) Signal: onNext(2) Signal: onNext(3) Signal: onComplete
Printing signal.get() directly shows why you should branch first: terminal signals carry no value.
import reactor.core.publisher.Flux
fun main() {
Flux.just("a", "b")
.doOnEach { signal ->
println("signal=${signal.type} value=${signal.get()}")
}
.subscribe()
}The two values print with their payloads, but the final line reads value=null โ the onComplete signal has nothing inside. Always guard signal.get() with isOnNext before using the value.
signal=onNext value=a signal=onNext value=b signal=onComplete value=null
Signal also exposes the subscriber Context via signal.contextView โ which is why doOnEach is the standard bridge for MDC-style contextual logging in Reactor applications.
doFinally
fun <T> Flux<T>.doFinally(onFinally: (SignalType) -> Unit): Flux<T>doFinally registers a callback that runs exactly once after the sequence ends for ANY reason, passing a SignalType of ON_COMPLETE, ON_ERROR, or CANCEL. All upstream values pass through unchanged and the callback fires AFTER the terminal signal has propagated downstream โ in the marble, A, B, C emit normally and 'fin' runs once the complete bar lands. It is the reactive equivalent of a finally block, ideal for guaranteed resource cleanup regardless of how the stream terminates.
- Closing connections, files or DB cursors after the stream ends
- Stopping timers or decrementing metrics/gauges on termination
- Releasing a resource that must close on complete, error AND cancel
- Cleanup tied to subscription lifecycle rather than a specific signal
doFinally fires on CANCEL too, which doOnComplete and doOnError do NOT catch, so use it precisely when you need cleanup on cancellation. With multiple operators the callback runs on the closest subscriber per Subscription; if you wrongly assume it always means 'success' you may double-release or leak. Also, an exception thrown inside the callback is dropped to the global hook, not propagated downstream.
Flux.just("A", "B", "C", "D")
.doFinally { type -> println("finally: " + type) }
.take(2)
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. The doFinally hook sits UPSTREAM of take(2). What does this pipeline print?
doFinally is the reactive try/finally: it runs exactly once after the sequence ends, no matter how โ completion, error or cancellation โ and receives a SignalType telling you which one it was. That makes it the safest place to release resources: connections, file handles, semaphores, metrics gauges.
Unlike doOnTerminate, it fires after the terminal signal has already propagated downstream. And where you place it decides which ending it sees, because cancellation only travels upstream:
import reactor.core.publisher.Flux
import reactor.core.publisher.SignalType
fun main() {
Flux.just("frame-1", "frame-2", "frame-3", "frame-4", "frame-5")
.doFinally { type: SignalType -> println("upstream doFinally: $type") }
.take(3) // cancels upstream after 3 frames
.doFinally { type: SignalType -> println("downstream doFinally: $type") }
.subscribe { println("rendering $it") }
}take(3) forwards three frames, then cancels its upstream subscription and sends onComplete downstream. The upstream hook therefore reports cancel โ from its point of view somebody hung up โ while the downstream hook reports onComplete, because it received take's clean completion. One operator, two different endings, depending on where you stand.
rendering frame-1 rendering frame-2 rendering frame-3 upstream doFinally: cancel downstream doFinally: onComplete
The bread-and-butter use is guaranteed resource cleanup:
import reactor.core.publisher.Flux
import reactor.core.publisher.SignalType
fun main() {
Flux.just("config.yaml", "secrets.env")
.doOnNext { println("reading $it") }
.doFinally { signal: SignalType ->
println("closing file handle (reason=$signal)")
}
.subscribe { println("loaded $it") }
}Both files load normally, so the hook reports reason=onComplete โ but the exact same line would have run on an error or a cancellation, which is the whole point of using doFinally for cleanup.
reading config.yaml loaded config.yaml reading secrets.env loaded secrets.env closing file handle (reason=onComplete)
A close cousin โ for values instead of subscriptions โ is doOnDiscard. Operators like filter, skip and distinct drop elements as part of their normal job; if those elements hold resources (pooled buffers, open handles), dropping them silently would leak. doOnDiscard registers a hook that observes every value discarded by upstream operators so you can release it.
import reactor.core.publisher.Flux
fun main() {
Flux.just("order-11", "order-12", "cancelled-13", "order-14")
.filter { !it.startsWith("cancelled") }
.doOnDiscard(String::class.java) { println("[discard] releasing buffer for $it") }
.subscribe { println("processing $it") }
}filter drops the cancelled order, and doOnDiscard โ installed downstream of the filter โ sees the discarded element right away and releases its buffer, while the surviving orders flow through untouched.
processing order-11 processing order-12 [discard] releasing buffer for cancelled-13 processing order-14
doFinally callbacks must not throw: an exception there is routed to the global error hook, not propagated downstream. And doOnDiscard only covers elements discarded synchronously by upstream operators โ treat it as a best-effort cleanup channel, not a guarantee.
log
fun <T> Flux<T>.log(): Flux<T> // also log(category), log(category, Level, SignalType...)log() is a pass-through operator: it observes every Reactive Streams signal โ onSubscribe, request(n), onNext, onComplete, onError, and cancel โ and writes a trace line for each, then forwards the original signal completely untouched and in the same order. It alters nothing in the data stream; values, terminal signals, and timing reach downstream exactly as before, so it is transparent to correctness. Logging runs on whatever thread emits the signal, so the thread name in each line reflects the active Scheduler at that point in the chain.
- Debugging why a reactive chain emits nothing, hangs, or completes early
- Inspecting backpressure by seeing the actual request(n) demand flowing upstream
- Confirming which Scheduler/thread an operator runs on after publishOn/subscribeOn
- Tracing the exact terminal signal (onComplete vs onError) and its position
log() is not free: it formats and writes a line for every single signal, so leaving it in a hot path can flood logs and noticeably slow high-throughput streams โ it is a debugging tool, not production instrumentation. Its placement matters: put it before or after a given operator and you see different signals, and at TRACE/DEBUG it also reports request and cancel events that most other peek operators (like doOnNext) never expose.
Flux.just("A", "B", "C")
.log()
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit downstream?
log() is the fastest way to see what a reactive chain is actually doing: it traces every Reactive Streams event at that point โ onSubscribe, request(n), onNext, onComplete, onError and cancel โ printing one line per signal while forwarding everything untouched. When a pipeline emits nothing, hangs, or completes too early, dropping a log() at the suspicious spot usually answers the question.
import reactor.core.publisher.Flux
fun main() {
Flux.range(1, 3)
.log("my.flux") // logs with category "my.flux"
.map { it * 2 }
.log("after.map")
.subscribe()
}Two log stages bracket the map, and the output interleaves: my.flux traces the source values (1, 2, 3) while after.map traces the doubled ones (2, 4, 6), each stage first recording its own onSubscribe and request. Placement determines what you see โ the same chain reads completely differently from the two vantage points.
[ INFO] (main) my.flux - onSubscribe(FluxShim.ShimSubscription) [ INFO] (main) my.flux - request(unbounded) [ INFO] (main) after.map - onSubscribe(FluxShim.ShimSubscription) [ INFO] (main) after.map - request(unbounded) [ INFO] (main) my.flux - onNext(1) [ INFO] (main) after.map - onNext(2) [ INFO] (main) my.flux - onNext(2) [ INFO] (main) after.map - onNext(4) [ INFO] (main) my.flux - onNext(3) [ INFO] (main) after.map - onNext(6) [ INFO] (main) my.flux - onComplete() [ INFO] (main) after.map - onComplete()
With a named category the trace slots straight into your logging configuration, so you can raise or silence a single pipeline:
import reactor.core.publisher.Flux
fun main() {
Flux.just("eur", "usd", "jpy")
.map { it.uppercase() }
.log("fx.rates") // logs subscribe/request/onNext/onComplete
.blockLast()
}We map three currency codes to uppercase and block for the last one. The fx.rates category records the subscription, the unbounded request that blockLast issues, each uppercased value, and the completion โ the full life of the stream in six lines.
[ INFO] (main) fx.rates - onSubscribe(FluxShim.ShimSubscription) [ INFO] (main) fx.rates - request(unbounded) [ INFO] (main) fx.rates - onNext(EUR) [ INFO] (main) fx.rates - onNext(USD) [ INFO] (main) fx.rates - onNext(JPY) [ INFO] (main) fx.rates - onComplete()
log() formats and writes a line for every single signal, so leaving it on a hot path floods logs and slows high-throughput streams. It is a debugging tool โ for production observability prefer name()/tap() with Micrometer.
materialize / dematerialize
fun <T> Flux<T>.materialize(): Flux<Signal<T>>materialize() converts every reactive event into a regular onNext emission carrying a Signal<T>. Each source onNext(a) becomes a Signal of kind ON_NEXT wrapping the value, in order; the terminal onComplete or onError is turned into a Signal of kind ON_COMPLETE or ON_ERROR emitted as a normal value, and only AFTER that does the materialized Flux itself complete normally. So errors no longer terminate the stream as errors โ they travel downstream as data, letting you inspect or transform terminal signals like any other element.
- Inspecting or logging terminal signals (complete vs error) inline within the pipeline
- Buffering or recording a full event history for replay or testing
- Treating errors as data so they can be filtered, counted, or transformed without aborting the stream
- Pairing with dematerialize() to round-trip signals after intermediate processing
After materialize() the downstream stream never sees a real onError โ an upstream error arrives as a normal Signal value and the Flux then completes successfully, so onErrorResume/retry placed AFTER materialize() will never fire. You must call dematerialize() to restore the original onNext/onComplete/onError semantics, and error-handling operators belong before materialize or after dematerialize.
Flux.just("a", "b")
.materialize()
.doOnNext { signal -> println(signal) }
.subscribe()๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit (as onNext items) before completing?
materialize() reifies the stream: every signal โ each value, and even the terminal completion or error โ becomes a Signal object emitted as a normal element. The result is a Flux<Signal<T>> that always completes successfully, because errors are now just data. dematerialize() is the exact inverse, turning Signal elements back into real onNext / onComplete / onError signals.
The pair powers event-sourcing-style tricks: recording a stream's full history, transporting signals across a boundary, or counting errors without dying.
import reactor.core.publisher.Flux
fun main() {
// materialize: signals become data
Flux.just(1, 2, 3)
.materialize()
.subscribe { signal ->
println("Signal: $signal (type=${signal.type})")
}
// dematerialize: turn signals back into real signals
Flux.just(1, 2, 3)
.materialize()
.dematerialize<Int>()
.subscribe(
{ println("Value: $it") },
{ println("Error: $it") },
{ println("Complete") }
)
}The first pipeline prints four ordinary elements: three onNext Signals and one Signal(onComplete) โ proof that even the terminal event became data. The second pipeline round-trips through materialize + dematerialize, restoring the original values and completion for a classic subscriber.
Signal: onNext(1) (type=onNext) Signal: onNext(2) (type=onNext) Signal: onNext(3) (type=onNext) Signal: onComplete() (type=onComplete) Value: 1 Value: 2 Value: 3 Complete
After materialize() the downstream never sees a real onError: an upstream failure arrives as a normal Signal value and the Flux then completes successfully. onErrorResume / retry placed after materialize() will never fire โ error handling belongs before materialize or after dematerialize.
Buffering & Windowing
So far most operators have handled one element at a time. Real pipelines constantly need the opposite: "insert these rows in batches of 500", "flush whatever arrived in the last second", "split this log at every COMMIT". That is exactly what this family does โ it groups a flat stream into chunks.
The family splits cleanly in two. The buffer* operators collect elements into Lists โ simple to consume, but each batch is materialized in memory before it is emitted. The window* operators (and groupBy) instead cut the stream into live sub-Fluxes that you consume with flatMap or concatMap. Both sides offer the same boundaries:
- Fixed size โ buffer(n) / window(n)
- Size OR time, whichever comes first โ bufferTimeout / windowTimeout
- A predicate over the data โ bufferUntil, bufferWhile / windowUntil, windowWhile
- A key change between neighbors โ bufferUntilChanged / windowUntilChanged
- Companion Publishers opening and closing boundaries โ bufferWhen / windowWhen
- A key per element, regardless of position โ groupBy
buffer
buffer is the batching workhorse: it collects incoming values into a List and passes each full List downstream as a single emission, turning a Flux<T> into a Flux<List<T>>. The classic use case is chunking work for systems that prefer bulk โ databases, search indexes, message brokers โ anywhere "one insert of 500 rows" beats "500 inserts of one row".
fun <T> Flux<T>.buffer(maxSize: Int): Flux<List<T>>buffer(N) accumulates source values into a List and emits that List downstream every time it fills up to N elements, preserving source order. When the source completes, any partially filled buffer (fewer than N items) is emitted as a final list before the complete signal propagates. An error from the source is forwarded immediately and the current in-progress buffer is discarded, not emitted.
- Batch records before a bulk DB insert or HTTP write
- Group events into fixed-size chunks for paginated processing
- Reduce downstream call frequency by sending N items at once
- Chunk a large stream for parallel batch handling
On completion buffer emits the leftover partial list (e.g. 5 items with buffer(2) yields [1,2],[3,4],[5]), so don't assume every emitted list has exactly N elements. Also, with a time-based or never-completing source a stuck partial buffer may never be flushed unless you use bufferTimeout.
Flux.just(1, 2, 3, 4)
.buffer(2)
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit before completing?
import reactor.core.publisher.Flux
fun main() {
Flux.just("ORD-1", "ORD-2", "ORD-3", "ORD-4", "ORD-5", "ORD-6", "ORD-7")
.buffer(3)
.subscribe { batch -> println("bulk insert (${batch.size}): $batch") }
}We have a stream of seven order IDs. buffer(3) collects them silently โ nothing is emitted while a batch is filling โ and the moment the third element arrives, the whole List comes out as one marble. Finally we subscribe and print each batch: two full batches of three, and when the source completes, the leftover ORD-7 is flushed as a final, smaller batch.
bulk insert (3): [ORD-1, ORD-2, ORD-3] bulk insert (3): [ORD-4, ORD-5, ORD-6] bulk insert (1): [ORD-7]
buffer(size, skip) unlocks two more shapes. skip means "how many elements until the next buffer opens": with skip smaller than size the buffers overlap (neighbors share elements โ sliding windows), and with skip larger than size there are gaps (the elements between buffers are dropped).
import reactor.core.publisher.Flux
fun main() {
// Overlapping buffers: size=3, skip=2 (a new buffer opens every 2 elements)
Flux.range(1, 6)
.buffer(3, 2)
.subscribe { println("Overlap: $it") }
// Dropping buffers: size=2, skip=4 (elements between buffers are skipped)
Flux.range(1, 10)
.buffer(2, 4)
.subscribe { println("Drop: $it") }
}Overlap: [1, 2, 3] Overlap: [3, 4, 5] Overlap: [5, 6] Drop: [1, 2] Drop: [5, 6] Drop: [9, 10]
Don't assume every list has exactly N elements: the final partial buffer is emitted on completion ([ORD-7] above). If the source errors, the in-progress buffer is discarded, not emitted. And on sources that can go quiet without completing, prefer bufferTimeout so a half-full batch can't sit around forever.
bufferTimeout
bufferTimeout is micro-batching with a latency guarantee: it flushes the current buffer when it reaches maxSize OR when maxTime elapses โ whichever happens first. It is the operator you want between a chatty producer and an expensive sink (analytics events, log shipping, metrics). Size alone can strand a half-full batch during quiet periods; time alone can flood you during bursts. bufferTimeout bounds both.
fun <T> Flux<T>.bufferTimeout(maxSize: Int, maxTime: Duration): Flux<List<T>>Accumulates upstream items into a List and emits that buffer as soon as EITHER it reaches maxSize OR maxTime elapses since the buffer was opened โ whichever trips first. The size trigger resets the timer, so a flush by count restarts the window for the next batch; an empty window does not emit an empty list. On completion any partially filled buffer is emitted before onComplete, and an error propagates immediately (the in-progress buffer is dropped). The timeout runs on Schedulers.parallel() by default, so downstream may observe buffers on a timer thread.
- Micro-batching writes to a DB or external API to cut round-trips
- Bounding latency on bursty streams (flush at N or every X ms)
- Batching log/metric/event records before a bulk flush
- Smoothing chatty producers into steady, sized chunks
The timer is per-buffer and resets after each flush โ it is NOT a fixed wall-clock tick. Because emission can happen on the parallel scheduler's timer thread, downstream work runs off the source thread unless you publishOn; and tiny maxTime values create many small buffers, adding overhead rather than saving it.
Flux.just("a", "b", "c", "d", "e", "f")
.bufferTimeout(3, Duration.ofSeconds(1))
.subscribe { batch -> println(batch) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit when the println runs?
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
Flux.interval(Duration.ofMillis(80))
.map { "evt-$it" }
.bufferTimeout(5, Duration.ofMillis(250))
.take(3)
.doOnNext { println("flushed batch: $it") }
.blockLast()
}We have an event every 80 ms and a buffer that allows up to 5 items or 250 ms, whichever trips first. Since only three events fit into each 250 ms window, the timer wins every round and each flushed batch carries three events. take(3) stops the demo after three batches, and blockLast() keeps main alive until then.
flushed batch: [evt-0, evt-1, evt-2] flushed batch: [evt-3, evt-4, evt-5] flushed batch: [evt-6, evt-7, evt-8]
The timer is per-buffer: it restarts every time a buffer is flushed, whether by count or by time โ it is not a fixed wall-clock tick. Also, timeout-triggered flushes run on Schedulers.parallel() by default, so downstream code can suddenly find itself on a timer thread.
bufferUntil / bufferWhile
Sometimes the boundary isn't a count or a clock โ it lives in the data itself. bufferUntil closes the current batch when a predicate returns true, keeping the triggering element as the last item of that batch. bufferWhile keeps a batch open while the predicate holds, and the element that breaks it is dropped entirely โ it never appears in any list. Think delimiter-based data: a COMMIT marker ending a transaction, an END record closing a page, a zero reading separating measurement bursts.
fun <T> Flux<T>.bufferUntil(predicate: (T) -> Boolean): Flux<List<T>> // also bufferWhile(predicate)bufferUntil accumulates values into a List and emits that List the moment the predicate returns true, INCLUDING the triggering element as the last item of the buffer; a new buffer then starts. bufferWhile instead only adds elements while the predicate is true and closes (emits) the buffer when it returns false, DROPPING the false element entirely. On completion, any non-empty pending buffer is flushed downstream before onComplete; onError propagates immediately and discards the in-progress buffer.
- Group log lines or events until a delimiter/sentinel record appears
- Batch streaming records by a 'commit' or 'end-of-record' marker
- Split a flat stream into chunks bounded by a domain rule (e.g. every multiple of N)
- Reassemble multi-part messages where a flag marks the final fragment
The key difference is inclusivity: bufferUntil KEEPS the boundary element (closes inclusively), while bufferWhile DROPS the element that fails the predicate โ so that value never appears in any output list. Also, if the predicate never fires, all values pile into one buffer emitted only at completion (unbounded memory risk); bufferUntil has a cutBefore overload that starts the new buffer with the trigger element instead.
Flux.just(1, 2, 3, 4, 5, 6, 7)
.bufferUntil { it % 3 == 0 }
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit?
import reactor.core.publisher.Flux
fun main() {
Flux.just("wake up", "coffee", "COMMIT", "code", "review", "COMMIT", "deploy")
.bufferUntil { it == "COMMIT" }
.subscribe { println("Until: $it") }
Flux.just(4, 8, 15, 0, 16, 23, 0, 42)
.bufferWhile { it != 0 }
.subscribe { println("While: $it") }
}The first stream is a work log where COMMIT ends each transaction: bufferUntil emits each batch with the COMMIT marker included, and the trailing "deploy" is flushed on completion. The second stream uses 0 as a separator: bufferWhile collects values while they are non-zero and silently swallows each 0 โ notice that no zero appears anywhere in the output.
Until: [wake up, coffee, COMMIT] Until: [code, review, COMMIT] Until: [deploy] While: [4, 8, 15] While: [16, 23] While: [42]
Need the delimiter to START the next batch instead of ending the current one? bufferUntil has a cutBefore overload: bufferUntil(predicate, true) opens a new buffer with each matching element โ handy when records begin with a header line. And remember the asymmetry: until keeps the boundary element, while drops it.
bufferUntilChanged / windowUntilChanged
fun <T, V> Flux<T>.bufferUntilChanged(keySelector: (T) -> V): Flux<List<T>> // also windowUntilChangedComputes a key for each element and compares it with the previous element's key: while the keys are equal (by equals, or by an optional keyComparator overload) values accumulate into the same List; the first element with a DIFFERENT key flushes the previous run downstream and becomes the first item of a new buffer. On completion the pending run is flushed before onComplete; an error propagates immediately and discards the in-progress buffer. The no-arg variant uses the element itself as the key, and windowUntilChanged emits each run as a live inner Flux instead of a List.
- Chunk a sorted-by-key export into per-key batches without holding the whole stream
- Collapse repeated states (log levels, sensor status) into runs for summarizing
- Group contiguous activity โ GPS points per road segment, ticks per symbol in a feed
- Run-length encode noisy signals before storing or transmitting them
It only groups CONSECUTIVE equal keys: a key that reappears after a different one starts a brand-new buffer (unlike groupBy, which routes it back to its existing group). That bounds memory to one run โ but the inverse risk remains: a single run that never changes key grows without limit, so make sure something else (bufferTimeout, take) bounds pathological runs.
Flux.just("a", "a", "b", "b", "b", "a")
.bufferUntilChanged()
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit before completing?
bufferUntilChanged doesn't ask you to describe the boundary at all โ the boundary is "the key changed". It compares each element's key with the previous one and, while they match, keeps stacking values into the same list; the first element with a different key flushes the previous run and starts a new one. It is run-length grouping for streams, and it shines on data that arrives sorted or naturally clustered: ticker feeds, log levels, GPS points per road segment.
import reactor.core.publisher.Flux
data class Tick(val symbol: String, val price: Double)
fun main() {
Flux.just(
Tick("AAPL", 191.2), Tick("AAPL", 191.4),
Tick("MSFT", 415.0),
Tick("AAPL", 191.1), Tick("AAPL", 191.3)
)
.bufferUntilChanged { it.symbol }
.subscribe { run ->
println("${run.first().symbol} run of ${run.size}: ${run.map { it.price }}")
}
}We stream five market ticks. The first two share the AAPL key, so they pile into one run. The MSFT tick carries a different key โ that flushes [191.2, 191.4] and opens an MSFT run. When AAPL comes back, the one-tick MSFT run is flushed, and the final AAPL pair is emitted on completion. Note the two AAPL runs stay separate: only consecutive elements are grouped.
AAPL run of 2: [191.2, 191.4] MSFT run of 1: [415.0] AAPL run of 2: [191.1, 191.3]
Every buffer operator has a windowing twin, and this one is no exception: windowUntilChanged emits each run as a live Flux instead of a materialized List.
import reactor.core.publisher.Flux
fun main() {
Flux.just("info", "info", "error", "error", "error", "info")
.windowUntilChanged()
.concatMap { window -> window.collectList() }
.subscribe { println("run: $it") }
}run: [info, info] run: [error, error, error] run: [info]
bufferUntilChanged is NOT groupBy: when a key reappears later, it starts a brand-new buffer instead of rejoining the old one. That is exactly what makes it safe for unbounded key spaces โ it only ever holds one run in memory. Watch the other direction though: a single run that never ends grows without bound.
bufferWhen
bufferWhen hands boundary control to other publishers: one companion opens buffers, and for each opening value a second, derived companion decides when that particular buffer closes. This is the most flexible โ and trickiest โ member of the family: buffers can overlap (one element lands in several lists) or leave gaps (an element arriving while nothing is open is simply dropped).
fun <T, V> Flux<T>.bufferWhen(bucketOpening: Publisher<out U>, closeSelector: (U) -> Publisher<V>): Flux<List<T>>Each time the bucketOpening Publisher emits a value, a new buffer is opened; that value is passed to closeSelector to derive a closing Publisher, and the buffer collects every source element until that closing Publisher emits (or completes), at which point the collected List is emitted. Buffers can overlap or leave gaps depending on opening/closing timing, and a single source element may land in multiple concurrently-open buffers or none at all. When the source completes, any still-open buffers are emitted before the downstream complete signal; an error from the source or any companion propagates downstream and discards in-flight buffers.
- Create dynamic, possibly overlapping windows whose duration depends on the opening value
- Batch events into sessions started by user activity and closed by an inactivity timeout
- Sample bursts where each opening triggers a differently-sized collection window
- Group data using external control streams rather than fixed sizes or fixed time
Each opening value derives its OWN closing Publisher, so buffers can overlap and an element may be duplicated across several lists (or dropped if no buffer is open) โ this is not a simple non-overlapping split. Also, the closing Publisher only needs to emit ONE signal (or complete) to close; if it never emits, that buffer stays open and is only flushed when the source terminates.
Flux.just(1, 2, 3, 4, 5)
.bufferWhen({ openSignals }) { Mono.delay(Duration.ofMillis(20)) }
.subscribe { batch -> println(batch) }
// closing signals fall after [1,2] and [3,4]๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. Given the marble timeline (items 1..5 with closing signals after 1,2 then 3,4), what does this Flux emit before completing?
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
Flux.interval(Duration.ofMillis(50))
.map { "tick-$it" }
.bufferWhen(
Flux.interval(Duration.ofMillis(120)), // opens a buffer
java.util.function.Function { Flux.interval(Duration.ofMillis(90)) } // closes it 90ms later
)
.take(3)
.doOnNext { println("captured: $it") }
.blockLast()
}Ticks arrive every 50 ms. Every 120 ms the opening interval starts a new buffer, and each buffer closes 90 ms after it opened. That 120/90 rhythm leaves ~30 ms holes between buffers โ look closely at the output: tick-6 is missing, because it arrived in a gap when no buffer was open.
captured: [tick-2, tick-3] captured: [tick-4, tick-5] captured: [tick-7]
If a closing publisher never emits, its buffer stays open until the source terminates (still-open buffers are flushed on completion). And because each opening spawns its own closing companion, nothing stops buffers from overlapping โ bufferWhen is a superset of boundary-based buffering, not just a fancier bufferTimeout.
window
window is buffer's streaming twin: instead of collecting elements into Lists, it cuts the source into inner Fluxes โ Flux<T> becomes Flux<Flux<T>>. Each window is a live view: elements flow through it as they arrive, nothing waits for a chunk to fill. Reach for window when chunks are big (don't materialize a million-element List) or when each chunk deserves a whole sub-pipeline of its own โ reduce it, rate-limit it, stream it somewhere.
fun <T> Flux<T>.window(maxSize: Int): Flux<Flux<T>>window(n) re-emits the source as a Flux of inner Flux windows, each carrying at most n elements; the first n source values go to window 1, the next n to window 2, and so on. Each inner Flux opens, emits its slice, then completes, and the outer Flux completes after the last inner window finishes. Unlike buffer, windows are live, unicast views: elements flow through them as they arrive rather than being collected into a List, so a subscriber must consume each inner Flux promptly.
- Process a large or infinite stream in fixed-size chunks without buffering each chunk into memory
- Apply per-chunk operators (reduce, collectList, sum) on each inner window independently
- Backpressure-friendly batching where you stream chunk contents instead of materializing Lists
- Rate-limited or paged processing pipelines that act on every N elements
Inner windows are hot, unicast, and live: if you do not subscribe to each inner Flux (e.g. via flatMap/concatMap) before the next window opens, you can lose its data or stall the pipeline, and you cannot re-subscribe to a window. Prefer concatMap over flatMap when consuming windows so chunks stay ordered and only one is active at a time.
Flux.just(1, 2, 3, 4, 5, 6)
.window(2)
.concatMap { win -> win.collectList() }
.subscribe { batch -> println("batch=" + batch) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. Flux.window(2) splits the source into inner Fluxes. Given six values, what does the outer Flux emit and how is each chunk consumed below?
import reactor.core.publisher.Flux
fun main() {
Flux.just(10, 20, 30, 40, 50, 60, 70)
.window(3)
.index()
.concatMap { tuple ->
val i = tuple.t1
tuple.t2.reduce(0) { a, b -> a + b }.map { "window $i sum=$it" }
}
.subscribe { println(it) }
}Seven readings are split into windows of three. index() numbers each window, and then we run a real per-window pipeline: reduce sums the elements as they stream through โ no List is ever built. Window 0 holds 10+20+30, so we print 60, then 150, and the final short window sums to 70. Because we consume with concatMap, windows are processed strictly in order, one at a time.
window 0 sum=60 window 1 sum=150 window 2 sum=70
Windows are unicast and cannot be replayed: subscribe to each inner Flux exactly once, promptly (flatMap/concatMap do that for you). Sitting on unconsumed windows stalls the pipeline. Prefer concatMap when order matters โ it keeps exactly one window in flight.
windowTimeout
windowTimeout is to window what bufferTimeout is to buffer: it closes the current inner Flux after maxSize elements OR maxTime, whichever comes first. Use it when you need time-bounded chunks but don't want materialized Lists โ a dashboard that repaints every 200 ms, per-second aggregation over a request stream.
fun <T> Flux<T>.windowTimeout(maxSize: Int, maxTime: Duration): Flux<Flux<T>>Splits the source into a sequence of inner Fluxes (windows), opening a new window and closing the current one whenever EITHER the element count reaches maxSize OR maxTime elapses since the window opened, whichever comes first. Order is fully preserved: elements flow into the current window in order, and windows are emitted in order. The timer runs on the parallel Scheduler by default; an upstream completion or error closes the open window and then terminates the outer Flux with the same signal.
- Micro-batching where you flush by size or after a max latency, whichever hits first
- Aggregating bursty events into time-or-count bounded chunks before a DB write
- Rate-limited buffering for network/file flushes with a latency ceiling
- Building dashboards that emit at most N items or every interval
It emits EMPTY windows when a time boundary fires with no buffered elements, so downstream sees windows of size 0 during idle periods. Also, each inner Flux MUST be consumed (subscribed to) promptly; if you don't subscribe to a window before the next is opened, you can drop data or hit backpressure errors. Use windowTimeout, not bufferTimeout, only when you need streaming windows rather than materialized Lists.
Flux.just(1, 2, 3, 4, 5, 6)
.windowTimeout(3, Duration.ofSeconds(1))
.flatMap { window -> window.collectList() }
.subscribe { batch -> println(batch) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. Given Flux.just(1, 2, 3, 4, 5, 6) emitted instantly, what does windowTimeout(3, Duration.ofSeconds(1)) produce downstream?
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
Flux.interval(Duration.ofMillis(60))
.map { "req-$it" }
.windowTimeout(4, Duration.ofMillis(200))
.concatMap { window -> window.collectList() }
.take(3)
.doOnNext { println("served window: $it") }
.blockLast()
}Requests arrive every 60 ms; each window may hold at most 4 of them or 200 ms worth of them. Early windows close on the timer with three requests each; when the timing drifts enough for a fourth request to squeeze in, the size limit closes the window instead โ the last window in this run carries four. We collect each window into a list only to print it.
served window: [req-0, req-1, req-2] served window: [req-3, req-4, req-5] served window: [req-6, req-7, req-8, req-9]
windowTimeout emits EMPTY windows when the timer fires with nothing buffered โ an idle stream produces a steady pulse of zero-element windows downstream. Guard your per-window aggregations (skip empty collectList results) or they will happily process empty chunks.
windowUntil / windowWhile / windowWhen
Every predicate and companion boundary from the buffer side has a windowing twin. windowUntil closes the current window when the predicate matches, keeping the delimiter inside it (and a cutBefore overload starts the new window with the delimiter instead). windowWhile keeps a window open while the predicate holds and drops the delimiter. windowWhen opens windows from one publisher and closes each through a derived companion, exactly like bufferWhen.
fun <T> Flux<T>.windowUntil(predicate: (T) -> Boolean): Flux<Flux<T>> // also windowWhile / windowWhenwindowUntil routes each element into the current inner Flux and, when the predicate returns true, completes that window with the matching element as its LAST item (the cutBefore overload instead completes the window BEFORE the match, which then opens the next one). windowWhile keeps the window open while the predicate holds and DROPS the element that breaks it. windowWhen takes an opening Publisher plus a per-opening closing Publisher โ the live-window mirror of bufferWhen, overlap and gaps included. In all variants, upstream completion closes the open window(s) before completing the outer Flux, and an error terminates windows and outer stream alike.
- Split protocol or log streams at delimiter records while streaming each record's lines
- Separate measurement bursts with windowWhile when the separators themselves are noise
- Start records at header lines with windowUntil(predicate, cutBefore = true)
- Build session windows with windowWhen: user activity opens, inactivity timeout closes
windowUntil and windowWhile can emit EMPTY windows โ back-to-back delimiters or a stream starting with one produce zero-element windows downstream, so per-window aggregations must tolerate empty chunks. And as with every windowing operator, inner windows are unicast and live: consume each one promptly, exactly once, or the pipeline stalls.
Flux.just(1, 2, 0, 3, 0, 4)
.windowWhile { it != 0 }
.concatMap { w -> w.collectList() }
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. windowWhile splits the stream on a predicate. What does this print?
import reactor.core.publisher.Flux
fun main() {
// windowUntil: the delimiter CLOSES the window and is INCLUDED in it
Flux.just("line 1", "line 2", "END", "line 3", "END", "line 4")
.windowUntil { it == "END" }
.concatMap { window -> window.collectList() }
.subscribe { println("Until: $it") }
// windowWhile: the delimiter closes the window and is DROPPED
Flux.just("a", "b", "-", "c", "-", "d")
.windowWhile { it != "-" }
.concatMap { window -> window.collectList() }
.subscribe { println("While: $it") }
}Two streams, two cuts. windowUntil splits the log at each END, and the ENDs stay inside their windows; the trailing "line 4" gets its own window when the source completes. windowWhile treats "-" as a pure separator: it closes windows but vanishes from the output. Each window is a live Flux, so we collect it with concatMap { it.collectList() } just to print it.
Until: [line 1, line 2, END] Until: [line 3, END] Until: [line 4] While: [a, b] While: [c] While: [d]
And when the boundaries come from the outside world โ user activity, control messages, timers โ windowWhen mirrors bufferWhen with live windows:
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
Flux.interval(Duration.ofMillis(50))
.map { "pkt-$it" }
.windowWhen(
Flux.interval(Duration.ofMillis(120)), // opens a window
java.util.function.Function { Flux.interval(Duration.ofMillis(90)) } // closes it 90ms later
)
.concatMap { window -> window.collectList() }
.take(3)
.doOnNext { println("session window: $it") }
.blockLast()
}session window: [pkt-2, pkt-3] session window: [pkt-4, pkt-5] session window: [pkt-7]
In real Reactor, windowUntil and windowWhile can emit EMPTY windows โ for example when two delimiters arrive back-to-back, or when the stream starts with one. If you aggregate each window, make sure an empty chunk doesn't break the math. And as with every window operator: consume each inner Flux promptly, exactly once.
groupBy
Everything above cuts the stream by position in time. groupBy is different: it partitions by content. Each element is routed by a key function into its group's GroupedFlux, and the outer Flux emits each group the first time its key appears. Groups are live, concurrent and permanent โ when a key reappears, the element rejoins its existing group (unlike bufferUntilChanged, which would start a new run).
fun <T, K> Flux<T>.groupBy(keyMapper: (T) -> K): Flux<GroupedFlux<K, T>>Each source element is routed by its computed key into a GroupedFlux<K, T>, and a new GroupedFlux is emitted on the main flux the first time a key appears. Existing groups keep receiving matching elements in source order; the outer completion/error propagates to every open group. Crucially each GroupedFlux MUST be subscribed and drained, otherwise the operator backpressures and the whole pipeline can stall.
- Partition events by category, tenant, or user id for per-key processing
- Fan out to per-key aggregation, windowing, or rate-limiting
- Route a mixed stream to different sinks based on type
- Apply distinct flatMap concurrency per group of related items
groupBy is designed for LOW cardinality. Every group is a live Flux you must consume; an unconsumed (or slowly consumed) group exerts backpressure and can deadlock the source. High or unbounded key cardinality also leaks memory because groups stay open until the source terminates.
Flux.just(1, 2, 3, 4, 5, 6)
.groupBy { it % 2 }
.flatMap { group -> group.map { n -> "key=" + group.key() + " val=" + n } }
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit when subscribed?
import reactor.core.publisher.Flux
data class Event(val type: String, val data: String)
fun main() {
val events = Flux.just(
Event("INFO", "App started"),
Event("ERROR", "Connection failed"),
Event("INFO", "Processing..."),
Event("WARN", "Low memory"),
Event("ERROR", "Timeout"),
Event("INFO", "Done")
)
events.groupBy { it.type }
.flatMap { group ->
group.collectList().map { "${group.key()}: ${it.map { e -> e.data }}" }
}
.subscribe { println(it) }
}Six log events carry three distinct types. groupBy { it.type } creates a GroupedFlux the moment a type first shows up โ INFO, then ERROR, then WARN. Inside flatMap each group collects its own events into a list tagged with group.key(). All groups complete when the source does, so the three lists print in the order the groups were created.
INFO: [App started, Processing..., Done] ERROR: [Connection failed, Timeout] WARN: [Low memory]
groupBy is built for LOW key cardinality. Every group must be consumed: an ignored or slow group backpressures the whole source and can deadlock it, and groups stay open until the source terminates, so unbounded key spaces leak memory. For consecutive runs over unbounded keys, reach for bufferUntilChanged / windowUntilChanged instead.
Reducing / Aggregating
Most operators transform values as they fly past. Reducing operators do the opposite: they wait, absorb the whole stream, and boil it down to a single value or collection. The result is always a Mono โ one summary, emitted the moment the source completes. That last part is the family's defining trait: nothing reaches your subscriber until the upstream says it is done.
As a taste of the whole family, this example combines groupBy with collectList to build a per-letter index of fruit names:
import reactor.core.publisher.Flux
fun main() {
Flux.just("apple", "avocado", "banana", "cherry", "blueberry", "apricot")
.groupBy { it.first() }
.flatMap { group -> group.collectList().map { "${group.key()}: $it" } }
.subscribe { println(it) }
}We have a stream of six fruit names. groupBy { it.first() } splits it into three keyed sub-fluxes, one per initial letter. For each group, collectList() gathers that group's fruits into a single List, and map prefixes it with the group's key. Finally we subscribe and print one aggregated line per letter:
a: [apple, avocado, apricot] b: [banana, blueberry] c: [cherry]
reduce
reduce is the reactive version of a fold: it walks the stream with an accumulator function, carrying a running result forward, and emits only the final value when the source completes. If you have ever summed a list with Kotlin's fold or reduce, this is the exact same idea โ except the values arrive over time instead of sitting in memory.
fun <T, A> Flux<T>.reduce(initial: A, accumulator: (A, T) -> A): Mono<A>reduce starts from the seed (here 0) and applies the accumulator to each value in emission order, threading the running result forward: 0+1=1, 1+2=3, 3+3=6, 6+4=10. Nothing is emitted while the source runs; only when the source completes does the resulting Mono emit the single folded value (10) followed by onComplete. If the source errors mid-stream, the partial accumulator is discarded and the error is propagated instead. There is no operator-introduced threading change, so the fold runs on whatever thread delivers each onNext.
- Summing, counting, or averaging a finite stream into one value
- Building an aggregate like a total, max, or concatenated string
- Collapsing a Flux into a Mono before returning from a service
- Folding events into a single state snapshot at completion
reduce only emits on source completion, so on an infinite or never-completing stream it emits nothing and hangs forever. The seeded overload always emits at least the initial value (so an empty source yields the seed, not an empty Mono), whereas the seedless reduce(BiFunction) emits empty on an empty source.
Flux.just(1, 2, 3, 4)
.reduce(0) { a, x -> a + x }
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Reactor pipeline emit (and how many items) when subscribed?
Suppose each value in the stream is the amount of one order placed today, and we want the day's total revenue:
import reactor.core.publisher.Flux
fun main() {
// Amounts (in USD) of every order placed today
Flux.just(120, 45, 99, 15)
.reduce { total, amount -> total + amount }
.subscribe { println("Daily revenue: $$it") }
}We have a stream of four order amounts. reduce combines the first two values (120 and 45) into 165, then folds each following amount into that running total: 165 + 99 = 264, and 264 + 15 = 279. Nothing reaches the subscriber while values are still arriving โ only when the source completes does the Mono emit the single total, which we print:
Daily revenue: $279
A second overload, reduce(seed) { acc, next -> โฆ }, starts the fold from an initial value instead of the first element โ which also lets the accumulator have a different type than the stream's elements. (There is also reduceWith { seed } when the seed must be built lazily, once per subscriber.) Here we fold checkout-flow steps into one readable pipeline string, starting from an empty StringBuilder:
import reactor.core.publisher.Flux
fun main() {
Flux.just("cart", "checkout", "pay", "ship")
.reduce(StringBuilder()) { acc, step -> acc.append(step).append(" > ") }
.map { it.toString().trimEnd(' ', '>') }
.subscribe { println("pipeline: $it") }
}pipeline: cart > checkout > pay > ship
Empty-source semantics differ by overload: seedless reduce completes empty when the source is empty, while reduce(seed) always emits at least the seed. And since reduce only fires on completion, pointing it at an infinite stream means the Mono never emits โ if you want to see the running total at every step, use scan instead.
collectList / collectMap / collectMultimap
collectList() is the operator you reach for when you need everything together: it buffers every element the Flux emits and, on completion, hands you a single Mono<List<T>> in original emission order โ perfect for a batch insert or one JSON response. collectMap() does the same job but builds a Map along the way, applying a key extractor (and optionally a value extractor) to each element.
fun <T> Flux<T>.collectList(): Mono<List<T>> // also collectMap(keyFn[, valueFn]): Mono<Map<K, V>>, collectMultimap(keyFn[, valueFn]): Mono<Map<K, Collection<V>>>collectList() buffers every onNext value from the source Flux into a single List, preserving emission order, and emits that List exactly once only after the source completes. The downstream Mono emits the full list followed by onComplete; if the source is empty it emits an empty list (never an empty Mono), and any source error is propagated without emitting a partial list. collectMap() and collectMultimap() run the same buffer-until-complete machinery but key each element through an extractor: collectMap keeps one value per key (last one wins), collectMultimap keeps every value that shares a key.
- Aggregate a bounded stream into a List for a batch insert or single API response
- Collect all results before returning from a service method that expects a List
- Fan-in flatMap results when you need them all together rather than streamed
- Bridge reactive code to a List-based API at the edge of your pipeline
The collect* family must hold every element in memory and emits nothing until the source completes, so on an infinite or very large/hot Flux it never emits and can cause unbounded memory growth or OOM โ use it only on bounded streams. And remember collectMap silently overwrites duplicate keys: when duplicates matter, reach for collectMultimap.
Flux.just("apple", "avocado", "banana")
.collectMap { it.first() }
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. Two fruits start with the same letter. What does this pipeline print?
import reactor.core.publisher.Flux
data class Product(val id: String, val name: String, val price: Double)
fun main() {
val products = Flux.just(
Product("1", "Laptop", 999.99),
Product("2", "Mouse", 29.99),
Product("3", "Keyboard", 79.99)
)
// collectList: Mono<List<Product>>
products.collectList()
.subscribe { println("All: $it") }
// collectMap with key extractor only: Mono<Map<String, Product>>
products.collectMap { it.id }
.subscribe { map -> println("By ID: ${map.keys}") }
// collectMap with key AND value extractors: Mono<Map<String, String>>
products.collectMap({ it.id }, { it.name })
.subscribe { println("Names: $it") }
}We have a catalog of three products. collectList() waits for all three and emits them as one List. The first collectMap indexes each product by its id, so the map's keys are 1, 2, 3 and the values are the full products. The second adds a value extractor, keeping only each product's name. All three subscriptions print once, when the source completes:
All: [Product(id=1, name=Laptop, price=999.99), Product(id=2, name=Mouse, price=29.99), Product(id=3, name=Keyboard, price=79.99)]
By ID: [1, 2, 3]
Names: {1=Laptop, 2=Mouse, 3=Keyboard}collectMap keeps exactly one value per key: if two elements map to the same key, the later one silently overwrites the earlier. When duplicates are meaningful, that is your cue to switch to collectMultimap.
collectMultimap() is the collision-friendly sibling: instead of one value per key, it accumulates every value that shares a key, producing a Mono<Map<K, Collection<V>>>. Think "group by, then keep them all" โ support tickets per team, orders per customer, log lines per level. Like collectMap, it accepts a key extractor alone or a key-plus-value extractor pair:
import reactor.core.publisher.Flux
data class Ticket(val team: String, val id: Int)
fun main() {
Flux.just(
Ticket("billing", 101),
Ticket("auth", 102),
Ticket("billing", 103),
Ticket("auth", 104),
Ticket("billing", 105)
)
.collectMultimap { it.team }
.subscribe { byTeam ->
byTeam.forEach { (team, tickets) ->
println("$team -> ${tickets.map { it.id }}")
}
}
}We have five support tickets tagged with the team that owns them. collectMultimap { it.team } buckets each ticket under its team key, keeping every ticket rather than only the last one. When the source completes, the Mono emits one map with two entries, which we print team by team:
billing -> [101, 103, 105] auth -> [102, 104]
For anything more exotic there is the general-purpose collect(Collector), which accepts any java.util.stream.Collector โ Collectors.joining(", "), Collectors.groupingBy(โฆ), or one you write yourself. The whole collect* family is really just prepackaged, reactive-friendly collectors over the same buffer-until-complete machinery.
collectSortedList
collectSortedList() buffers the entire stream, sorts it, and emits one sorted List when the source completes. It is the ship-a-ranking operator: scores arrive in whatever order players finish, but the UI wants a leaderboard. With no arguments it uses natural ordering; pass a Comparator for anything custom.
fun <T> Flux<T>.collectSortedList(comparator: Comparator<T>? = null): Mono<List<T>>collectSortedList subscribes upstream with unbounded demand and buffers every emitted element into an internal list, emitting nothing until the source completes. On the onComplete signal it sorts the buffered list (natural ordering, or the supplied Comparator) and emits exactly one List<T>, then completes the Mono. If the source errors before completing, the buffer is discarded and the error is propagated with no list emitted; an empty source yields an empty list.
- Producing a leaderboard or ranking from a finite stream of scores
- Sorting a bounded result set before returning it from a service call
- Collecting and ordering items for a single batched response or report
- Materializing a small async stream into one sorted snapshot for the UI
It is unbounded: it buffers the entire stream in memory and emits nothing until completion, so on an infinite or very large/hot source it never emits and can exhaust the heap. It also needs a terminal onComplete to fire โ and elements must be Comparable when no Comparator is given, or you get a ClassCastException.
Flux.just(5, 3, 1, 4, 2)
.collectSortedList()
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Reactor pipeline emit?
import reactor.core.publisher.Flux
data class Score(val player: String, val points: Int)
fun main() {
// Natural order
Flux.just(5, 3, 8, 1, 9, 2)
.collectSortedList()
.subscribe { println("Sorted: $it") }
// Leaderboard: highest score first, via a custom Comparator
Flux.just(Score("nina", 740), Score("leo", 980), Score("sam", 610), Score("ada", 860))
.collectSortedList(compareByDescending { it.points })
.subscribe { board ->
board.forEachIndexed { i, s -> println("#${i + 1} ${s.player} โ ${s.points}") }
}
}The first pipeline collects six unordered numbers and sorts them naturally. The second collects four player scores and sorts them with compareByDescending { it.points }, so the strongest score lands first; we then print the final standings with their positions:
Sorted: [1, 2, 3, 5, 8, 9] #1 leo โ 980 #2 ada โ 860 #3 nina โ 740 #4 sam โ 610
Sorting requires seeing every element, so collectSortedList holds the whole stream in memory and emits nothing until completion โ never point it at an infinite or unbounded source. Without a Comparator the elements must implement Comparable, or the pipeline fails with a ClassCastException.
count
count() answers the simplest aggregation question โ how many? โ as a Mono<Long> emitted when the source completes. On its own it is almost trivial; it becomes genuinely useful combined with filter (count only what matters) or groupBy (count per category), which is the bread and butter of monitoring and alerting code.
fun <T> Flux<T>.count(): Mono<Long>count subscribes to the source and consumes every element, discarding the values themselves while incrementing an internal counter. It emits nothing until the source completes, at which point it emits exactly one Long (the total element count) and then completes. If the source errors before completing, count propagates that error and emits no count.
- Reporting how many records a stream processed
- Asserting an expected number of emissions in tests
- Driving conditional logic from the size of a finite source
- Logging throughput totals after a batch completes
count only emits once the source completes, so applying it to an infinite or hot stream that never ends means the Mono never produces a value. It also fully drains the source, so the original elements are gone by the time you get the count.
Flux.just("A", "B", "C", "D")
.count()
.subscribe { n -> println(n) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Reactor pipeline emit?
import reactor.core.publisher.Flux
data class LogEntry(val level: String, val message: String)
fun main() {
val logs = Flux.just(
LogEntry("INFO", "App started"),
LogEntry("ERROR", "Connection refused"),
LogEntry("WARN", "Slow query: 2.3s"),
LogEntry("ERROR", "Timeout on /api/users"),
LogEntry("INFO", "Request completed"),
LogEntry("ERROR", "NullPointerException in OrderService"),
LogEntry("WARN", "Memory usage > 80%")
)
// Count errors for alerting
logs.filter { it.level == "ERROR" }
.count()
.subscribe { errorCount ->
println("Errors: $errorCount")
if (errorCount > 2) println("ALERT: Error threshold exceeded!")
}
// Count by level using groupBy + count
logs.groupBy { it.level }
.flatMap { group ->
group.count().map { "${group.key()}: $it" }
}
.subscribe { println(it) }
}We have seven log entries. The first pipeline filters down to ERROR entries and counts them: three survive, so the subscriber prints the count and โ since it crosses the threshold โ an alert. The second pipeline groups all entries by level and counts each group independently, printing one total per level in first-seen order:
Errors: 3 ALERT: Error threshold exceeded! INFO: 2 ERROR: 3 WARN: 2
all / any / hasElements
These three collapse a whole stream into a single Mono<Boolean>. all(predicate) asks "does every element pass?", any(predicate) asks "does at least one pass?", and hasElements() asks "did anything arrive at all?". There is also the handy shortcut hasElement(value) โ literally any { it == value } โ for membership checks. They are smart enough to stop early: any emits true the instant a match appears and cancels the rest of the stream, and all emits false the instant one element fails.
fun <T> Flux<T>.all(predicate: (T) -> Boolean): Mono<Boolean> // also any(predicate): Mono<Boolean>, hasElements(): Mono<Boolean>, hasElement(value: T): Mono<Boolean>These are short-circuiting reductions of a whole Flux to a single Mono<Boolean>. all() subscribes, tests each element with the predicate, and as soon as one fails it emits false and cancels the upstream; if the source completes with every element matching (or is empty) it emits true. any() is the mirror: it emits true and cancels on the first match, otherwise false at completion. hasElements() ignores values entirely, emitting true on the first onNext (then cancelling) or false if the source completes empty โ and hasElement(value) is simply any { it == value }. All of them emit exactly one Boolean followed by onComplete, and propagate an upstream onError instead of a Boolean.
- Validate that every item in a batch passes a rule before committing (all)
- Check if at least one record matches a condition, e.g. permission or flag (any)
- Detect whether a query/stream produced any results at all (hasElements)
- Gate downstream logic with switchIfEmpty or filterWhen using the Boolean result
On an empty source all() returns true (vacuous truth) while any() and hasElements() return false โ easy to get backwards. Also, because they short-circuit and cancel the upstream early, any side effects in later elements (logging, doOnNext) won't run; never rely on the whole stream being consumed.
Flux.empty<Int>()
.all { it > 100 }
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. The source is EMPTY. What does this snippet print?
import reactor.core.publisher.Flux
data class Payment(val id: String, val amount: Double, val verified: Boolean)
fun main() {
val payments = Flux.just(
Payment("p1", 250.0, true),
Payment("p2", 40.0, true),
Payment("p3", 3200.0, true)
)
// all: is every payment in the batch verified?
payments.all { it.verified }
.subscribe { println("All verified: $it") }
// any: does at least one payment exceed the fraud-review threshold?
payments.any { it.amount > 1000 }
.subscribe { println("Needs fraud review: $it") }
// hasElements: did the stream emit anything at all?
Flux.empty<Payment>().hasElements()
.subscribe { println("Has payments: $it") }
// hasElement: does the stream contain this exact value?
Flux.just("USD", "EUR", "MXN").hasElement("EUR")
.subscribe { println("Supports EUR: $it") }
}We have a batch of three payments. all { it.verified } scans them and, since none fails, emits true when the source completes. any { it.amount > 1000 } short-circuits: the moment p3 exceeds the threshold, it emits true and cancels the upstream. hasElements() on an empty Flux emits false at completion, and hasElement("EUR") emits true as soon as the matching currency appears:
All verified: true Needs fraud review: true Has payments: false Supports EUR: true
Empty sources are the classic trap: all() returns true on an empty stream (every element of none passes โ vacuous truth), while any() and hasElements() return false. And because all three short-circuit and cancel the upstream, never rely on side effects from later elements running.
Timing & Delay
Everything in a reactive stream happens on a timeline โ and these operators let you bend it. You can pace a burst of values apart, push the whole stream to a later start, hold each element back until some asynchronous work tied to it finishes, give a silent source a deadline, or simply measure when things happened.
- delayElements / delaySequence โ space a burst out evenly, or shift the whole stream while keeping its rhythm
- delaySubscription โ start later: the source is not even subscribed until the timer fires
- delayUntil โ hold each element until an async action derived from it completes
- timeout โ fail fast (or switch to a fallback) when the source goes silent for too long
- elapsed / timestamp / timed โ annotate every value with when it happened
One detail that surprises newcomers: all of these operators move work onto Reactor's parallel Scheduler by default. As soon as a delay or a timer is involved, your values stop arriving on the thread that subscribed โ which is also why the examples below block with blockLast(), keeping main alive while the timers fire.
delayElements / delaySequence
fun <T> Flux<T>.delayElements(delay: Duration): Flux<T>delayElements signals each onNext on a parallel scheduler after the given Duration has elapsed since that element was received, so every value is individually time-shifted while strict source order is preserved. Elements are neither dropped nor reordered, and the onComplete/onError terminal signal passes through after the last delayed element (the terminal is not itself delayed). Because work is rescheduled, downstream runs on a Schedulers.parallel() worker rather than the source thread.
- Throttle/space out emissions to a rate-limited downstream API
- Simulate steady streaming or paced UI updates from a fast source
- Add a uniform per-item pause when replaying events
- Smooth bursty sources into evenly-timed elements
delayElements delays each element relative to when it arrives, not by accumulating offsets; with a fast source it effectively paces emissions one delay apart, but it does NOT preserve the original inter-element timing of a slow source (use delaySequence for that). It also moves emission onto Schedulers.parallel(), so downstream no longer runs on the source thread.
Flux.just("A", "B", "C")
.delayElements(Duration.ofMillis(100))
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit, and how is the timing affected?
Imagine a flight-status app that receives three push notifications in the same instant. Firing them all at once feels like spam; spacing them out reads like a story. delayElements(d) does exactly that: it holds every element back by the duration d, so a bursty source comes out paced โ each value arrives at least d after the previous one, in the original order.
Its sibling delaySequence(d) answers a different question: it shifts the entire stream d into the future while preserving the original spacing between elements. Use delayElements to change the rhythm, delaySequence to keep the rhythm but start later.
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
val t0 = System.currentTimeMillis()
fun stamp() = "+${System.currentTimeMillis() - t0}ms"
println("delayElements โ every notification paced 300ms apart:")
Flux.just("boarding pass ready", "gate changed to B12", "boarding started")
.delayElements(Duration.ofMillis(300))
.doOnNext { println(" ${stamp()} $it") }
.blockLast()
println("delaySequence โ same 200ms rhythm, whole stream shifted 500ms:")
val t1 = System.currentTimeMillis()
Flux.interval(Duration.ofMillis(200))
.take(3)
.delaySequence(Duration.ofMillis(500))
.doOnNext { println(" +${System.currentTimeMillis() - t1}ms tick $it") }
.blockLast()
}We have three flight notifications that are all ready immediately. delayElements(300ms) releases them one every ~300 ms โ the timestamps show ~300, ~600 and ~900 ms. Then an interval source ticks every 200 ms; delaySequence(500ms) pushes the first tick from 200 ms to ~700 ms but keeps the 200 ms gaps between ticks intact. Finally, blockLast() waits for each stream to finish so main does not exit early.
delayElements โ every notification paced 300ms apart: +346ms boarding pass ready +639ms gate changed to B12 +938ms boarding started delaySequence โ same 200ms rhythm, whole stream shifted 500ms: +708ms tick 0 +911ms tick 1 +1115ms tick 2
delayElements delays each element relative to when it arrives and preserves order, so a burst comes out paced d apart โ but it does NOT reproduce a slow source's original spacing; that is delaySequence's job. Both operators move emissions onto Schedulers.parallel(), so downstream code no longer runs on the subscribing thread.
delaySubscription
fun <T> Flux<T>.delaySubscription(delay: Duration): Flux<T>delaySubscription waits the given Duration before propagating the subscribe signal upstream, so the source is not even subscribed to until the timer fires. Once the delay elapses, the source runs normally and every element, plus the terminal onComplete/onError, flows through unchanged โ the entire stream is simply time-shifted to the right. The delay runs on the parallel Scheduler by default (or one you pass), not on the subscribing thread.
- Stagger startup so a downstream dependency is ready before the source begins
- Introduce a fixed warm-up or backoff before polling an API or resource
- Gate a cold publisher on another signal (delaySubscription(Publisher) variant)
- Build deliberate delays in tests or demos without altering emission timing
It delays only the subscription, not the elements: once the source starts, items emit at their natural pace, so this does NOT add a gap between elements (use delayElements for that). On a hot source the delay can mean you miss everything emitted during the wait, since you subscribe late.
Flux.just("A", "B", "C")
.delaySubscription(Duration.ofSeconds(1))
.subscribe { v -> println("got " + v) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit, and when?
Sometimes the stream itself is fine โ it just starts too early. Maybe you want a health check to begin only after the app has had half a second to warm up, or you are staggering several pollers so they do not all hit an API at once. delaySubscription(d) postpones the moment the source is subscribed: nothing upstream runs, connects or emits until the timer fires.
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
val t0 = System.currentTimeMillis()
fun stamp() = "+${System.currentTimeMillis() - t0}ms"
println("${stamp()} subscribe() called โ health check scheduled...")
Flux.just("db: ok", "cache: ok", "queue: ok")
.doOnSubscribe { println("${stamp()} source subscribed, checks begin") }
.delaySubscription(Duration.ofMillis(500))
.doOnNext { println("${stamp()} $it") }
.blockLast()
}We call subscribe (via blockLast) at +0ms, but doOnSubscribe โ placed on the source, above delaySubscription โ shows the source is not touched until ~500 ms later. Once the real subscription happens, the three checks flow immediately, back to back: the delay moved the start of the stream, not the gaps inside it.
+0ms subscribe() called โ health check scheduled... +546ms source subscribed, checks begin +547ms db: ok +547ms cache: ok +547ms queue: ok
On a cold publisher this is a pure time shift. On a hot source (a live ticker, a shared bus) subscribing late means missing everything emitted during the wait โ delaySubscription does not buffer anything for you.
delayUntil
fun <T> Flux<T>.delayUntil(triggerProvider: (T) -> Publisher<*>): Flux<T>delayUntil derives a companion Publisher from each element via the triggerProvider lambda and holds that element back until the companion terminates โ whatever the companion emits is ignored; only its completion matters. The element then continues downstream unchanged. Companions are subscribed one at a time, in source order: the next element's trigger starts only after the previous element has been released, so ordering is always preserved and triggers never overlap. If a companion errors, that error is propagated downstream and the element is never emitted.
- Run an async side action per element (audit write, e-mail, cache warm-up) without changing the value
- Emit an entity only after its save()/persist() call has completed
- Wait on a per-element precondition โ a lock, a rate-limit token, a saga step โ before releasing it
- Keep strict source order where flatMap { work(it).thenReturn(it) } could interleave
delayUntil is strictly sequential: it never subscribes to the next companion until the current element has been released, so total time is the SUM of all trigger durations โ a slow trigger throttles the entire stream. If the side actions may run concurrently (and you can give up ordering), flatMap is the right tool; delayUntil deliberately trades throughput for order.
Flux.just("order-1", "order-2")
.delayUntil { o -> Mono.just(o.uppercase()).delayElement(Duration.ofMillis(100)) }
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. The trigger Mono emits a transformed value. What does the subscriber print?
You often need to do something asynchronous for each element without changing it: e-mail a receipt per order, persist an audit row per event, wait for a per-item lock. The tempting โ and wrong โ reflex is flatMap { doSideThing(it).thenReturn(it) }: it works, but it buries a simple idea in plumbing, and with flatMap it can even reorder your elements. delayUntil is the idiomatic answer.
flux.delayUntil { trigger(it) } derives a companion publisher from each element and holds that element back until the companion completes. The value comes out untouched, order is preserved, and the triggers run sequentially: order-2's receipt is only sent after order-1 has been released downstream.
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.time.Duration
fun main() {
val t0 = System.currentTimeMillis()
fun stamp() = "+${System.currentTimeMillis() - t0}ms"
// Simulated async side action: e-mail a receipt, takes ~250ms
fun sendReceipt(order: String): Mono<Long> =
Mono.delay(Duration.ofMillis(250))
.doOnNext { println(" ${stamp()} receipt e-mailed for $order") }
Flux.just("order-1", "order-2", "order-3")
.delayUntil { order -> sendReceipt(order) }
.doOnNext { println("${stamp()} $it forwarded downstream") }
.blockLast()
}We have three orders. For each one, delayUntil calls sendReceipt(order) โ a Mono that takes ~250 ms, simulating an e-mail round-trip โ and waits for it to complete before letting the order continue. The log shows the strict sequence: receipt for order-1, then order-1 downstream, then order-2's receipt, and so on. Total time is ~3 ร 250 ms, because delayUntil is deliberately sequential.
+310ms receipt e-mailed for order-1 +310ms order-1 forwarded downstream +565ms receipt e-mailed for order-2 +565ms order-2 forwarded downstream +819ms receipt e-mailed for order-3 +819ms order-3 forwarded downstream
delayUntil vs flatMap: if you do not need the async action's result and want to keep order, delayUntil says it in one word. If you DO want the results merged in (and can accept interleaving), that is flatMap's job. And if a trigger errors, delayUntil propagates the error โ that element is never released.
timeout
fun <T> Flux<T>.timeout(timeout: Duration): Flux<T>timeout starts a per-item timer that is reset every time the source emits. If no onNext (or terminal signal) arrives within the given Duration after the previous emission, it cancels the source and propagates a TimeoutException downstream, terminating the sequence; here A, B, C pass through, then the silence after C exceeds d and the stream errors (X). The first timer also covers the gap before the very first item. The timer runs on Schedulers.parallel() by default unless an explicit scheduler is supplied.
- Bound latency of slow upstream network or DB calls
- Detect a stalled or silent stream and fail fast
- Trigger a fallback Publisher when a source goes quiet
- Enforce per-item SLAs in streaming pipelines
timeout measures the gap BETWEEN consecutive emissions, not the total duration of the stream: a source that steadily emits every interval below d never times out even if it runs forever. Also, without a fallback the TimeoutException terminates the sequence and cancels the source, so any in-flight work is lost.
val flux = Flux.just("A", "B", "C", "D")
.concatMap { v -> Mono.just(v).delayElement(if (v == "D") Duration.ofSeconds(2) else Duration.ofMillis(50)) }
.timeout(Duration.ofSeconds(1))
flux.subscribe(::println) { err -> println("error: " + err) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. Given this Flux where the source delays the 4th item past the timeout window, what does the subscriber receive?
A stream that errors is easy to handle; a stream that goes quiet is far more dangerous โ your pipeline just sits there forever. timeout(d) arms a timer after every emission (and at subscribe time for the first one). If the next signal does not arrive within d, it cancels the source and fails with a TimeoutException. Pass a fallback publisher and it switches over instead of failing.
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.time.Duration
import java.util.concurrent.CountDownLatch
fun main() {
// 1) Without fallback: silence beyond 400ms becomes a TimeoutException
val done = CountDownLatch(1)
Flux.just("heartbeat-1", "heartbeat-2")
.delayElements(Duration.ofMillis(150))
.concatWith(Flux.never()) // the monitored service goes silent
.timeout(Duration.ofMillis(400))
.subscribe(
{ println("received: $it") },
{ e -> println("alert: ${e.message}"); done.countDown() }
)
done.await()
// 2) With fallback: switch to a cached quote instead of failing
Flux.just("live price: 101.30")
.concatWith(Flux.never()) // the exchange feed stalls
.timeout(Duration.ofMillis(300), Mono.just("cached price: 99.80"))
.doOnNext { println(it) }
.blockLast()
}The first pipeline is a heartbeat monitor: two heartbeats arrive 150 ms apart โ well inside the 400 ms window โ then concatWith(Flux.never()) makes the service go silent. 400 ms later, timeout cancels the source and our error handler prints the alert. The second pipeline asks a price feed that stalls immediately: instead of erroring, timeout(300ms, fallback) swaps in the cached quote and the stream completes normally.
received: heartbeat-1 received: heartbeat-2 alert: Did not observe any item or terminal signal within 400ms (and no fallback has been configured) live price: 101.30 cached price: 99.80
timeout measures the gap between consecutive signals, not the total duration of the stream: a source that emits every 100 ms sails through timeout(Duration.ofMillis(200)) forever. To bound total time, use take(Duration) or apply the timeout to an aggregate like collectList().
elapsed / timestamp / timed
fun <T> Flux<T>.elapsed(): Flux<Tuple2<Long, T>> // also timestamp(): Flux<Tuple2<Long, T>>, timed(): Flux<Timed<T>>elapsed() maps each onNext to a Tuple2 where T1 is the milliseconds elapsed since the previous emission (or since subscription for the first value) and T2 is the original value, so [A,B,C,D] becomes [[150,A],[250,B],[220,C],[200,D]]. timestamp() instead pairs each value with the absolute epoch-millis at emission time, and timed() wraps the value in a Timed object carrying both elapsed and timestamp at nanosecond resolution. All three are transparent pass-throughs: they preserve order and forward the complete/error terminal signal unchanged, and they measure time on the parallel Scheduler by default (configurable via an overload).
- Measuring latency or gaps between events in a stream
- Logging or metrics with per-element timing for diagnostics
- Detecting slow producers or backpressure stalls
- Adding wall-clock timestamps for audit trails or event sourcing
elapsed() resets its reference clock at subscription, so the FIRST value's elapsed time is measured from subscribe, not from any prior signal; also it times on the parallel Scheduler by default, which can skew measurements unless you pass an explicit Scheduler. Don't confuse elapsed() (relative gap, ms) with timestamp() (absolute epoch, ms) โ they return the same Tuple2<Long,T> shape but the Long means completely different things.
Flux.just("A", "B", "C", "D")
.delayElements(Duration.ofMillis(200))
.elapsed()
.subscribe { pair -> println(pair.t1.toString() + " -> " + pair.t2) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. Given the marble timing, what does this Flux emit?
When a pipeline feels slow, the first question is where the time goes. elapsed() turns a Flux<T> into a Flux<Tuple2<Long, T>> where the Long is the milliseconds since the previous emission (measured from subscription for the first value). timestamp() pairs each value with the absolute epoch-millis at which it was emitted, and timed() wraps both โ at nanosecond precision โ in a Timed<T> object.
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
// elapsed: how many ms passed since the previous reading?
Flux.interval(Duration.ofMillis(200))
.take(3)
.elapsed()
.doOnNext { (gap, n) -> println("reading $n arrived ${gap}ms after the previous one") }
.blockLast()
// timestamp: at what wall-clock instant did each user event happen?
Flux.just("login", "add-to-cart", "checkout")
.delayElements(Duration.ofMillis(100))
.timestamp()
.doOnNext { (at, event) -> println("$event at epoch $at") }
.blockLast()
}First, a sensor emits a reading every 200 ms; elapsed() reports each reading's gap โ 210, 200 and 202 ms here, because real timers are never exact. Then a user's shopping session, spaced out by delayElements(100ms), goes through timestamp(): each event comes out paired with the epoch instant at which it passed through the operator.
reading 0 arrived 210ms after the previous one reading 1 arrived 200ms after the previous one reading 2 arrived 202ms after the previous one login at epoch 1783371424258 add-to-cart at epoch 1783371424361 checkout at epoch 1783371424461
The same Tuple2<Long, T> shape means opposite things: elapsed()'s Long is a relative gap in ms, while timestamp()'s Long is an absolute epoch instant. When you need both at nanosecond precision, reach for timed() and its Timed<T> accessors โ elapsed(), timestamp() and elapsedSinceSubscription().
Backpressure
Every Reactive Streams subscriber controls its own inflow: it tells the publisher how many elements it is ready to receive by calling request(n) on its Subscription. That demand signal โ flowing upstream, against the direction of the data โ is backpressure. While the consumer keeps up you never notice it. The interesting question is what should happen when a producer emits faster than the consumer can process: a price feed ticking every millisecond in front of a UI that repaints thirty times a second, or a sensor burst hitting a database that inserts one row at a time.
import reactor.core.publisher.Flux
fun main() {
Flux.range(1, 4)
.doOnRequest { n -> println("upstream sees: request($n)") }
.subscribe { println("received: $it") }
}We have a stream of four values and a doOnRequest probe that prints every demand signal the source receives. A plain subscribe { } does not limit anything: it requests Long.MAX_VALUE up front โ "send me everything you have". Finally the four values arrive. That unbounded default is exactly why this family of operators exists: when the consumer is slower than the producer, somebody has to decide what happens to the surplus.
upstream sees: request(9223372036854775807) received: 1 received: 2 received: 3 received: 4
Each operator in this category picks a different trade-off for the overflow:
- Keep everything, deliver it later โ onBackpressureBuffer
- Keep what fits, discard the rest โ onBackpressureDrop
- Keep only the freshest value โ onBackpressureLatest
- Treat overflow as a bug and fail fast โ onBackpressureError
- Don't lose anything โ reshape demand into small batches instead โ limitRate
onBackpressureBuffer
fun <T> Flux<T>.onBackpressureBuffer(): Flux<T> // also (maxSize), (maxSize, BufferOverflowStrategy), (maxSize, onOverflow)When the downstream requests fewer items than the source emits, onBackpressureBuffer holds the surplus in a FIFO queue and replays it as demand arrives, preserving original order. With no argument the buffer is unbounded; with a maxSize it is bounded and, once full, applies an overflow strategy (default ERROR, emitting an IllegalStateException; or DROP_LATEST / DROP_OLDEST). Terminal signals (onComplete/onError) are forwarded only after all buffered elements have been drained downstream.
- Bursty fast producer feeding a slower consumer (sensor/event spikes)
- Hot sources that don't honor backpressure (e.g. UI events, websockets)
- Smoothing temporary throughput mismatches without dropping data
- Bounding memory with maxSize plus a DROP or onOverflow callback
The default unbounded buffer can grow without limit and cause OutOfMemoryError if the source keeps outpacing the consumer; always consider a bounded maxSize. A bounded buffer with the default strategy ERRORS (IllegalStateException) the moment it overflows, terminating the stream โ use DROP_OLDEST/DROP_LATEST if you'd rather shed data than fail.
Flux.just(1, 2, 3, 4, 5, 6)
.onBackpressureBuffer()
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. A fast source emits 1..6 then completes, with a slow consumer. What does this Flux emit?
onBackpressureBuffer is the "lose nothing" strategy: when the consumer falls behind, the overflowing elements go into a FIFO queue and are replayed โ in their original order โ as demand returns. Reach for it when every element matters (orders, payments, readings that feed an audit trail) and the speed mismatch is a temporary burst rather than a permanent difference.
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
Flux.interval(Duration.ofMillis(20)) // sensor: one reading every 20 ms
.map { "reading-$it" }
.take(10)
.onBackpressureBuffer() // queue whatever the writer can't take yet
.delayElements(Duration.ofMillis(60)) // database writer: one insert every 60 ms
.doOnNext { println("persisted: $it") }
.blockLast()
}We have a sensor producing one reading every 20 ms and a database writer that only manages one insert every 60 ms โ a consumer three times slower than its producer. onBackpressureBuffer() parks every reading the writer has not asked for yet, and delayElements plays the part of the slow writer. Finally we subscribe and print each insert: all ten readings are persisted, in the exact order they were produced, just later than they arrived.
persisted: reading-0 persisted: reading-1 persisted: reading-2 persisted: reading-3 persisted: reading-4 persisted: reading-5 persisted: reading-6 persisted: reading-7 persisted: reading-8 persisted: reading-9
The no-argument version buffers without limit, which is also its danger: if the consumer never catches up, the queue grows until the JVM runs out of memory. Production code usually passes a maxSize plus a BufferOverflowStrategy that decides what to do when the queue is full: ERROR (the default) terminates the stream with an IllegalStateException, DROP_OLDEST evicts the oldest buffered element to make room, and DROP_LATEST rejects the newcomer to protect what is already queued.
import reactor.core.publisher.Flux
import reactor.core.publisher.BufferOverflowStrategy
import java.time.Duration
fun main() {
Flux.interval(Duration.ofMillis(2)) // ~500 readings per second
.map { "reading-$it" }
.take(50)
.onBackpressureBuffer(
5, // keep at most 5 waiting readings
{ dropped -> println(" rejected: $dropped") },
BufferOverflowStrategy.DROP_LATEST // full? reject the newcomer
)
.delayElements(Duration.ofMillis(15)) // consumer: ~66 per second
.doOnNext { println("stored: $it") }
.blockLast()
}We have ~500 readings per second racing into a consumer that stores ~66 per second, with room for only five waiting readings. The first ~40 readings flow through while the consumer's prefetch absorbs them; then the little queue fills up and DROP_LATEST starts rejecting fresh arrivals (reading-42 onward) to protect the ones already waiting โ each rejection handed to the overflow callback. When a slot frees up at the right moment, one newcomer (reading-46) sneaks in. Everything that made it into the buffer is eventually stored, in order.
stored: reading-0 stored: reading-1 stored: reading-2 stored: reading-3 stored: reading-4 rejected: reading-42 rejected: reading-43 rejected: reading-44 rejected: reading-45 stored: reading-5 rejected: reading-47 rejected: reading-48 rejected: reading-49 stored: reading-6 stored: reading-7 stored: reading-8 stored: reading-9 stored: reading-10 stored: reading-11 stored: reading-12 stored: reading-13 stored: reading-14 stored: reading-15 stored: reading-16 stored: reading-17 stored: reading-18 stored: reading-19 stored: reading-20 stored: reading-21 stored: reading-22 stored: reading-23 stored: reading-24 stored: reading-25 stored: reading-26 stored: reading-27 stored: reading-28 stored: reading-29 stored: reading-30 stored: reading-31 stored: reading-32 stored: reading-33 stored: reading-34 stored: reading-35 stored: reading-36 stored: reading-37 stored: reading-38 stored: reading-39 stored: reading-40 stored: reading-41 stored: reading-46
The unbounded default can end in OutOfMemoryError if the consumer never catches up โ prefer a bounded maxSize. And remember that a bounded buffer without an explicit strategy uses ERROR: it kills the whole stream with an IllegalStateException the instant it overflows. If shedding data is acceptable, say so explicitly with DROP_OLDEST or DROP_LATEST.
onBackpressureDrop
fun <T> Flux<T>.onBackpressureDrop(onDropped: (T) -> Unit = {}): Flux<T>onBackpressureDrop relays items downstream as long as there is outstanding request; whenever an item arrives but downstream demand is zero, that item is dropped instead of buffered, so the operator never overflows and request to the source effectively becomes unbounded. Delivered items keep their original order (1, 4, 7 in the marble โ the in-between items were dropped while the consumer was busy), and terminal signals (onComplete/onError) pass through normally once they are reached. The optional onDropped callback is invoked synchronously, on the producing thread, for each discarded element so you can log it or release resources.
- Real-time telemetry, sensor or stock-tick feeds where only the latest data matters and stale items are worthless
- Hot sources you cannot slow down (mouse moves, UI events) feeding a slower processor
- Protecting a slow consumer from OOM when bursts exceed processing capacity
- Metrics/logging pipelines where lossy sampling under load is acceptable
It silently loses data โ this is lossy by design, so it is wrong wherever every item must be processed (payments, persistence, exactly-once). Also, because it requests unbounded from upstream, an unbounded fast source plus a fast onDropped callback can still peg a CPU core; the drop happens at this operator, not at the source.
Flux.just(1, 2, 3, 4, 5, 6, 7)
.onBackpressureDrop { dropped -> println("dropped " + dropped) }
.subscribe { value -> println("got " + value) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. A fast Flux is consumed slowly, so onBackpressureDrop() sheds overflow items. Given the marble diagram (source 1..7, only 1, 4 and 7 survive), what does the downstream actually receive?
onBackpressureDrop is the "shed load" strategy: if the consumer has no outstanding demand when an element arrives, that element is discarded on the spot โ optionally handed to a callback โ and the stream simply moves on. It shines with live data that goes stale instantly: price tickers, mouse positions, progress updates. Rendering last second's price late is worse than never rendering it at all.
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
Flux.interval(Duration.ofMillis(3)) // price ticks, ~330 per second
.map { "tick-$it" }
.take(45)
.onBackpressureDrop { println(" dropped: $it") }
.delayElements(Duration.ofMillis(40)) // UI renders ~25 per second
.doOnNext { println("rendered: $it") }
.blockLast()
}We have a price feed ticking every 3 ms and a UI that renders one tick every 40 ms. The first ~34 ticks squeeze into the renderer's prefetch buffer; from there on, whatever arrives while the UI is busy is dropped and logged by the callback. Notice tick-41 making it through the middle of the drops: a slot freed up at just the right moment. When the source completes, the stream completes normally โ dropping is a policy, not an error.
rendered: tick-0 rendered: tick-1 dropped: tick-34 dropped: tick-35 dropped: tick-36 dropped: tick-37 dropped: tick-38 dropped: tick-39 dropped: tick-40 rendered: tick-2 dropped: tick-42 dropped: tick-43 dropped: tick-44 rendered: tick-3 rendered: tick-4 rendered: tick-5 rendered: tick-6 rendered: tick-7 rendered: tick-8 rendered: tick-9 rendered: tick-10 rendered: tick-11 rendered: tick-12 rendered: tick-13 rendered: tick-14 rendered: tick-15 rendered: tick-16 rendered: tick-17 rendered: tick-18 rendered: tick-19 rendered: tick-20 rendered: tick-21 rendered: tick-22 rendered: tick-23 rendered: tick-24 rendered: tick-25 rendered: tick-26 rendered: tick-27 rendered: tick-28 rendered: tick-29 rendered: tick-30 rendered: tick-31 rendered: tick-32 rendered: tick-33 rendered: tick-41
This operator is lossy by design โ never put it in a flow where every element must be processed (payments, persistence, exactly-once pipelines). Also note the onDropped callback runs synchronously on the producing thread: keep it cheap (a counter or a metrics call), not another slow operation.
onBackpressureLatest
fun <T> Flux<T>.onBackpressureLatest(): Flux<T>When the downstream requests slower than the upstream produces, onBackpressureLatest holds a single-element buffer containing only the most recent value; each new arrival overwrites the previously held one, so older undelivered items are silently dropped. On the next downstream request the consumer receives whatever the latest cached value was at that moment, preserving emission order for delivered items. Terminal signals (onComplete/onError) pass straight through once the buffered value, if any, has been emitted โ in the marble A is delivered immediately, B and C are overwritten by D, and D then E reach the slow consumer before complete.
- Live sensor, price, or telemetry feeds where only the freshest reading matters
- UI/state updates that should snap to the most recent value, skipping stale intermediates
- Fast hot publishers feeding a slower consumer that cannot afford unbounded buffering
- Dashboards or gauges that render the latest tick rather than every historical one
It does NOT slow the source โ the upstream still runs at full speed and you silently lose every value except the latest, so it is wrong for any flow where each element must be processed (use onBackpressureBuffer for that). The drop is also racy: a value can be discarded the instant before a request arrives, and dropped items skip onNext entirely (no per-item callback unless you use the discard hook).
val source = Flux.just("A", "B", "C", "D", "E")
.onBackpressureLatest()
// slow subscriber requests items one at a time
source.subscribe(slowConsumer)๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. A fast Flux feeds a slow subscriber through .onBackpressureLatest(). Given source values A, B, C, D, E then complete, what sequence does the slow consumer ultimately receive?
onBackpressureLatest keeps a buffer of exactly one element: the newest. Each arrival overwrites the previous pending value, and when the consumer finally asks for more, it receives only the freshest state. It is the natural fit for GPS positions, gauge readings, "current state" feeds โ anywhere an intermediate value becomes obsolete the moment a newer one exists.
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
Flux.interval(Duration.ofMillis(3)) // a GPS fix every 3 ms
.map { "position-$it" }
.take(100)
.onBackpressureLatest() // keep only the freshest position
.delayElements(Duration.ofMillis(40)) // map redraw, ~25 per second
.doOnNext { println("map shows: $it") }
.blockLast()
}We have a GPS fix every 3 ms and a map that redraws every 40 ms. The first ~34 positions flow straight through while the renderer's prefetch lasts; after that the map is permanently behind, so each redraw shows whatever fix is freshest at that instant โ watch the sequence jump from 33 to 40, then 53, 67, 80, 94. One guarantee stands out at the end: the final element (position-99) is always delivered, because nothing newer ever overwrites it.
map shows: position-0 map shows: position-1 map shows: position-2 map shows: position-3 map shows: position-4 map shows: position-5 map shows: position-6 map shows: position-7 map shows: position-8 map shows: position-9 map shows: position-10 map shows: position-11 map shows: position-12 map shows: position-13 map shows: position-14 map shows: position-15 map shows: position-16 map shows: position-17 map shows: position-18 map shows: position-19 map shows: position-20 map shows: position-21 map shows: position-22 map shows: position-23 map shows: position-24 map shows: position-25 map shows: position-26 map shows: position-27 map shows: position-28 map shows: position-29 map shows: position-30 map shows: position-31 map shows: position-32 map shows: position-33 map shows: position-40 map shows: position-53 map shows: position-67 map shows: position-80 map shows: position-94 map shows: position-99
onBackpressureError
fun <T> Flux<T>.onBackpressureError(): Flux<T>Relays elements downstream as long as it has outstanding demand (request(n)). The moment the source emits an item while downstream demand is zero, it terminates the stream with an onError carrying IllegalStateException ("The receiver is overrunning its capacity"), as shown by [1,2] passing through before X. It is fail-fast: no buffering, no dropping callback, just an immediate error signal that cancels the upstream.
- When overflow signals a real bug you want surfaced loudly instead of silently dropped
- Strict systems where losing data is worse than crashing the pipeline
- Testing/diagnosing whether a consumer can actually keep up with a producer
- Fast-fail contracts where downstream must honor backpressure or abort
This is the strictest strategy: a single overflow tears down the whole stream with an error, so the downstream stops receiving everything, not just the offending item. The error is an IllegalStateException, not a domain error, so make sure your onError handling doesn't mistake it for a normal failure.
Flux.just(1, 2, 3, 4, 5)
.onBackpressureError()
.subscribe(
{ value -> println("next=" + value) },
{ err -> println("error=" + err) }
)๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. A slow subscriber requests only 2 items, but the source pushes 1,2,3,4,5 faster than they are consumed. What does this Flux emit to the subscriber?
onBackpressureError treats overflow as a defect, not as a condition to manage: the first element that arrives without downstream demand terminates the stream with an IllegalStateException. Choose it when losing data silently would be worse than crashing โ payments, inventory movements, billing events โ or when you want a load mismatch to page an operator instead of hiding inside a buffer.
import reactor.core.publisher.Flux
import reactor.core.scheduler.Schedulers
import java.time.Duration
fun main() {
Flux.interval(Duration.ofMillis(1)) // transactions arrive every 1 ms
.map { "TXN-${1000 + it}" }
.onBackpressureError() // overflow must never be silent
.publishOn(Schedulers.single())
.doOnNext { Thread.sleep(30) } // each commit takes 30 ms
.subscribe(
{ println("committed: $it") },
{ err -> println("ALERT ${err::class.simpleName}: ${err.message}") }
)
Thread.sleep(600)
}We have transactions arriving every millisecond and a committer that takes 30 ms each, moved to its own thread by publishOn. The first commits go through while publishOn's prefetch queue absorbs the difference, but the producer relentlessly outruns the consumer; the moment an element shows up with zero demand left, onBackpressureError cancels the source and pushes an onError downstream. Finally our error handler prints the alert โ and nothing after it is ever delivered, because the error is terminal.
committed: TXN-1000 committed: TXN-1001 committed: TXN-1002 committed: TXN-1003 committed: TXN-1004 committed: TXN-1005 committed: TXN-1006 committed: TXN-1007 ALERT IllegalStateException: The receiver is overrun by more signals than expected (bounded queue...)
A single overflow tears down the entire stream โ every subsequent element is lost, not just the one that overflowed. If you use onBackpressureError as a canary, pair it with retry/resume logic upstream of your subscriber, and make sure your onError handling does not mistake the IllegalStateException for a business failure.
limitRate
fun <T> Flux<T>.limitRate(prefetchRate: Int): Flux<T> // also limitRate(highTide, lowTide)limitRate is a value-transparent operator: every item from upstream (1,2,3,4,5,6) and the terminal complete/error signal pass through downstream unchanged and in the same order. What it changes is the backpressure protocol: instead of propagating the downstream's request (often unbounded) straight upstream, it requests in fixed chunks of `prefetchRate` and replenishes by re-requesting once 75% of the batch has been consumed, smoothing demand on the source.
- Throttling a source that paginates or fetches in pages (e.g. a DB cursor or HTTP API)
- Capping prefetch so a fast unbounded downstream doesn't pull everything at once
- Tuning memory usage by bounding how many in-flight items are buffered
- Pacing requests to an external system that performs better with small batch sizes
limitRate does NOT drop or rate-limit items over time like a debounce/sample โ all elements still arrive; it only reshapes backpressure requests. By default it also has a 75% low-tide replenish point (use the limitRate(highTide, lowTide) overload to change it), and setting lowTide to 0 makes it re-request only after fully draining each batch. To cap TOTAL demand instead, don't reach for the deprecated limitRequest(n) โ use take(n, true).
Flux.just(1, 2, 3, 4, 5, 6)
.limitRate(2)
.subscribe { print(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit to the subscriber?
limitRate does not drop, delay, or transform anything โ every element still arrives, in order. What it changes is the demand your pipeline sends upstream: instead of forwarding a huge (often unbounded) request as-is, it asks for elements in chunks of prefetchRate and replenishes once 75% of the current batch has been consumed. Use it to stop an eager consumer from asking a paginated API, a database cursor, or a message broker for everything at once.
import reactor.core.publisher.Flux
// A paginated API: doOnRequest shows every demand signal it receives
fun paginatedApi(): Flux<String> = Flux.range(1, 12)
.doOnRequest { n -> println(" API sees: request($n)") }
.map { "page-$it" }
fun main() {
println("Without limitRate:")
paginatedApi().subscribe { println(" got $it") }
println("With limitRate(4):")
paginatedApi()
.limitRate(4)
.subscribe { println(" got $it") }
}We have a paginated API instrumented with doOnRequest so we can watch demand arrive. Subscribing directly sends request(9223372036854775807) โ Long.MAX_VALUE, unbounded. Through limitRate(4) the very same subscriber becomes polite: the API first sees request(4) and then, each time 75% of a batch is consumed, a replenishing request(3). Finally all twelve pages come out both times, unchanged and in order โ only the request pattern differs.
Without limitRate: API sees: request(9223372036854775807) got page-1 got page-2 got page-3 got page-4 got page-5 got page-6 got page-7 got page-8 got page-9 got page-10 got page-11 got page-12 With limitRate(4): API sees: request(4) got page-1 got page-2 got page-3 got page-4 API sees: request(3) got page-5 got page-6 got page-7 API sees: request(3) got page-8 got page-9 got page-10 API sees: request(3) got page-11 got page-12
Deprecation note: limitRequest(n) โ which capped the TOTAL demand instead of batching it โ is deprecated; use take(n, true) instead. And since Reactor 3.5, plain take(n) also caps upstream request by default (it used to request unbounded and simply cancel after n). Rule of thumb: limitRate reshapes demand into batches; take(n, true) puts a hard ceiling on it.
Scheduling
By design, Reactor is concurrency-agnostic: a Flux does not own a thread. Nothing runs while you assemble the pipeline, and when you finally subscribe, every step โ from the source's emissions to your subscriber callback โ executes on whatever thread happened to call subscribe(). That is a feature, not a limitation: instead of the library imposing a threading model on you, you opt into one, exactly where you need it, with a handful of scheduling operators.
import reactor.core.publisher.Flux
fun main() {
println("running on: ${Thread.currentThread().name}")
Flux.just("order-1", "order-2")
.map { "$it charged on ${Thread.currentThread().name}" }
.subscribe { println(it) }
println("subscribe() returned on: ${Thread.currentThread().name}")
}We charge two orders and print the executing thread at every step. Since no scheduler is involved, the whole pipeline runs synchronously on the caller: the map and the subscriber callback both execute on main, and the final println only runs after the stream has already completed.
running on: main order-1 charged on main order-2 charged on main subscribe() returned on: main
When you do want to move work, you pick a Scheduler โ Reactor's abstraction over a thread pool โ and place one of the operators below in the chain. The built-in schedulers cover the usual suspects:
- Schedulers.parallel() โ a fixed pool sized to your CPU cores, for fast non-blocking computation
- Schedulers.boundedElastic() โ a bounded pool that grows on demand, made for wrapping blocking I/O (JDBC, files, legacy SDKs)
- Schedulers.single() โ one shared thread, for low-volume work that must stay ordered
- Schedulers.immediate() โ no hop at all: stay on the current thread (a no-op placeholder)
The mental model: subscribeOn decides where the source starts emitting (its position in the chain does not matter), publishOn re-routes every signal below it (position is everything), and parallel().runOn() is the only one of the three that gives you true multi-core fan-out.
publishOn
fun <T> Flux<T>.publishOn(scheduler: Scheduler): Flux<T>publishOn does not change the values or their order: [A,B,C,D] arrive identically followed by the same onComplete (or onError). What it changes is the thread: every signal emitted downstream (onNext, onComplete, onError) is re-dispatched onto a worker of the supplied Scheduler, and that thread affinity propagates to all operators below until the next publishOn. Items are handed off through a bounded queue, so the affected boundary runs sequentially on one Scheduler worker even though the upstream may run elsewhere.
- Move slow or blocking downstream work (parsing, mapping, I/O wrappers) off the producer thread
- Fast publisher, slow consumer: decouple emission rate from processing
- Hop CPU-bound transformations onto Schedulers.parallel() worker threads
- Get off a Netty/event-loop thread before doing heavier per-item processing
publishOn only affects operators placed AFTER it (downstream); to control the subscription/source thread you need subscribeOn instead. Also, a single Schedulers.parallel() worker processes the whole boundary sequentially, so publishOn does NOT parallelize per item, it only relocates the thread; use flatMap with inner publishOn/subscribeOn or parallel().runOn() for real fan-out.
val flux = Flux.just("A", "B", "C", "D", "OK")
.publishOn(Schedulers.parallel())
.doOnNext { v -> println(v + " on " + Thread.currentThread().name) }
flux.subscribe()๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit, and what changes when publishOn is applied?
publishOn is the operator you reach for when a stream is produced on a thread that must stay responsive โ a Netty event loop, a UI thread โ but consumed by work too heavy to run there. From the exact point where it appears in the chain, publishOn re-dispatches every signal (onNext, onComplete, onError) onto a worker of the Scheduler you supply, so everything downstream executes there until the next publishOn. Values, order and terminal signals are untouched; only the executing thread changes.
import reactor.core.publisher.Flux
import reactor.core.scheduler.Schedulers
fun main() {
// publishOn switches the thread for every operator placed AFTER it
Flux.just("btc", "eth", "sol")
.map { "$it mapped on ${Thread.currentThread().name}" }
.publishOn(Schedulers.parallel())
.map { "$it, then on ${Thread.currentThread().name}" }
.doOnNext { println(it) }
.blockLast()
}We map three ticker symbols twice, printing the thread each time. The first map sits above publishOn, so it runs on main. Then publishOn(Schedulers.parallel()) re-routes the stream: the second map and the doOnNext below it run on the parallel-1 worker. Same values, same order โ only the thread changed mid-chain.
btc mapped on main, then on parallel-1 eth mapped on main, then on parallel-1 sol mapped on main, then on parallel-1
In a real service the pattern usually has two hops: leave the event loop to do blocking I/O on boundedElastic, then hop again to parallel for CPU-bound post-processing. Each publishOn rules the segment between itself and the next one:
import reactor.core.publisher.Flux
import reactor.core.scheduler.Schedulers
// Real-world pattern: HTTP request arrives on Netty's event-loop thread.
// Heavy computation must NOT block the event loop โ use publishOn to offload.
fun main() {
Flux.just("user-request-1", "user-request-2", "user-request-3")
.doOnNext {
// This runs on the calling thread (e.g., Netty event-loop)
println("[$it] Received on: ${Thread.currentThread().name}")
}
.publishOn(Schedulers.boundedElastic()) // offload to elastic pool
.map { request ->
// Simulate heavy DB query โ now on boundedElastic, not event-loop!
Thread.sleep(50)
println("[$request] DB query on: ${Thread.currentThread().name}")
"$request โ result"
}
.publishOn(Schedulers.parallel()) // switch to parallel for CPU work
.map { result ->
println("[$result] Serialization on: ${Thread.currentThread().name}")
"{ \"data\": \"$result\" }"
}
.blockLast()
}The three requests are received on main, because that doOnNext sits above any boundary. The first publishOn moves the simulated 50 ms database query onto boundedElastic-1; the second moves serialization onto parallel-1. Watch the lines interleave: while request 1 is being serialized on the parallel worker, request 2's query is already running on the elastic one โ the chain has become a small assembly line.
[user-request-1] Received on: main [user-request-2] Received on: main [user-request-3] Received on: main [user-request-1] DB query on: boundedElastic-1 [user-request-1 โ result] Serialization on: parallel-1 [user-request-2] DB query on: boundedElastic-1 [user-request-2 โ result] Serialization on: parallel-1 [user-request-3] DB query on: boundedElastic-1 [user-request-3 โ result] Serialization on: parallel-1
publishOn does not parallelize. The whole downstream boundary is processed in order by ONE worker at a time โ it relocates work, it does not fan it out. For per-item concurrency use flatMap with an inner subscribeOn, or parallel().runOn() (below). And remember placement: operators above a publishOn are completely unaffected by it.
subscribeOn
fun <T> Flux<T>.subscribeOn(scheduler: Scheduler): Flux<T>subscribeOn doesn't change which values flow โ the marble in [1,2,3,4,done] passes through identically as out [1,2,3,4,done], preserving order, every element, and the terminal complete signal. What it changes is threading: the subscription, onSubscribe, and request signals run on a worker from the given Scheduler, so the source begins emitting from that thread. Because subscription propagates from the bottom of the chain up to the source, its position in the pipeline doesn't matter โ it affects the whole upstream from the source onward (until a later publishOn switches threads again).
- Wrapping a blocking/synchronous source (JDBC, file or legacy I/O) so it runs off the caller thread
- Offloading a slow or CPU-bound publisher onto Schedulers.boundedElastic() or parallel()
- Keeping the event loop / main thread free when subscribing to a blocking generator
- Choosing the thread where emission starts regardless of where the operator sits
Only the first subscribeOn in a chain wins โ later ones are effectively ignored for source placement. It is not the mirror of publishOn: subscribeOn affects the whole upstream regardless of position, while publishOn only moves signals downstream of itself. A subsequent publishOn will still re-route everything after it onto its own Scheduler.
val scheduler = Schedulers.boundedElastic()
Flux.just(1, 2, 3, 4)
.subscribeOn(scheduler)
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit, and on which thread does the subscription run?
Some publishers are born blocking: reading a file, calling JDBC, wrapping a legacy SDK. The work happens at subscription time, inside the source itself โ no publishOn placed after it can help, because by the time signals reach that boundary the blocking call has already run. subscribeOn fixes this at the root: it moves the act of subscribing (and therefore everything the source does to produce data) onto the Scheduler you give it. And because the subscription signal travels from the bottom of the chain up to the source, it does not matter where you put it โ it always affects the source.
import reactor.core.publisher.Flux
import reactor.core.scheduler.Schedulers
fun main() {
// subscribeOn dictates the thread the whole source chain runs on
Flux.fromIterable(listOf("invoice-1", "invoice-2", "invoice-3"))
.doOnSubscribe { println("subscribed on ${Thread.currentThread().name}") }
.map { "$it processed on ${Thread.currentThread().name}" }
.subscribeOn(Schedulers.boundedElastic())
.doOnNext { println(it) }
.blockLast()
}The Flux is assembled and subscribed from main, yet subscribeOn(Schedulers.boundedElastic()) relocates the subscription: doOnSubscribe already fires on boundedElastic-1, and each invoice is emitted and mapped there too. Notice that subscribeOn sits below the map in the chain and still moved it โ position does not matter.
subscribed on boundedElastic-1 invoice-1 processed on boundedElastic-1 invoice-2 processed on boundedElastic-1 invoice-3 processed on boundedElastic-1
The classic production pairing is subscribeOn for the blocking source plus publishOn for the processing below it:
import reactor.core.publisher.Flux
import reactor.core.scheduler.Schedulers
// subscribeOn moves the SOURCE to a different thread pool.
// Unlike publishOn, placement doesn't matter โ it always affects upstream.
// Use for blocking I/O sources that shouldn't run on the event loop.
fun blockingFileReader(): Flux<String> = Flux.create { sink ->
println("Reading file on: ${Thread.currentThread().name}")
// Simulate blocking file read
listOf("line-1: config=production", "line-2: port=8080", "line-3: debug=false")
.forEach { sink.next(it) }
sink.complete()
}
fun main() {
blockingFileReader()
.subscribeOn(Schedulers.boundedElastic()) // blocking I/O โ elastic
.publishOn(Schedulers.parallel()) // processing โ parallel
.filter { !it.contains("debug") }
.map { line ->
println("Parsing on: ${Thread.currentThread().name}")
val (key, value) = line.substringAfter(": ").split("=")
key to value
}
.doOnNext { (k, v) -> println("Config: $k = $v") }
.blockLast()
// Key insight: subscribeOn(elastic) ensures the file read doesn't
// block the caller's thread, while publishOn(parallel) offloads parsing.
}blockingFileReader simulates a blocking read inside Flux.create. subscribeOn(boundedElastic) sends that read to boundedElastic-1, so the calling thread is never blocked. From publishOn(parallel) downward, filtering and parsing run on parallel-1. The debug line is filtered out, which is why only two lines are parsed into config pairs.
Reading file on: boundedElastic-1 Parsing on: parallel-1 Config: config = production Parsing on: parallel-1 Config: port = 8080
One more rule worth memorizing: if two subscribeOn calls end up in the same chain โ yours plus one hiding inside a library, say โ the one closest to the source wins. The subscription signal travels upward, so the hop nearest the source happens last and decides where the source actually runs:
import reactor.core.publisher.Flux
import reactor.core.scheduler.Schedulers
fun main() {
// Two subscribeOn calls: only the one closest to the source wins
Flux.just("payment-1", "payment-2")
.map { "$it validated on ${Thread.currentThread().name}" }
.subscribeOn(Schedulers.boundedElastic()) // closest to the source โ wins
.subscribeOn(Schedulers.parallel()) // ignored for source placement
.doOnNext { println(it) }
.blockLast()
}payment-1 validated on boundedElastic-1 payment-2 validated on boundedElastic-1
Cheat sheet: subscribeOn = where the stream is born (affects the source, position-independent, closest-to-the-source wins). publishOn = where it flows from that point on (affects downstream only, position is everything, can appear several times).
parallel
fun <T> Flux<T>.parallel(parallelism: Int = cpuCount): ParallelFlux<T>; ParallelFlux<T>.runOn(scheduler: Scheduler): ParallelFlux<T>; ParallelFlux<T>.sequential(): Flux<T>parallel() splits the source into N 'rails' by round-robin distributing each onNext across rails (1->rail0, 2->rail1, ...), but it does NOT change threads on its own. runOn(scheduler) is what actually moves each rail onto a worker thread from the scheduler, so the per-rail operators (here a map x10) run concurrently. sequential() rejoins the rails back into a single Flux, draining rails in a fair round-robin so values may be reordered versus the source; the merged onComplete only fires once every rail has completed.
- CPU-bound transforms over a large stream (parsing, hashing, image/number crunching)
- Saturating multiple cores when per-item work is heavy and independent
- Parallel blocking I/O via runOn(Schedulers.boundedElastic()) across rails
- Fan out, compute per rail, then sequential() to feed a downstream that wants one Flux
Calling parallel() alone gives you nothing parallel โ without runOn() all rails still execute on the original calling thread; runOn() is mandatory for actual concurrency. Also, parallel processing breaks source ordering: sequential() does NOT restore the original order, so [1,2,3,4,5,6] may emerge interleaved. If you need parallelism AND order, prefer flatMapSequential or flatMap(..., concurrency) on a scheduler instead.
val par = Schedulers.parallel()
Flux.just(1, 2, 3, 4, 5, 6)
.parallel()
.runOn(par)
.map { it * 10 }
.sequential()
.subscribe { println("got " + it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this snippet emit when collected?
publishOn and subscribeOn move work to another thread, but the stream stays sequential โ one element after another on a single worker. When each element carries real CPU work (hashing, image resizing, scoring), what you want is every core working at once. That is parallel(): it splits the Flux into N independent 'rails', runOn() gives each rail its own worker, and sequential() merges the rails back into a plain Flux when you are done.
import reactor.core.publisher.Flux
import reactor.core.scheduler.Schedulers
fun main() {
Flux.range(1, 10)
.parallel(4) // 4 rails
.runOn(Schedulers.parallel()) // run on parallel scheduler
.map { value ->
println("Processing $value on ${Thread.currentThread().name}")
value * value
}
.sequential() // merge back to single Flux
.collectSortedList()
.subscribe { println("Results: $it") }
Thread.sleep(1000)
}We square the numbers 1 through 10 across four rails. parallel(4) deals elements round-robin โ rail 0 gets 1, 5, 9; rail 1 gets 2, 6, 10; and so on โ and runOn(Schedulers.parallel()) gives each rail its own worker. The output shows exactly that assignment: parallel-1 processed 1, 5 and 9 while parallel-4 processed 4 and 8, all at the same time. collectSortedList() then gathers the squares back into ascending order.
Processing 1 on parallel-1 Processing 2 on parallel-2 Processing 3 on parallel-3 Processing 4 on parallel-4 Processing 5 on parallel-1 Processing 9 on parallel-1 Processing 6 on parallel-2 Processing 10 on parallel-2 Processing 7 on parallel-3 Processing 8 on parallel-4 Results: [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
Two gotchas. parallel() alone creates zero concurrency โ until you call runOn(), every rail still runs on the original thread, one after another. And sequential() does not restore source order: rails are drained as they produce, so values from different rails interleave. Need parallelism with order preserved? Use flatMapSequential, or sort after the merge as collectSortedList() does here.
Multicasting & Caching
By default a Flux is cold: every subscriber gets its own private run of the source. Subscribe three components to a Flux backed by an HTTP call and you fire three HTTP calls. The operators in this section change that contract โ they let many subscribers share one upstream subscription, turning the Flux into a hot stream that broadcasts live data to whoever is listening.
The four tools differ on two questions: when does the shared source start, and what does a late subscriber see?
- share โ hot multicast with automatic start: the first subscriber starts the source, the last one leaving stops it. Late subscribers miss the past.
- publish โ returns a ConnectableFlux: subscribers line up, but nothing flows until you call connect() yourself.
- refCount / autoConnect โ automatic connection policies for a ConnectableFlux; refCount(1) is exactly how share() works under the hood.
- replay / cache โ multicast plus memory: late subscribers first get recent history replayed, then continue live.
share
share() is the one-liner for making a source hot. The first subscriber triggers the single upstream subscription; everyone who subscribes afterwards taps into the same live stream instead of restarting it. Picture a live price feed: you want one connection to the exchange no matter how many widgets on the page are watching, and anyone who tunes in late simply starts from the current price.
Under the hood share() is literally publish().refCount(1) โ the mechanics are covered in the refCount section below. Its Mono-flavored sibling shareNext() shares the source the same way but hands every subscriber just the first value it produces, as a Mono<T>.
fun <T> Flux<T>.share(): Flux<T>share() turns a cold Flux into a hot one by wrapping it in a ConnectableFlux and auto-connecting on the first subscriber โ it is literally publish().refCount(1). The single upstream subscription is multicast to all current subscribers, so they observe the same in-flight stream from the moment they join โ late subscribers miss items already emitted. When the last subscriber cancels, the source is unsubscribed; a brand-new subscription will restart the cold source. Terminal signals (complete/error) are broadcast to everyone subscribed at that time. shareNext() is the Mono variant: one shared subscription, and every subscriber gets the first value the source produces.
- Share one expensive call (HTTP, DB query) among several concurrent subscribers
- Multicast a live/hot data feed instead of re-triggering work per subscriber
- Fan out a single upstream to several downstream pipelines without duplicating side effects
- Avoid re-subscribing to a source that should only run once while consumers are active
share() does NOT cache: late subscribers miss everything emitted before they joined, and if all subscribers leave the ref count drops to 0, so the next subscriber re-runs the cold source from scratch instead of replaying. If you need replay of past items, use cache() or replay() instead.
val hot = Flux.just("A", "B", "C", "D").share()
// A and B are already emitted to the first subscriber.
// A late subscriber attaches just before C and D:
hot.subscribe { println("late -> " + it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. Given a hot source that has already emitted A and B, a late subscriber attaches via share() right before C and D are produced. What does that late subscriber receive?
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
// A live price feed: one connection to the exchange, shared by everyone
val prices = Flux.interval(Duration.ofMillis(100))
.map { 100 + it }
.doOnSubscribe { println("feed: connected to exchange") }
.share()
prices.subscribe { println("dashboard: $it") } // first subscriber starts the feed
Thread.sleep(250)
prices.subscribe { println("logger: $it") } // joins late, misses early prices
Thread.sleep(300)
}We simulate a price feed with interval, ticking every 100 ms and mapping the tick count onto a price. The dashboard subscribes first โ that is what actually connects to the exchange, and doOnSubscribe proves the connection happens exactly once. By the time the logger joins 250 ms later, prices 100 and 101 have already gone by; it receives nothing retroactively and simply rides along from 102. From then on both subscribers see every price at the same time:
feed: connected to exchange dashboard: 100 dashboard: 101 dashboard: 102 logger: 102 dashboard: 103 logger: 103 dashboard: 104 logger: 104
share() does not cache anything: a late subscriber misses everything emitted before it joined. And because it is refCount-based, when the last subscriber cancels, the upstream is disconnected โ the next subscriber restarts the cold source from scratch. If subscribers need history, use cache() or replay() instead.
publish / ConnectableFlux
publish() gives you manual control over when a shared stream starts. It returns a ConnectableFlux: subscribers can line up freely, but the source stays dormant until you fire the starting gun with connect(). That makes it perfect for the setup-then-go pattern โ wire up every consumer first, then release the data once, knowing nobody missed the beginning.
fun <T> Flux<T>.publish(): ConnectableFlux<T>publish() turns a cold Flux into a ConnectableFlux that holds a single, shared upstream subscription. Nothing is requested or emitted until you call connect(); at that moment the source starts and every already-attached subscriber receives the same emissions and the terminal complete/error signal together, in order. Subscribers that attach after connect() miss whatever was emitted before they joined, since the stream is now hot. connect() returns a Disposable you can use to tear the shared connection down.
- Share one expensive source (HTTP call, DB query) among multiple subscribers
- Coordinate so all subscribers are attached before emission begins via connect()
- Avoid re-running side effects that a cold Flux would repeat per subscriber
- Build hot streams where late subscribers should not replay history
It is hot once connected: subscribers added after connect() see only future items (or just the terminal signal if the source already finished). If you forget to call connect(), the source never runs and subscribers wait forever; conversely connect() with no subscribers will drain and lose all data. Use autoConnect()/refCount() to connect automatically.
val shared = Flux.just("A", "B", "C").publish()
shared.subscribe { print("s1=" + it + " ") }
shared.subscribe { print("s2=" + it + " ") }
shared.connect()๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. A ConnectableFlux is created from Flux.just("A", "B", "C") via publish(). After both subscribers are attached, connect() is called once. What does each subscriber receive?
import reactor.core.publisher.Flux
fun main() {
// ConnectableFlux waits: nothing is emitted until connect() is called
val events = Flux.just("login", "click", "purchase")
.doOnNext { println("emitting $it") }
.publish()
events.subscribe { println("analytics: $it") }
events.subscribe { println("audit: $it") }
println("-- both subscribers ready, connecting --")
events.connect()
}We have a stream of user events โ login, click, purchase โ that two independent consumers care about: analytics and audit. Both subscribe to the ConnectableFlux, and notice that the doOnNext probe stays silent: nothing has been emitted yet. Only when connect() is called does the source run, exactly once, pushing each event to both subscribers in lockstep. The single 'emitting' line per event proves the work is not duplicated:
-- both subscribers ready, connecting -- emitting login analytics: login audit: login emitting click analytics: click audit: click emitting purchase analytics: purchase audit: purchase
Two classic traps: forget to call connect() and nothing ever flows โ subscribers wait forever; call connect() before subscribers are attached and the data drains into the void โ with Flux.just it would complete before anyone hears a thing. When you would rather not manage connect() by hand, that is exactly what refCount and autoConnect are for.
refCount / autoConnect
fun <T> ConnectableFlux<T>.refCount(minSubscribers: Int = 1): Flux<T> // autoConnect(n): connect at n, never disconnectBoth operators turn a ConnectableFlux back into a plain Flux by automating connect(). refCount(n) keeps a live subscriber count: when it reaches n it connects the shared upstream, and when it drops back to zero it cancels it; the next subscriber after that starts a completely fresh connection cycle, re-running the cold source from the beginning. autoConnect(n) connects at the same threshold but is fire-and-forget: once connected, the upstream stays subscribed even with zero subscribers left. share() is exactly publish().refCount(1).
- Run an expensive live feed only while at least one consumer is watching (refCount)
- Hold data back until N coordinated consumers are attached, then start exactly once (refCount(n) / autoConnect(n))
- Keep a stream alive across subscriber churn โ e.g. a metrics poller that must not restart (autoConnect)
- Replace manual connect() bookkeeping with a declarative connection policy
refCount tears the connection down at zero subscribers, so a brief lull re-runs the source from scratch โ real Reactor offers refCount(n, gracePeriod) to survive short gaps. autoConnect never disconnects: on an infinite source that is a deliberate leak unless you capture the connection Disposable via the autoConnect(n, consumer) overload and dispose it yourself.
val ticks = Flux.interval(Duration.ofMillis(100))
.publish()
.refCount(1)
val s1 = ticks.subscribe { println("S1: " + it) } // sees 0, 1
Thread.sleep(250)
s1.dispose() // subscriber count drops to 0
Thread.sleep(300)
ticks.subscribe { println("S2: " + it) } // what comes first?๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. An interval source is shared via publish().refCount(1). Subscriber S1 receives 0 and 1, then disposes. 300 ms later S2 subscribes. What is the first value S2 receives?
Managing connect() by hand gets old fast. refCount and autoConnect turn a ConnectableFlux back into a regular Flux with an automatic connection policy. refCount(n) is reference counting, just like in memory management: connect when the subscriber count reaches n, disconnect the upstream when it drops back to zero. autoConnect(n) connects at the same threshold but never lets go โ the source keeps running even after every subscriber leaves.
This is also the secret behind share(): it is nothing more than publish().refCount(1). Reach for the explicit form when you need a different threshold โ publish().refCount(2) to wait for a pair of consumers, say โ or the opposite lifecycle with autoConnect.
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
// A temperature sensor that should only run while someone is watching
val sensor = Flux.interval(Duration.ofMillis(100))
.map { 20 + it }
.doOnSubscribe { println("sensor: ON") }
.doOnCancel { println("sensor: OFF") }
.publish()
.refCount(1)
val dashboard = sensor.subscribe { println("dashboard: $it C") }
Thread.sleep(250)
dashboard.dispose() // last subscriber leaves -> refCount cancels the sensor
Thread.sleep(150)
println("-- someone opens the dashboard again --")
sensor.subscribe { println("alerts: $it C") } // fresh cycle, restarts at 20
Thread.sleep(250)
}We model a temperature sensor that should only be powered while someone is watching. The dashboard subscribes, the count goes 0 โ 1, and refCount connects: 'sensor: ON'. Readings flow until the dashboard disposes its subscription โ the count drops back to zero and refCount cancels the upstream: 'sensor: OFF'. When a new subscriber shows up later, that is a brand-new connection cycle, so the interval restarts from scratch and the alerts consumer sees 20 again, not a continuation:
sensor: ON dashboard: 20 C dashboard: 21 C sensor: OFF -- someone opens the dashboard again -- sensor: ON alerts: 20 C alerts: 21 C
autoConnect flips the second half of the policy. It is ideal when you want to hold the stream back until everyone is seated โ and then let it run for the lifetime of the app:
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
// autoConnect(2): wait for 2 subscribers, then connect โ and never let go
val feed = Flux.interval(Duration.ofMillis(100))
.map { "tick-$it" }
.doOnSubscribe { println("feed: started") }
.doOnCancel { println("feed: cancelled") }
.publish()
.autoConnect(2)
val a = feed.subscribe { println("A: $it") }
println("A subscribed - nothing flows yet")
Thread.sleep(150)
val b = feed.subscribe { println("B: $it") } // 2nd subscriber -> connect!
Thread.sleep(250)
a.dispose(); b.dispose()
Thread.sleep(200)
println("both left - no 'feed: cancelled', the feed is still running")
}A alone gets nothing โ autoConnect(2) is still waiting. The moment B subscribes, the threshold is met and the feed starts; both receive tick-0 and tick-1 together. Then both disposeโฆ and the crucial detail is what does not get printed: there is no 'feed: cancelled'. autoConnect never disconnects, so the interval keeps ticking to an empty room:
A subscribed - nothing flows yet feed: started A: tick-0 B: tick-0 A: tick-1 B: tick-1 both left - no 'feed: cancelled', the feed is still running
Rule of thumb: refCount for 'run only while someone is watching' โ accepting that a lull with zero subscribers restarts the source โ and autoConnect for 'start once, keep running'. Two escape hatches worth knowing: refCount(n, gracePeriod) keeps the connection alive through short gaps between subscribers, and autoConnect(n, consumer) hands you the connection's Disposable so you can still shut the stream down manually.
replay / cache
replay and cache add the missing ingredient to multicasting: memory. cache() makes the source hot and remembers what it emitted, so a late subscriber first receives the replayed history โ as much of it as you configured โ and then continues with the live stream. It is the classic fix for 'every component that subscribes re-triggers my HTTP call'.
You choose how much memory: cache() keeps everything, cache(n) keeps the last n values, and cache(ttl) keeps values for a time window. replay(n) is the same machinery exposed as a ConnectableFlux โ in fact cache(n) is just replay(n).autoConnect(): connect on the first subscriber, never disconnect, replay history to whoever comes late.
fun <T> Flux<T>.cache(history: Int): Flux<T> // replay(n) returns ConnectableFlux<T>cache(n) turns a cold Flux into a hot, multicasting source that subscribes once upstream and replays the last n onNext values (plus any terminal signal) to every late subscriber. New subscribers immediately receive the buffered history in original order, then continue with live emissions; the terminal onComplete/onError is also cached and re-emitted. replay(n) is the same machinery as a ConnectableFlux you must connect() (or autoConnect/refCount) to start โ cache(n) is just replay(n).autoConnect(). cache() with no argument keeps everything; cache(ttl) keeps a time-bounded window.
- Cache an expensive HTTP/DB call so repeat subscribers reuse the result
- Replay the latest N state updates to components that subscribe late
- Share one upstream among many consumers without re-triggering it
- Seed late event-stream subscribers with recent history
cache(n) only buffers the last n values, so a late subscriber misses everything older than the window ([A,B,C,D] with cache(2) replays only C, D). The unbounded cache() variant retains every value, which can leak memory on long-lived or infinite sources; errors are cached too, so all late subscribers replay the same failure forever.
val cached = Flux.just("A", "B", "C", "D").cache(2)
cached.subscribe() // eager subscriber drains everything
// later, after completion:
cached.subscribe { v -> println(v) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit to the late subscriber that subscribes after the source has already completed?
import reactor.core.publisher.Flux
fun main() {
// An expensive lookup that several parts of the app need
val rates = Flux.just("USD -> 0.92", "GBP -> 1.17", "JPY -> 0.0062")
.doOnSubscribe { println("hitting the exchange-rate API...") }
.cache()
rates.subscribe { println("checkout: $it") } // triggers the one real call
rates.subscribe { println("invoices: $it") } // served entirely from the cache
}The exchange-rate lookup is our expensive call โ the doOnSubscribe probe tells us every time it actually runs. The checkout subscribes first: the API is hit once and the three rates flow through. When invoices subscribes a moment later, the probe stays silent โ no second API call โ yet it still receives all three rates, straight from the cache:
hitting the exchange-rate API... checkout: USD -> 0.92 checkout: GBP -> 1.17 checkout: JPY -> 0.0062 invoices: USD -> 0.92 invoices: GBP -> 1.17 invoices: JPY -> 0.0062
import reactor.core.publisher.Flux
fun main() {
// Keep only the last 2 alerts for anyone who tunes in late
val alerts = Flux.just("disk 81%", "cpu 95%", "mem 72%", "disk 93%")
.cache(2)
alerts.subscribe() // first subscriber drains the whole source
println("-- a new dashboard opens after the fact --")
alerts.subscribe(
{ println("dashboard: $it") },
{ e -> println("error: $e") },
{ println("dashboard: up to date") }
)
}Here four alerts stream through a cache(2), and the first subscriber drains the source to completion. When the dashboard opens afterwards, the source is long finished โ yet the subscriber is not left empty-handed: it receives the last two alerts, mem 72% and disk 93%, followed by the cached completion signal. The two older alerts fell outside the history window and are gone:
-- a new dashboard opens after the fact -- dashboard: mem 72% dashboard: disk 93% dashboard: up to date
cache() with no arguments buffers every value forever โ on an infinite or long-lived source that is a memory leak by design. And the terminal signal is cached too: if the source failed, every future subscriber replays the same onError. For a cache you can evict, look at Mono's cacheInvalidateIf / cacheInvalidateWhen.
Blocking Operations
Everything in Reactor is asynchronous โ you declare a pipeline, subscribe, and values arrive whenever they are ready. But sooner or later the reactive world has to hand a result to plain, synchronous code: a main() method that wants to print a total, a JUnit test that needs to assert on a value, a batch job feeding a legacy API. The blocking family โ block, blockFirst, blockLast, toIterable and toStream โ is that bridge: each one subscribes for you and parks the calling thread until the data it promised is actually there.
The golden rule is about where the calling thread lives. Blocking is perfectly fine on a thread that has nothing better to do: the main thread of a CLI tool, a test runner, a scheduled batch job. It is strictly forbidden on the threads that power reactive applications โ Netty event-loop workers, Schedulers.parallel() โ because one parked thread there starves every other stream it was serving. Reactor even defends itself: calling block* on a thread marked non-blocking throws IllegalStateException instead of deadlocking.
import reactor.core.publisher.Flux
fun main() {
// Inside: a fully reactive pipeline. At the very edge: ONE blocking call,
// because main() is a plain synchronous method with nothing better to do.
val receipt = Flux.just("bread", "milk", "eggs")
.map { it.uppercase() }
.collectList() // Mono<List<String>>
.block() // bridge to the imperative world
println("purchased: $receipt")
}We have a stream of three grocery items. map uppercases each one and collectList gathers them into a Mono<List<String>>. Then comes the bridge: block() subscribes to that Mono and parks the main thread until the list is ready, returning it as an ordinary value that println can use. Notice the shape of the code โ the whole transformation is reactive, and blocking happens exactly once, at the boundary.
purchased: [BREAD, MILK, EGGS]
In a WebFlux application you almost never need these operators: controllers can return Mono/Flux directly and the framework subscribes for you. If you catch yourself blocking in the middle of a reactive call chain, the fix is usually a composition operator (flatMap, zip, then) โ not a blocked thread.
blockFirst / blockLast
fun <T> Flux<T>.blockLast(): T? // also blockFirst(): T?, plus timeout overloads e.g. blockLast(d: Duration): T?blockFirst/blockLast subscribe to the Flux and park the current thread until a terminal signal arrives, then return a single value: blockFirst returns the first onNext (cancelling the rest), while blockLast drains every element and returns the last one seen before onComplete. For in=[1,2,3,4,C], blockLast walks 1,2,3,4 and returns 4; blockFirst would return 1. If the sequence completes empty they return null, and any onError is re-thrown synchronously to the caller.
- Bridging reactive code into a blocking context like a main() method, a test, or a legacy synchronous API
- Grabbing the final aggregated result of a pipeline (e.g. last running total)
- Quick scripts, demos, or CLI tools where you just need the value now
- blockFirst to fetch only the first emission and cancel the source early
These methods block the calling thread, so never call them inside an operator or on a reactive/event-loop thread (e.g. Netty, Schedulers.parallel()) โ Reactor will throw an IllegalStateException to prevent the deadlock, and even off the event loop they defeat the purpose of being non-blocking. An empty sequence silently returns null (easy to confuse with a real null-like result), and the no-arg overload waits forever, so prefer the Duration overload to avoid hanging.
val result = Flux.just(3, 5, 7)
.filter { it % 2 == 0 }
.blockLast()
println(result)๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this snippet print?
blockFirst and blockLast subscribe to a Flux and park the calling thread until they can hand you a single value. blockFirst returns as soon as the first element arrives โ and immediately cancels the rest of the sequence, since it has what it came for. blockLast is the patient sibling: it drains every element and only returns once onComplete confirms which one was truly last.
Both return null if the sequence completes empty, and both rethrow an upstream error as a plain exception on the calling thread. The no-argument overloads wait forever, so in any code that matters, reach for the Duration overloads โ if nothing arrives in time they throw instead of hanging your thread indefinitely. (On a Mono, blockOptional() sidesteps the null ambiguity entirely by wrapping the result in an Optional.)
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
val readings = Flux.just(18.2, 18.4, 19.1) // temperature samples
// blockFirst: return the first reading and cancel the rest
val first = readings.blockFirst()
println("First reading: $first")
// blockLast: wait for onComplete, return the final reading
val last = readings.blockLast()
println("Last reading: $last")
// In real code, always prefer the timeout overload
val tick = Flux.interval(Duration.ofMillis(100))
.blockFirst(Duration.ofSeconds(1))
println("First tick: $tick")
}We have a stream of three temperature readings. blockFirst subscribes, grabs 18.2 the instant it is emitted and cancels the remaining samples. blockLast subscribes again (each call is its own subscription to this cold Flux), lets all three readings stream past and returns 19.1 once the source completes. The third call shows the safe pattern: Flux.interval is infinite, but blockFirst(Duration.ofSeconds(1)) only needs the first tick โ value 0 โ and would throw after a second rather than wait forever.
First reading: 18.2 Last reading: 19.1 First tick: 0
A typical real-world use is grabbing the boundary events of a pipeline โ and being ready for the empty case, which quietly returns null.
import reactor.core.publisher.Flux
fun main() {
// Grab the boundary events of a deployment pipeline
val stages = Flux.just("start", "build", "test", "deploy")
println("first stage = ${stages.blockFirst()}")
println("last stage = ${stages.blockLast()}")
// An empty sequence quietly returns null - always be ready for it
val none = Flux.empty<String>().blockLast()
println("empty gives: $none")
}The deployment pipeline emits four stages. blockFirst returns "start" and cancels; blockLast waits through all four and returns "deploy". The last line is the trap worth memorizing: blockLast on an empty Flux does not throw โ it hands you a null that is easy to mistake for a real result.
first stage = start last stage = deploy empty gives: null
Never call blockFirst/blockLast inside an operator or on an event-loop thread (Netty workers, Schedulers.parallel()) โ Reactor throws IllegalStateException to prevent the deadlock. Also remember that blockLast must drain the whole sequence: on an infinite Flux it simply never returns, so pair it with take(...) or a Duration timeout.
toIterable / toStream
fun <T> Flux<T>.toIterable(): Iterable<T> // also toStream(): Stream<T>, with optional batchSize / prefetchtoIterable() / toStream() turn a cold Flux into a blocking bridge: emitted items A,B,C,D are buffered into an internal queue and handed out lazily, in original order, one per iterator.next() (or per Stream element). Each call to next() BLOCKS the calling thread until the next element, the onComplete (|), or an onError arrives; the complete signal ends iteration (hasNext() returns false / the Stream finishes). An error is rethrown as a runtime exception while you iterate.
- Bridging reactive output into legacy/blocking code that expects an Iterable or Stream
- Feeding a Flux into a Java for-each loop or Stream pipeline (filter/map/collect)
- Quick tests or scripts where you want results synchronously without subscribe()
- Consuming a bounded Flux from a non-reactive layer (CLI tool, batch job)
It BLOCKS the current thread, so never call it from inside a reactive operator or an event-loop / Netty thread โ it can deadlock or trigger Reactor's blocking detection. Also, if you stop iterating early you must close the Stream (or fully drain the Iterable) to cancel the upstream subscription, otherwise you leak the source.
val letters = Flux.just("A", "B", "C", "D")
letters.toStream().forEach { print(it) }
// what is printed?๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. Given this Flux bridged into a blocking Stream, what gets printed?
Where blockFirst and blockLast pick one value out of a Flux, toIterable and toStream hand you all of them โ as a lazy, blocking Iterable or java.util.stream.Stream. Nothing is precomputed: each call to the iterator's next() parks the calling thread until the next element arrives, delivers it in original order, and when onComplete lands, hasNext() returns false and the loop ends cleanly. An upstream error is rethrown as an exception right where you were iterating.
This is the bridge of choice when the consumer is imperative but wants every element: a plain for-loop, a legacy API that accepts an Iterable, or a java.util.stream pipeline of filters and collectors.
import reactor.core.publisher.Flux
fun main() {
// toIterable: a Flux you can walk with a plain for-loop
val pages = Flux.just("home", "cart", "checkout").toIterable()
for (page in pages) {
println("visited: $page")
}
// toStream: plug a Flux straight into the java.util.stream API
val sum = Flux.range(1, 10)
.toStream()
.mapToInt { it }
.sum()
println("Stream sum: $sum")
}We have a stream of visited pages. toIterable wraps it so an ordinary for-loop can walk it: each iteration blocks briefly, receives the next page and prints it. The second pipeline bridges a Flux of numbers 1 through 10 into the Stream API, where mapToInt and sum do their usual synchronous work โ 55, computed by java.util.stream from reactive data.
visited: home visited: cart visited: checkout Stream sum: 55
The classic scenario: a legacy function that only understands Iterable, fed by a reactive source it never has to know about.
import reactor.core.publisher.Flux
// A legacy report function that only understands Iterable
fun printReport(rows: Iterable<String>) {
rows.forEach { println("row: $it") }
}
fun main() {
val rows = Flux.just("Q1;102", "Q2;97", "Q3;120")
printReport(rows.toIterable()) // the legacy code never learns it was reactive
}printReport is ordinary blocking code โ it takes an Iterable and prints each row. We hand it rows.toIterable() and it consumes the three quarterly rows one by one, blocking imperceptibly between pulls. The reactive plumbing stays invisible on the legacy side of the bridge.
row: Q1;102 row: Q2;97 row: Q3;120
Two cautions: these bridges block the calling thread on every pull, so they follow the same never-on-an-event-loop rule as block*. And if you stop iterating early, you must close the Stream (Kotlin's .use { } / Java's try-with-resources) or finish draining the Iterable โ otherwise the upstream subscription is never cancelled and the source leaks.
Utility Operators
Utility operators are the Swiss-army knife of the Flux API: a fallback for streams that finish empty, in-memory sorting, re-subscription loops, reusable pipeline composition, and leak-proof resource handling. Unlike map or filter they rarely touch individual values โ they act on the stream as a whole, which is exactly why they solve the problems the other categories leave open.
import reactor.core.publisher.Flux
fun main() {
// Convert a Flux into a Java Stream to reuse stream terminal ops
val total = Flux.just(19.99, 5.49, 12.00)
.toStream()
.mapToDouble { it }
.sum()
println("cart total = $total")
}As a warm-up, a tiny bridge to the non-reactive world: we have a cart with three prices and toStream() converts the Flux into a plain, blocking java.util.stream.Stream, letting us reuse Stream terminal operations like sum(). It blocks the calling thread, so treat it as an escape hatch for tests and scripts, not something to put inside a reactive pipeline:
cart total = 37.48
defaultIfEmpty
Sooner or later a query comes back with nothing: the search matched zero products, the filter dropped every element, the cache was cold. defaultIfEmpty is the simplest insurance policy for that case โ if the source completes without having emitted a single item, it emits one predefined fallback value and then completes. If the source does emit, the operator is completely transparent and every value passes through untouched.
Note that it takes an already-built value, not a lambda: the fallback is fixed at assembly time and costs nothing to produce. When the fallback must be computed lazily โ or needs to be a whole alternative publisher, like a second data source โ its bigger sibling switchIfEmpty is the right tool.
fun <T> Flux<T>.defaultIfEmpty(defaultV: T): Flux<T>defaultIfEmpty transparently relays every onNext and any onError from the source unchanged. Only if the source completes (onComplete) without ever emitting a single item does it instead emit the supplied default value and then complete. The fallback is emitted on the same thread that carried the source's terminal signal, and it never fires if even one real item passed through.
- Guarantee a downstream always receives at least one value (e.g. a count or total of 0)
- Provide a neutral fallback for an empty DB query or filtered result set
- Avoid empty-stream branches when mapping to a UI or API response
- Supply a sentinel before a reduce/collect that must never yield nothing
It only reacts to an EMPTY completion, not to errors: if the source fails with onError, the default is never emitted and the error propagates. It also fires for exactly zero items, not for null values, so a source that emits real elements (even one) bypasses it entirely. For a computed/lazy fallback or to swap in an alternate publisher, use switchIfEmpty instead.
Flux.just(1, 2, 3)
.filter { it > 10 }
.defaultIfEmpty(0)
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit to the subscriber?
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
// Common REST API pattern: return default response when query yields nothing
data class Product(val id: String, val name: String, val price: Double)
fun searchProducts(query: String): Flux<Product> =
if (query.length < 3) Flux.empty()
else Flux.just(
Product("P1", "Kotlin in Action", 39.99),
Product("P2", "Reactive Spring", 44.99)
).filter { it.name.lowercase().contains(query.lowercase()) }
fun main() {
// Search with results
println("Search 'kotlin':")
searchProducts("kotlin")
.defaultIfEmpty(Product("N/A", "No products found", 0.0))
.subscribe { println(" ${it.name} - $${it.price}") }
// Search with no results โ default kicks in
println("\nSearch 'python':")
searchProducts("python")
.defaultIfEmpty(Product("N/A", "No products found", 0.0))
.subscribe { println(" ${it.name} - $${it.price}") }
// Too-short query โ empty โ default
println("\nSearch 'ab':")
searchProducts("ab")
.defaultIfEmpty(Product("N/A", "Query too short", 0.0))
.subscribe { println(" ${it.name}") }
}We simulate a tiny product-search endpoint. searchProducts returns an empty Flux for queries shorter than three characters, and otherwise filters a two-book catalog. The first search finds 'Kotlin in Action', so the fallback never fires and the real product flows through. The second search matches nothing: the source completes empty and defaultIfEmpty injects the 'No products found' placeholder. The third is too short to even query, so the 'Query too short' sentinel comes out. Finally we subscribe and print each result:
Search 'kotlin': Kotlin in Action - $39.99 Search 'python': No products found - $0.0 Search 'ab': Query too short
The same pattern in its smallest form โ a VIP filter that matches nobody, so the fallback string is all the subscriber ever sees:
import reactor.core.publisher.Flux
fun main() {
val premiumCustomers = listOf("Ada", "Linus", "Grace")
Flux.fromIterable(premiumCustomers)
.filter { it.startsWith("Z") } // no VIP whose name starts with Z
.defaultIfEmpty("-- no VIP found --")
.subscribe { println(it) }
}-- no VIP found --
defaultIfEmpty reacts to an EMPTY completion only, never to errors: if the source fails, the error propagates and the default is not emitted (that is onErrorReturn's job). And because the default value is built eagerly at assembly time, use switchIfEmpty for a lazily computed fallback or a whole alternate Flux.
sort
sort collects every element the source emits into an internal buffer and, only when the source completes, releases them again in order โ ascending natural order by default, or whatever a custom Comparator dictates. Until that completion signal arrives, nothing flows downstream at all.
That buffering is the crucial detail: sort belongs on short, finite streams โ the last mile before rendering a leaderboard or a report โ never in the middle of an infinite event feed, where it would buffer forever and emit nothing.
fun <T> Flux<T>.sort(): Flux<T> // also sort(comparator: Comparator<in T>)sort subscribes upstream and buffers every emitted element into an internal list, emitting nothing while the source is still active. Only when the source sends onComplete does it sort the buffered elements (natural order, or a supplied Comparator) and replay them in ascending order, then forward onComplete. Equal elements keep their relative input order (the sort is stable), and an upstream onError is propagated immediately without emitting any of the buffered, partially-collected values.
- Presenting a finished, bounded result set in ranked order
- Sorting a small batch by a key via a custom Comparator
- Producing deterministic output before a render or report step
- Reordering results merged from several async sources once complete
sort is fully blocking on completion: it must buffer the ENTIRE stream in memory before emitting a single item, so it never emits on an infinite or unbounded hot source and can exhaust memory on large flows. It also adds latency since nothing flows until onComplete.
Flux.just(3, 1, 4, 1, 5, 2)
.sort()
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux emit, in order?
import reactor.core.publisher.Flux
fun main() {
// Natural order
Flux.just(5, 3, 8, 1, 9, 2)
.sort()
.subscribe { print("$it ") }
println() // 1 2 3 5 8 9
// Custom comparator
Flux.just("banana", "apple", "cherry", "date")
.sort(Comparator.comparingInt { it.length })
.subscribe { println(it) }
}Two quick runs. The first sorts numbers by natural order: 5, 3, 8, 1, 9, 2 go in unordered, and only after the source completes do they come out as 1 2 3 5 8 9 on a single line. The second hands sort a Comparator that compares string lengths: date (4 letters) wins, apple (5) follows, and banana and cherry (6 each) keep their original relative order because the sort is stable:
1 2 3 5 8 9 date apple banana cherry
With domain objects you almost always supply a comparator. Here we rank orders by total, biggest first, with Kotlin's compareByDescending:
import reactor.core.publisher.Flux
data class Order(val id: String, val total: Double)
fun main() {
Flux.just(Order("A", 42.0), Order("B", 12.5), Order("C", 99.9))
.sort(compareByDescending { it.total }) // biggest orders first
.subscribe { println("order ${it.id} -> $${it.total}") }
}Three orders arrive in arbitrary order; sort buffers all of them, applies the descending-by-total comparator on completion, and releases C ($99.9) first, then A, then B:
order C -> $99.9 order A -> $42.0 order B -> $12.5
Never put sort on an infinite or unbounded hot source: it must buffer the ENTIRE sequence in memory before emitting anything, so it would grow without limit and never emit. If what you really want is a sorted List at the end, collectSortedList gives you a Mono<List<T>> in one step.
repeat / repeatWhen
repeat re-subscribes to the source after it completes, replaying the whole sequence from scratch. repeat(n) allows n extra passes (n + 1 runs in total), repeat { predicate } keeps going while a condition holds, and repeatWhen hands the 'when do we go again?' decision to a companion publisher โ the standard trick for polling with a pause between rounds.
Think of it as retry's optimistic twin: retry re-subscribes when the source FAILS, repeat re-subscribes when it SUCCEEDS. Both only make sense on cold sources, which restart their work on every new subscription.
fun <T> Flux<T>.repeat(times: Long = Long.MAX_VALUE): Flux<T>On each source onComplete, repeat re-subscribes to the (cold) source from scratch, replaying its full sequence; repeat(n) adds n EXTRA subscriptions, so the source runs n+1 times total. The intermediate onComplete signals are swallowed and not forwarded downstream โ only the final completion (after the last pass) terminates the stream. An onError at any point is propagated immediately and stops the repetition.
- Re-poll a finite source (HTTP/DB query) repeatedly to build a stream of snapshots
- Retry-like re-run of a sequence that completes normally (not on error)
- Generate test/load data by replaying a cold publisher N times
- Use repeatWhen() to add backoff/delay between re-subscriptions
repeat only reacts to onComplete, NOT onError โ for re-running after failures use retry() instead. On a hot source there is nothing to replay (re-subscription just continues live), and repeat() with no argument loops forever (Long.MAX_VALUE), which can be an infinite stream if the source always completes.
Flux.just(1, 2)
.repeat(1)
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What sequence does this Flux emit?
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
// repeat: re-subscribe N times
Flux.just("ping")
.repeat(2) // original + 2 repeats = 3 total
.subscribe { println(it) }
// repeat with predicate
var count = 0
Flux.just("tick")
.doOnNext { count++ }
.repeat { count < 5 }
.subscribe { println("$it #$count") }
// repeatWhen: delayed repeat
Flux.just("poll")
.repeatWhen { it.delayElements(Duration.ofSeconds(1)) }
.take(3)
.subscribe { println(it) }
Thread.sleep(4000)
}Three mini-demos in one program. First, repeat(2): 'ping' is emitted once plus two replays โ three in total. Second, repeat with a predicate: doOnNext increments a counter and the source is re-subscribed while count < 5, printing tick #1 through tick #5. Third, repeatWhen with delayElements re-polls one second after each completion; take(3) cuts the loop after three 'poll' emissions, and the final Thread.sleep keeps the JVM alive while those delayed repeats fire:
ping ping ping tick #1 tick #2 tick #3 tick #4 tick #5 poll poll poll
repeat replays the SUBSCRIPTION, not a recording โ so combine it with Flux.defer to re-run side effects and produce fresh values on every pass:
import reactor.core.publisher.Flux
import java.util.concurrent.atomic.AtomicInteger
fun main() {
val poll = AtomicInteger()
Flux.defer { Flux.just("heartbeat #${poll.incrementAndGet()}") }
.repeat(2) // emit once, then re-subscribe 2 more times
.subscribe { println(it) }
}Without defer, Flux.just would capture one value at assembly time and repeat would print the same heartbeat three times. defer re-executes the factory on every subscription, so the AtomicInteger advances and each pass produces a fresh heartbeat number:
heartbeat #1 heartbeat #2 heartbeat #3
repeat reacts to onComplete only: an error stops the loop immediately and propagates โ use retry/retryWhen for re-running after failures. On a hot source there is nothing to replay (re-subscribing just keeps listening to the live feed), and bare repeat() with no argument loops effectively forever (Long.MAX_VALUE).
then / thenMany / thenEmpty
Sometimes you only care THAT a job finished, not what it produced: write all the rows, then answer 'ok'. The then family discards every element from the source and reacts only to its terminal signal. then() turns the completion into a Mono<Void>; then(mono) plays a follow-up Mono once the source completes; thenMany(publisher) plays a whole follow-up Flux; and thenEmpty(publisher) awaits a Publisher<Void> โ 'run this side effect, then complete'.
Errors always jump the queue: if the source fails, the follow-up publisher is never subscribed and the error propagates downstream immediately.
fun <V> Flux<*>.then(other: Mono<V>): Mono<V>then(Mono) ignores and discards every onNext value from the source Flux, waiting only for it to terminate. Once the source completes successfully, it subscribes to and replays the supplied Mono, emitting its single value (here 'done') followed by completion. If the source errors instead, the supplied Mono is never subscribed and the error is propagated immediately, so the final value only appears after a clean upstream completion.
- Run a side-effecting stream (saves, deletes) then return a final result
- Chain a sequence: do all the work, then emit a confirmation value
- Sequence reactive steps where intermediate emissions are irrelevant
- Return a status/summary Mono after a batch of operations completes
The supplied Mono is evaluated eagerly when then() is called, not lazily on completion: if you write then(Mono.just(compute())) the compute() runs immediately at assembly time, even if the source never completes. Use then(Mono.defer { ... }) to defer the work until the source actually finishes.
val result: Mono<String> = Flux.just(1, 2, 3)
.then(Mono.just("done"))
result.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this Flux pipeline emit when subscribed?
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
fun main() {
// then: wait for completion, return Mono<Void>
Flux.just(1, 2, 3)
.doOnNext { println("Processing: $it") }
.then()
.doOnSuccess { println("All done!") }
.subscribe()
// then(Mono): play a Mono after completion
Flux.just("save1", "save2", "save3")
.doOnNext { println("Saving: $it") }
.then(Mono.just("All saved successfully"))
.subscribe { println(it) }
// thenMany: play another Publisher after completion
Flux.just("init1", "init2")
.doOnNext { println("Init: $it") }
.thenMany(Flux.just("ready1", "ready2"))
.subscribe { println("After init: $it") }
}Three variants back to back. In the first pipeline the doOnNext side effect prints 1, 2, 3 while then() swallows the values; when the Flux completes, the resulting Mono<Void> succeeds and doOnSuccess prints 'All done!'. The second runs three saves and then plays a confirmation Mono. The third runs two init steps and then streams a whole follow-up Flux with thenMany โ its subscriber only ever sees ready1 and ready2:
Processing: 1 Processing: 2 Processing: 3 All done! Saving: save1 Saving: save2 Saving: save3 All saved successfully Init: init1 Init: init2 After init: ready1 After init: ready2
The classic real-world shape: persist a batch of rows, and only when every write has completed, emit the follow-up signals:
import reactor.core.publisher.Flux
fun main() {
Flux.just("row-1", "row-2", "row-3")
.doOnNext { println("writing $it") }
.thenMany(Flux.just("COMMIT", "DONE")) // runs only after all writes complete
.subscribe { println(it) }
}The subscriber never receives row-1, row-2 or row-3 โ those lines come from the doOnNext side effect. Only after the third write completes does thenMany subscribe to the follow-up Flux and deliver COMMIT and DONE:
writing row-1 writing row-2 writing row-3 COMMIT DONE
then(Mono.just(compute())) runs compute() IMMEDIATELY at assembly time, not after the source completes โ the Mono is built eagerly even though it is subscribed late. Wrap the work in Mono.defer { } or Mono.fromCallable { } to postpone it until the source actually finishes. Related: ignoreElements() is then()'s sibling that keeps the element type instead of switching to Void.
transform / transformDeferred
fun <T, V> Flux<T>.transform(transformer: (Flux<T>) -> Publisher<V>): Flux<V>transform() runs the given function ONCE at assembly time, feeding the whole upstream Flux into a packaged operator chain and splicing the result back in โ so [1,2,3,4] become [20,40] through that reusable pipeline before the original onComplete passes through. Because it executes at assembly, the same Flux instance and captured state are shared by every subscriber; element order and terminal signals (onComplete/onError) are preserved exactly as the inner chain emits them. Use transformDeferred() (formerly compose) instead to run the function once PER subscriber, letting each subscription build its own pipeline and capture subscriber-specific state.
- Extract a repeated operator chain (filter+map+log) into a named, reusable function
- Build cross-cutting helpers like a metrics/logging or retry decorator applied to many flows
- Keep long pipelines readable by composing them from smaller named segments
- Use transformDeferred() when behavior must vary per subscriber (e.g. per-request context)
transform() invokes the function only ONCE at assembly time, so any per-subscription or stateful logic (counters, request context, time-based state) is shared across all subscribers and 'frozen' at build time โ if you need fresh state or subscriber-specific behavior on every subscribe, you must use transformDeferred() instead.
val pipeline = { f: Flux<Int> -> f.filter { it % 2 == 0 }.map { it * 10 } }
Flux.just(1, 2, 3, 4)
.transform(pipeline)
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. Given the packaged pipeline, what does this Flux emit?
transform is the extract-method refactor for reactive pipelines: it takes a function that receives the WHOLE Flux โ not each value โ and returns a reshaped Publisher, then splices that function into the chain. Instead of copy-pasting the same filter-map-log trio into ten pipelines, you name it once and .transform(it) everywhere.
Its sibling transformDeferred (formerly compose) does the same wiring but runs the function once per SUBSCRIBER instead of once at assembly. That difference only matters when the function captures state โ the third example below makes it visible.
import reactor.core.publisher.Flux
import org.reactivestreams.Publisher
// Reusable operator composition
fun <T> addLogging(): (Flux<T>) -> Publisher<T> = { flux ->
flux.doOnNext { println(" [LOG] $it") }
.doOnError { println(" [ERROR] ${it.message}") }
.doOnComplete { println(" [COMPLETE]") }
}
fun main() {
// transform: applies once at assembly
Flux.just(1, 2, 3)
.transform(addLogging())
.subscribe()
// transformDeferred: applies per subscriber
Flux.just("A", "B", "C")
.transformDeferred(addLogging())
.subscribe()
}addLogging() packages three doOn* peeks into one named, reusable unit. We apply it to a Flux of numbers with transform and to a Flux of letters with transformDeferred. With a stateless function like this one the two behave identically โ every value is logged, then the completion:
[LOG] 1 [LOG] 2 [LOG] 3 [COMPLETE] [LOG] A [LOG] B [LOG] C [COMPLETE]
A domain-flavored pipeline: apply 10% off, then keep only what still costs less than $50:
import reactor.core.publisher.Flux
fun main() {
val discounted = { prices: Flux<Double> ->
prices.map { it * 0.9 }.filter { it < 50.0 } // reusable 10%-off pipeline
}
Flux.just(30.0, 60.0, 45.0)
.transform(discounted)
.subscribe { println("final: $it") }
}The discounted function multiplies each price by 0.9 and drops anything at $50 or above. 30.0 becomes 27.0 and passes; 60.0 becomes 54.0 and is filtered out; 45.0 becomes 40.5 and passes:
final: 27.0 final: 40.5
So when does transform versus transformDeferred actually matter? When the function captures state. Here each function stamps values with a run number taken from a counter that increments every time the function executes:
import reactor.core.publisher.Flux
import java.util.concurrent.atomic.AtomicInteger
fun main() {
// transform: the function runs ONCE, when the pipeline is assembled
val assembleCount = AtomicInteger()
val once = Flux.just("a", "b").transform { f ->
val run = assembleCount.incrementAndGet()
f.map { "run $run: $it" }
}
once.subscribe { println("sub1 -> $it") }
once.subscribe { println("sub2 -> $it") }
// transformDeferred: the function runs once PER subscriber
val subscribeCount = AtomicInteger()
val perSubscriber = Flux.just("a", "b").transformDeferred { f ->
val run = subscribeCount.incrementAndGet()
f.map { "run $run: $it" }
}
perSubscriber.subscribe { println("sub1 -> $it") }
perSubscriber.subscribe { println("sub2 -> $it") }
}With transform the counter increments exactly once โ at assembly โ so both subscribers see 'run 1'. With transformDeferred the function executes on each subscribe: the first subscriber gets run 1, the second gets run 2. This per-subscriber execution is how you build behavior like per-request context or fresh metrics on every subscription:
sub1 -> run 1: a sub1 -> run 1: b sub2 -> run 1: a sub2 -> run 1: b sub1 -> run 1: a sub1 -> run 1: b sub2 -> run 2: a sub2 -> run 2: b
Related: as(). Where transform's function must return a Publisher, flux.as(f) hands the whole Flux to a function that may return ANY type โ handy for one-shot conversions such as bridging to another library (flux.as(RxJava3Adapter::fluxToFlowable)) or wrapping a pipeline in a test helper like StepVerifier. In Kotlin, as is a keyword, so call it with backticks: `as`.
using / usingWhen
Streams often lean on an external resource โ a database connection, a file handle, a lock โ that must be released no matter how the stream ends. using is the reactive try-with-resources: acquire the resource lazily on each subscription, build the actual Flux from it, and run a cleanup callback on complete, error or cancel.
usingWhen is the asynchronous big brother: the resource itself comes from a Publisher and the cleanup returns a Publisher too, with separate handlers for completion, error and cancellation โ the commit/rollback shape reactive transactions (like R2DBC) are built on.
Flux.using(Callable<R> resourceSupplier, Function<R, Publisher<T>> sourceFactory, Consumer<R> resourceCleanup): Flux<T>On each subscription, using() lazily calls resourceSupplier to acquire a resource, then builds the actual source Publisher from it via sourceFactory and relays its values. When the source terminates (complete OR error) or the subscription is cancelled, it invokes resourceCleanup on that resource exactly once. By default cleanup runs eagerly BEFORE the terminal signal is propagated downstream (matching the marble: open, values, then close, then complete); pass eager=false to release after the terminal signal instead.
- Wrap a JDBC/JOOQ connection or statement so it closes when the stream ends
- Stream lines from an opened file or InputStream and guarantee the handle is closed
- Hold a lock, semaphore permit, or transaction scope for the duration of a pipeline
- Per-subscriber resources in a cold Flux that must be re-acquired on each subscribe
The resource is acquired per-subscription, not once at assembly, and cleanup is SYNCHRONOUS and blocking on the terminating thread, so don't use using() for resources whose close() does I/O or returns a Publisher; use usingWhen() for async release. Also, with the default eager=true the resource is already closed before downstream sees onComplete/onError, which can surprise code that still touches it in the terminal signal.
val flux = Flux.using(
{ "conn" }, // acquire on subscribe
{ res -> Flux.just(res + "-r1", res + "-r2", res + "-r3") },
{ res -> println("close " + res) } // release on terminal
)
flux.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. A resource is acquired on subscribe and a Flux is built from it. What does this stream emit and when is the resource released?
import reactor.core.publisher.Flux
class DatabaseConnection(val name: String) : AutoCloseable {
init { println("Opening connection: $name") }
fun query(sql: String): List<String> = listOf("row1", "row2", "row3")
override fun close() { println("Closing connection: $name") }
}
fun main() {
// using: manages resource lifecycle
Flux.using(
{ DatabaseConnection("mydb") }, // resource supplier
{ conn -> Flux.fromIterable(conn.query("SELECT *")) }, // source built from it
{ conn -> conn.close() } // cleanup on terminal
).subscribe { println("Row: $it") }
// AutoCloseable version (cleanup is automatic)
Flux.using(
{ DatabaseConnection("auto-db") },
{ conn -> Flux.fromIterable(conn.query("SELECT 1")) }
).subscribe { println("Auto: $it") }
}DatabaseConnection announces its open and close so we can watch the lifecycle. The first using takes the classic three arguments โ acquire, build a Flux from the resource, release. The second exploits AutoCloseable: skip the third argument and close() is called for you. Both runs open a connection, stream three rows, and close โ in exactly that order:
Opening connection: mydb Row: row1 Row: row2 Row: row3 Closing connection: mydb Opening connection: auto-db Auto: row1 Auto: row2 Auto: row3 Closing connection: auto-db
The reason using exists is the unhappy path. Here the derived Flux fails after two values:
import reactor.core.publisher.Flux
fun main() {
Flux.using(
{ "session-42" }, // acquire
{ s -> Flux.just(1, 2).concatWith(Flux.error(IllegalStateException("boom in $s"))) },
{ s -> println("released $s") } // release, even on error
).subscribe(
{ println("got $it") },
{ e -> println("error: ${e.message}") }
)
}The stream emits 1 and 2, then errors. Watch the order in the output: the resource is released BEFORE the error reaches the subscriber, because using's cleanup is eager by default (an overload takes eager = false to release after the terminal signal instead):
got 1 got 2 released session-42 error: boom in session-42
The cleanup callback is synchronous and runs on the terminating thread โ if releasing the resource does I/O or returns a Publisher, don't block inside using: reach for usingWhen, which subscribes to an async cleanup Publisher and can distinguish complete, error and cancel with dedicated handlers.
Mono โ Flux Bridges
Mono promises at most one value; Flux promises a stream of them. Real pipelines cross that boundary all the time: you fetch one user and then want their many orders, you run one setup step and then serve a stream, you have one status endpoint but need to poll it forever. This category is the map of that border โ the handful of operators that take you from Mono to Flux, and the family that brings you back.
- Mono โ Flux: flatMapMany โ use the single value to open a stream (fetch the user, stream their orders)
- Mono โ Flux: thenMany โ wait for completion, discard the value, then stream (run the setup, then serve); Mono.flux() is the zero-logic type adapter
- Mono โ Flux: repeat โ run the same Mono again and again (the heart of every polling loop)
- Flux โ Mono: next / last / single / collectList / reduce / count / all / any / then / ignoreElements โ collapse a stream into one answer (mapped out in the last section below)
The rule of thumb: need the Mono's VALUE to build the stream? flatMapMany. Only need to know the Mono FINISHED? thenMany. Need the Mono to run MANY TIMES? repeat. And when an API just demands a Flux, Mono.flux() converts the type without touching the behavior.
Mono.flatMapMany
fun <R> Mono<T>.flatMapMany(mapper: (T) -> Publisher<R>): Flux<R>flatMapMany subscribes to the Mono and, when its single value arrives, passes it to the mapper; the Publisher the mapper returns is subscribed immediately and becomes the output Flux โ its values, its completion, its errors. If the Mono completes empty the mapper is never invoked and the output simply completes; if the Mono errors, the error propagates untouched. Because a Mono has at most one value there is exactly one inner Publisher, so none of Flux.flatMap's concurrency or interleaving concerns apply.
- Fetch one entity, then stream its children: user โ orders, account โ transactions, device โ readings
- Turn the result of a single async call into the seed of a live stream
- Chain a Mono-returning repository lookup into a Flux-returning query
- Gracefully produce an empty stream when the lookup finds nothing
Do not reach for flatMapMany when the value itself is irrelevant โ that is thenMany's job, and using flatMapMany there forces you to write a lambda that ignores its argument. And if your 'many' is a plain in-memory collection, flatMapIterable avoids the overhead of wrapping it in a Publisher.
Mono.empty<String>()
.flatMapMany { Flux.just("$it-1", "$it-2") }
.subscribe(
{ println(it) },
{ println("error") },
{ println("done") }
)๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. The Mono is empty โ no user was found. What gets printed?
This is THE Mono-to-Flux operator, and the shape of the problem is everywhere: one asynchronous lookup whose result you need in order to start a stream. Load the user, then stream their orders. Resolve the account, then subscribe to its transactions. Find the device, then follow its sensor readings. flatMapMany waits for the Mono's single value, hands it to your lambda, and the Publisher your lambda returns becomes the output Flux.
Think of it as Mono.flatMap with the lid taken off: flatMap keeps you in Mono-land (one value in, at most one out), while flatMapMany opens the fan โ one value in, any number out. If the "many" you are fanning out to is just an in-memory collection rather than another async source, flatMapIterable is the cheaper sibling.
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
data class User(val id: Int, val name: String)
// One user comes back from the database... (Mono = at most one value)
fun findUser(id: Int): Mono<User> =
Mono.just(User(id, "Ana"))
.doOnNext { println("user loaded: ${it.name} (#${it.id})") }
// ...and their order history arrives as a stream (Flux = 0..N values)
fun findOrders(user: User): Flux<String> =
Flux.just(
"${user.name}: 2x espresso",
"${user.name}: 1x croissant",
"${user.name}: 1x latte"
)
fun main() {
findUser(42)
.flatMapMany { user -> findOrders(user) }
.subscribe(
{ println(" $it") },
{ e -> println("error: $e") },
{ println("all orders delivered") }
)
}We start from a Mono: findUser(42) resolves to exactly one User, Ana. flatMapMany takes that user and calls findOrders(user), and the three-element Flux it returns simply becomes our output โ notice how every order string was built FROM the user object, which is exactly why we needed the value before the stream could exist. Finally we subscribe with three callbacks: each order prints indented, and the completion callback fires once the inner Flux finishes.
user loaded: Ana (#42) Ana: 2x espresso Ana: 1x croissant Ana: 1x latte all orders delivered
The empty case is a feature: if the Mono completes empty, the lambda is never invoked and the output Flux just completes โ no orders for a user that does not exist, no NullPointerException, no special-casing. An error in the Mono likewise flows straight through. Since there is only ever ONE inner Publisher, none of flatMap's usual interleaving questions apply here.
Mono.thenMany / Mono.flux()
fun <V> Mono<T>.thenMany(other: Publisher<V>): Flux<V>thenMany subscribes to the source Mono and lets it run to completion, discarding any value it emits. Only when the Mono's onComplete arrives does it subscribe to the other Publisher, whose signals then become the output Flux end to end. If the Mono errors, the error is forwarded and other is never subscribed. Mono.flux() is the degenerate cousin: no sequencing, no second publisher โ it just re-types the same 0..1-element source as a Flux.
- Run a setup step whose value is noise, then stream: clear cache โ stream prices, migrate โ serve
- Sequence 'wait for this write to finish, then read the collection'
- Gate a stream behind an acknowledgement, lock or permission check
- Mono.flux(): satisfy an API that demands a Flux when you hold a Mono
The argument is assembled eagerly: thenMany(buildFlux()) calls buildFlux() the moment the chain is built, even though its SUBSCRIPTION waits. If construction has side effects or must observe the setup's results, pass Flux.defer { buildFlux() }. Also remember the value is discarded but the WORK still runs โ thenMany is not a way to skip the Mono.
Mono.fromCallable { println("setup ran"); "ignored" }
.thenMany(Flux.just(1, 2))
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this print, and in what order?
Sometimes the first step's value does not matter โ only the fact that it finished. Clear the cache, then stream fresh prices. Run the migration, then serve requests. Acquire the lock, then process the queue. thenMany(other) subscribes to your Mono, waits for it to complete, throws its value away, and then switches to the Flux you handed it.
Its tiny cousin Mono.flux() does no sequencing at all: it is a pure type adapter that re-wraps the same 0-or-1-element source as a Flux, for the moments when an API demands a Flux and all you have is a Mono.
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
fun main() {
// Step 1: a Mono that clears the price cache (its value does not matter)
val clearCache: Mono<String> = Mono.fromCallable {
println("cache cleared")
"OK" // thenMany throws this value away
}
// Step 2: once it COMPLETES, stream the fresh prices
clearCache
.thenMany(Flux.just("AAPL: 189.90", "MSFT: 402.10", "NVDA: 118.30"))
.subscribe { println(it) }
// Mono.flux(): a pure type adapter โ a Flux of 0 or 1 elements
Mono.just("single value").flux()
.subscribe { println("from flux(): $it") }
}We have a setup Mono that clears a cache and returns "OK". thenMany subscribes to it first: "cache cleared" prints, the "OK" is discarded, and only then does the price Flux start flowing โ the three quotes print in order after the setup line, never before. The last two lines show flux(): the single value comes through unchanged, just wearing a Flux type now.
cache cleared AAPL: 189.90 MSFT: 402.10 NVDA: 118.30 from flux(): single value
Two things bite people here. First, errors: if the setup Mono fails, thenMany propagates the error and the second publisher is never subscribed โ your stream never starts, by design. Second, assembly vs subscription: the ARGUMENT to thenMany is built eagerly when you assemble the chain (only its subscription is deferred), so if constructing that Flux has side effects, wrap it in Flux.defer { ... }.
Mono.repeat
fun Mono<T>.repeat(): Flux<T> / fun Mono<T>.repeat(numRepeat: Long): Flux<T>repeat re-subscribes to the source every time it completes, concatenating the runs into one Flux. The no-arg form loops indefinitely; repeat(n) performs n ADDITIONAL subscriptions after the first, for n+1 runs total. Each round is a genuinely fresh subscription, so deferred sources (fromCallable, defer) redo their work while captured-value sources (just) replay the same value. Errors are not repeated โ an onError terminates the whole loop immediately.
- Polling loops: re-ask a status endpoint until the job is DONE (add delayElement for pacing, takeUntil to stop)
- Refresh a reading on a schedule: sensor values, queue depths, exchange rates
- Generate load or heartbeats by re-running one cheap Mono
- repeat(n): run a fixed number of rounds, e.g. sample a metric exactly 10 times
repeat() without a stop condition is an infinite stream โ always pair it with takeUntil/take or a downstream cancel. An EMPTY Mono that completes instantly plus repeat() is a hot spin-loop unless you add delayElement/delaySubscription. And do not confuse it with retry: repeat fires on onComplete, retry on onError.
var n = 0
Mono.fromCallable { ++n }
.repeat(2)
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. How many values does this print, and which ones?
A Mono runs once, delivers once, and is done. But plenty of real work is one question asked many times: is the export job finished yet? what does the temperature sensor read now? how deep is the queue? repeat() turns one Mono into that loop โ every time the source completes, it re-subscribes, and a fresh run begins. One Mono in, an endless Flux of results out.
The classic polling recipe stacks four small pieces: Mono.fromCallable (do the work fresh on every subscription), delayElement (breathe between rounds), repeat() (loop forever), and takeUntil (stop when the answer you wanted arrives).
import reactor.core.publisher.Mono
import java.time.Duration
import java.util.concurrent.atomic.AtomicInteger
fun main() {
val t0 = System.currentTimeMillis()
val calls = AtomicInteger(0)
// One "HTTP call": ask the job service how the export is going
val pollStatus: Mono<String> = Mono.fromCallable {
val n = calls.incrementAndGet()
if (n < 4) "poll #$n: PROCESSING" else "poll #$n: DONE"
}.delayElement(Duration.ofMillis(200)) // breathing room between polls
pollStatus
.repeat() // re-subscribe after each completion...
.takeUntil { it.endsWith("DONE") } // ...until the job finishes
.doOnNext { println("+${System.currentTimeMillis() - t0}ms $it") }
.blockLast()
}Each subscription to pollStatus makes one "call": the callable runs, reports PROCESSING or DONE, and delayElement holds the result for 200 ms so we do not hammer the service. When that tiny stream completes, repeat() subscribes again โ that is the loop. takeUntil watches the results flow by and cuts the stream after the first DONE, cancelling any further repeats. blockLast() keeps main alive while the timers fire on Reactor's parallel scheduler.
+331ms poll #1: PROCESSING +537ms poll #2: PROCESSING +739ms poll #3: PROCESSING +943ms poll #4: DONE
Three gotchas. repeat(n) means n ADDITIONAL subscriptions โ repeat(2) yields 3 runs in total. repeat only reacts to onComplete; its twin for onError is retry. And Mono.just(x).repeat() re-delivers the SAME captured value forever โ for fresh work each round you need a source that computes on subscription, like fromCallable or defer.
Flux โ Mono: bridging back
Flux<T>.next(): Mono<T> ยท .last() ยท .single() ยท .collectList(): Mono<List<T>> ยท .reduce() ยท .count() ยท .all() ยท .any() ยท .then(): Mono<Void> ยท .ignoreElements()Every FluxโMono bridge collapses a stream into a single publisher, differing only in WHAT it keeps and WHEN it can answer. next() emits the first element and cancels the source; last(), single(), collectList(), reduce(), count(), all() and any() must in general observe the stream up to onComplete before emitting (all/any can short-circuit early on a decisive element); then() and ignoreElements() drop every value and forward only the terminal signal. All of them propagate errors unchanged.
- next(): 'give me the first result and stop searching' โ cheap existence-with-value checks
- collectList()/reduce()/count(): build a report, a total or a size from a finite stream
- all()/any(): validate a whole stream into one boolean verdict
- then()/ignoreElements(): 'run this stream for its effects, tell me when it is done'
The completion-waiters (collectList, reduce, count, all, lastโฆ) never emit on an infinite Flux โ bound the stream first with take or a window. single() errors on both zero and multiple elements; if 'empty' is legal, use singleOrEmpty()/next(). And remember collectList buffers EVERYTHING in memory โ a million elements means a million-entry list.
Mono.just(3)
.flatMapMany { x -> Flux.range(1, x) }
.reduce { a, b -> a + b }
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. A full round trip โ Mono out to Flux and back to Mono. What prints?
The bridge runs in both directions, and the way back is actually the busier lane: any time you need ONE answer about a WHOLE stream โ a total, a verdict, a first match, a completion signal โ you are crossing from Flux to Mono. Unlike the handful of operators above, the return direction is a whole family, and each member already has its own section in this reference. Here is the map, grouped by the question they answer:
- Pick one element โ next() takes the first and cancels the rest; last() waits for the final one; single() insists on exactly one and errors otherwise
- Aggregate them all โ collectList() gathers into a List, reduce() folds values into one, count() just counts
- Answer yes/no โ all(predicate) and any(predicate) short-circuit into a Mono<Boolean>
- Keep only the ending โ then() gives a Mono<Void> that fires on completion; ignoreElements() is the same idea but keeps the element type
- โฆand to cross back out again: flatMapMany, thenMany and repeat, covered above
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
data class Order(val item: String, val amount: Double)
fun findOrders(user: String): Flux<Order> =
Flux.just(
Order("espresso", 2.50),
Order("croissant", 3.20),
Order("latte", 4.10)
)
fun main() {
// Mono -> Flux: fan one user out into their stream of orders
val orders: Flux<Order> = Mono.just("ana").flatMapMany { findOrders(it) }
// Flux -> Mono: collapse the stream back into single answers
orders.next()
.subscribe { println("next(): first order is ${it.item}") }
orders.count()
.subscribe { println("count(): $it orders placed") }
orders.reduce(0.0) { total, o -> total + o.amount }
.subscribe { println("reduce(): receipt total $" + "%.2f".format(it)) }
orders.all { it.amount < 10.0 }
.subscribe { println("all(): every item under \$10? $it") }
orders.map { it.item }.collectList()
.subscribe { println("collectList(): $it") }
orders.then()
.subscribe({ }, { }, { println("then(): stream finished โ values dropped") })
}First we cross the bridge outward: one user name fans out into a Flux of three orders via flatMapMany. Then we cross back six times, each time collapsing the same stream into a different single answer: the first order, the order count, the folded total, an all-under-$10 verdict, the full list, and finally a bare completion signal from then(). Because orders is a cold publisher, every subscribe re-runs it from the top โ six subscriptions, six replays, which is also why the six answers print in the order we subscribed.
next(): first order is espresso count(): 3 orders placed reduce(): receipt total $9.80 all(): every item under $10? true collectList(): [espresso, croissant, latte] then(): stream finished โ values dropped
Timing is the thing to internalize: next() answers as soon as the FIRST element arrives and cancels the rest of the source; all() and any() can also settle early, the moment one element decides the verdict. The aggregators โ collectList, reduce, count, last โ must wait for onComplete, so on an infinite Flux they wait forever. And single() is strict: zero elements or more than one both produce an error. Each of these operators is covered in depth in the Filtering and Reducing categories.
Context Propagation
Every real request carries invisible luggage: who the user is, which tenant they belong to, the trace id that ties twenty log lines into one story. In classic servlet apps that luggage lives in ThreadLocals โ one thread per request, so a static holder just works. Reactive pipelines break that assumption: a single request may touch four threads before it answers, and a ThreadLocal set on the first one is silently empty on the rest. Reactor's answer is the subscriber Context: a tiny immutable key/value map that rides on the subscription itself, so it follows the request wherever the pipeline goes.
The part that bends everyone's brain at first: the Context flows UPSTREAM. You write it at the bottom of the chain โ right where subscribe() happens and the request details are known โ and you read it at the top, near the source. That works because the subscription signal is the only one that travels from the subscriber toward the source, visiting every operator before a single value flows; the Context simply hitches a ride on it.
- contextWrite โ enrich the Context on its way up: auth tokens, tenant ids, correlation ids written once at the end of the chain
- deferContextual / transformDeferredContextual โ the reading side: build a source, or rewire a pipeline, from the ContextView
- contextCapture โ the Reactor 3.5+ bridge that snapshots ThreadLocals (MDC, tracing) into the Context automatically
contextWrite
fun <T> Flux<T>.contextWrite(transform: (Context) -> Context): Flux<T>contextWrite intercepts the subscribe signal on its way from the subscriber to the source and applies your transform to the Context riding on it. Because subscription flows bottom-up, only operators ABOVE the contextWrite see the enriched Context; the Context itself is immutable, so put/delete return a new instance that you must return from the lambda. The transform runs exactly once per subscriber, at subscription time, and data signals (onNext/onError/onComplete) pass through completely untouched. A shortcut overload, contextWrite(ContextView), merges a prebuilt context with putAll.
- Carry the authenticated principal or auth token to upstream repositories (the Spring Security WebFlux mechanism)
- Propagate a tenant id in multi-tenant services without threading parameters through every layer
- Attach correlation / trace ids so upstream operators can tag logs and events per request
- Inject per-request feature flags or locale that upstream factories read via deferContextual
Placement is everything: a contextWrite only affects operators ABOVE it, so a reader below (or in a sibling branch) sees nothing โ the classic symptom is a NoSuchElementException from ctx.get. And when two writes target the same key, the one closest to the reader wins, because the bottom write applies first and upper writes overwrite it as the subscription climbs.
Mono.deferContextual { ctx -> Mono.just("Hello " + ctx.get<String>("who")) }
.contextWrite { it.put("who", "Reactor") }
.contextWrite { it.put("who", "World") }
.subscribe(::println)๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. Two contextWrite calls put the same key. What does the subscriber print?
Picture a multi-tenant shop: the web layer knows the authenticated tenant, but the repository that actually fetches the orders sits five layers upstream. The imperative fix is to thread a tenantId parameter through every method signature in between. contextWrite offers a cleaner deal: write the value once, at the end of the chain, and every operator ABOVE that point can read it from the subscriber Context.
Mechanically, contextWrite takes a function from Context to Context. The Context is immutable โ ctx.put(key, value) returns a brand-new Context, it never mutates the old one โ and the whole transformation runs exactly once per subscriber, at subscription time, as the subscribe signal passes through on its way to the source. Data signals are never touched: values, errors and completion sail through unchanged.
import reactor.core.publisher.Flux
fun main() {
// A "repository" with no tenant parameter: it reads WHO is asking
// from the subscriber Context instead of from an argument.
fun findOrders(): Flux<String> =
Flux.deferContextual { ctx ->
val tenant = ctx.getOrDefault("tenantId", "unknown")
Flux.just("$tenant/order-1", "$tenant/order-2")
}
// The web layer, far away at the END of the chain, knows the tenant.
// It writes it once and the value travels UP to the repository.
findOrders()
.map { it.uppercase() }
.contextWrite { ctx -> ctx.put("tenantId", "acme") }
.subscribe { println("received: $it") }
// Same pipeline without the write: the repository sees no tenant.
findOrders()
.map { it.uppercase() }
.subscribe { println("received: $it") }
}We have a repository, findOrders(), that takes no tenant argument โ it reads the tenant from the Context via deferContextual. In the first pipeline, contextWrite sits between map and subscribe; when we subscribe, the subscription climbs upward, contextWrite stamps tenantId=acme onto the Context in transit, and by the time the subscription reaches the repository the value is there โ both orders come out prefixed with ACME. The second pipeline is identical minus the write: the repository finds nothing in the Context and falls back to "unknown".
received: ACME/ORDER-1 received: ACME/ORDER-2 received: UNKNOWN/ORDER-1 received: UNKNOWN/ORDER-2
This is the exact mechanism production frameworks ride on: Spring Security's ReactiveSecurityContextHolder stores the authenticated principal in the subscriber Context, and reactive tracing libraries carry span/correlation ids the same way. When two contextWrite calls put the same key, the one closest to the reader wins โ the subscription applies the bottom write first, then the upper one overwrites it on the way up.
deferContextual / transformDeferredContextual
fun <T> Flux.deferContextual(supplier: (ContextView) -> Publisher<out T>): Flux<T>deferContextual is defer with the subscriber's Context handed in: at subscription time the supplier receives the read-only ContextView โ already enriched by every contextWrite below โ and returns the publisher that will actually serve this subscriber. The factory runs once per subscription, so each subscriber can get a differently built stream. Its sibling transformDeferredContextual((Flux<T>, ContextView) -> Publisher<R>) receives the upstream too, letting you reshape an existing pipeline per subscriber instead of creating a source. Both exist on Mono as well.
- Build a source that depends on who subscribes: per-user greetings, per-tenant queries
- Read the security principal / trace id at the top of the chain where the data originates
- Switch pipeline shape per request (verbose logging, retry policy) with transformDeferredContextual
- Test context plumbing: pair it with contextWrite to assert values arrive upstream
ctx.get(key) throws NoSuchElementException on a missing key โ and keys are missing whenever the matching contextWrite sits ABOVE the reader or was never added. Use getOrDefault/getOrEmpty at boundaries you do not control. Remember you get a ContextView: there is no put โ reading code cannot sneak writes in.
Flux.deferContextual { ctx ->
Flux.just("tenant: " + ctx.getOrDefault("tenant", "public"))
}
.map { it.uppercase() }
.subscribe(::println)๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. There is no contextWrite anywhere in this chain. What prints?
Writing into the Context is only half the story โ something upstream has to read it. deferContextual is defer's context-aware sibling: instead of a plain factory, you give it a factory that receives the ContextView (the read-only face of the Context), and at subscription time it builds the real publisher from what it finds there. It is THE way to make a source depend on who is subscribing โ greet the logged-in user, query the right tenant's database, tag every event with the request's trace id.
Its heavier cousin, transformDeferredContextual, hands your function both the upstream Flux and the ContextView, and expects a rebuilt pipeline back. Use it when the Context should change the SHAPE of the chain โ switch on verbose logging for one flagged request, pick a different retry policy per client tier โ rather than just feed a value into it.
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
fun main() {
// 1) deferContextual โ build the source FROM the Context
val greeting: Mono<String> =
Mono.deferContextual { ctx ->
val user = ctx.get<String>("user")
Mono.just("Welcome back, $user!")
}
greeting
.contextWrite { it.put("user", "ana") }
.subscribe { println(it) }
// 2) transformDeferredContextual โ rewire the pipeline per subscriber
val prices = Flux.just(100, 250, 400)
.transformDeferredContextual { flux, ctx ->
if (ctx.getOrDefault("debug", false))
flux.map { "[debug] price=$it cents" }
else
flux.map { "price: $it" }
}
prices
.contextWrite { it.put("debug", true) }
.subscribe { println(it) }
prices.subscribe { println(it) } // no write -> normal rendering
}The greeting Mono does not exist until someone subscribes: the factory then receives the ContextView, finds user=ana (written one line below), and mints a personalized Mono. The price pipeline goes further โ transformDeferredContextual inspects the debug flag per subscriber and returns a differently-shaped chain each time: the first subscription wrote debug=true and gets the [debug] rendering; the second wrote nothing, getOrDefault returns false, and the same Flux serves the normal format. One pipeline definition, two different behaviors, chosen at subscription time.
Welcome back, ana! [debug] price=100 cents [debug] price=250 cents [debug] price=400 cents price: 100 price: 250 price: 400
ctx.get(key) throws NoSuchElementException when the key is absent โ a classic first-week crash, because the reader ran before you added the contextWrite (or you put it ABOVE the reader, where it is invisible). Prefer getOrDefault or getOrEmpty at trust boundaries. Also note you receive a ContextView: reading code cannot put or delete โ writes belong to contextWrite alone.
contextCapture
fun <T> Flux<T>.contextCapture(): Flux<T> // Reactor 3.5+contextCapture bridges the ThreadLocal world (MDC, tracing spans, security holders) into the subscriber Context. When the subscription passes through, it asks Micrometer's context-propagation library for every registered ThreadLocalAccessor and copies each ThreadLocal's current value โ as seen on the subscribing thread, at that instant โ into the Context under the accessor's key. Upstream operators then read those values like any contextWrite output. From 3.5.3, Hooks.enableAutomaticContextPropagation() completes the round trip by restoring Context values back into ThreadLocals around user callbacks.
- Carry SLF4J MDC values (request id, user id) into reactive chains so logs stay correlated
- Propagate Micrometer tracing / observation context across scheduler hops
- Migrate ThreadLocal-based legacy code to WebFlux without rewriting the callers first
- Replace hand-written contextWrite plumbing when accessors are already registered
It needs io.micrometer:context-propagation on the classpath AND a ThreadLocalAccessor registered per key โ with none registered it captures nothing, silently. The snapshot is taken once, at subscription time, on the subscribing thread: values set later or on other threads are missed. Capture is one-way in (ThreadLocal โ Context); restoring values around your lambdas is automatic propagation's job (or handle/tap).
val USER = ThreadLocal.withInitial { "guest" }
USER.set("ana") // set by a filter on the request thread
Mono.deferContextual { ctx -> Mono.just("user: " + ctx.getOrDefault("user", "missing")) }
.contextCapture()
.subscribe(::println)๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. Reactor 3.5+, io.micrometer:context-propagation is on the classpath and a ThreadLocalAccessor is registered under the key "user". What prints?
Most real codebases are half-and-half: SLF4J's MDC holds the request id for logging, tracing agents park spans in ThreadLocals โ while your new code is a reactive chain that hops threads whenever it likes. contextCapture (Reactor 3.5+) is the official bridge between those worlds: at subscription time it snapshots the current thread's ThreadLocal values into the subscriber Context, using Micrometer's context-propagation library โ every ThreadLocal you registered through a ThreadLocalAccessor gets copied in under its key.
Conceptually it is a contextWrite you did not have to write: instead of you naming keys and values, the registered accessors decide what gets captured. From Reactor 3.5.3 you can go further with Hooks.enableAutomaticContextPropagation(), which also restores those Context values back into their ThreadLocals around your callbacks โ so an MDC-based log statement inside a map lambda prints the right request id even on a scheduler thread.
import reactor.core.publisher.Mono
// Legacy code talks through a ThreadLocal โ MDC-style request tracking
val REQUEST_ID: ThreadLocal<String> = ThreadLocal.withInitial { "none" }
fun main() {
REQUEST_ID.set("req-4711") // set by a servlet filter, far from Reactor
Mono.deferContextual { ctx ->
Mono.just("charging card for ${ctx.get<String>("requestId")}")
}
.contextCapture() // 3.5+: snapshot ThreadLocals -> Context
.contextWrite { it.put("requestId", REQUEST_ID.get()) } // what contextCapture automates
.subscribe { println(it) }
}A servlet-era filter set REQUEST_ID on the request thread, long before any Reactor code ran. Our payment pipeline reads that id upstream through deferContextual. In a real 3.5+ app with a ThreadLocalAccessor registered for "requestId", the contextCapture() line alone would copy the ThreadLocal's value into the Context at subscription time; the explicit contextWrite below it spells out precisely what that capture does under the hood. Either way, the value rides the subscription up and the charge is logged against req-4711.
charging card for req-4711
The snapshot happens ONCE, at subscription time, on the thread that subscribes. A ThreadLocal set after subscribe(), or set on a different thread, is not captured. And capture is one-directional (ThreadLocal โ Context): getting values back OUT into ThreadLocals for your lambdas is the job of automatic propagation (Hooks.enableAutomaticContextPropagation(), 3.5.3+) or handle/tap, which restore ThreadLocals around their callbacks.
Debugging & Observability
Sooner or later every reactive team meets the stack trace from hell: a production error whose forty lines are all reactor.core.publisher internals โ subscribers calling subscribers โ with not a single line pointing at code anyone on the team actually wrote. The reason is structural, not bad luck: you ASSEMBLE a pipeline in one place, but it EXECUTES later, often on a different thread. By the time a value explodes inside a map, the call stack only remembers the execution machinery; the assembly site โ the line in your service where that map was declared, the one you actually need โ is long gone.
This category is Reactor's toolbox for exactly that gap, plus the hooks that feed your dashboards once pipelines reach production. Roughly from surgical to global:
- checkpoint โ plant a named signpost in the pipeline, so errors report WHICH segment failed
- name / tag โ give a sequence a stable identity and dimensions for metrics
- tap โ mirror every signal into a SignalListener; with reactor-core-micrometer that means real meters (the replacement for the deprecated metrics())
- Hooks.onOperatorDebug / ReactorDebugAgent โ global assembly tracebacks, for development and for production
- hide โ conceal a source's identity and disable fusion; a niche tool for tests and diagnostics
checkpoint
fun <T> Flux<T>.checkpoint(): Flux<T> // checkpoint(description: String) // checkpoint(description: String, forceStackTrace: Boolean)checkpoint inserts an assembly marker into the chain. In the data plane it is a pure pass-through; it acts on errors only: any onError crossing it is enriched with a suppressed OnAssemblyException that identifies the site. With a description it is a 'light' checkpoint โ just the string, no capture cost. With no arguments it captures a full stack trace when the pipeline is assembled (paid per assembly, i.e. per request in typical server code). The two-argument form records both the description and the full trace.
- Pin down WHICH segment of a long pipeline produced a cryptic error
- Annotate the boundary after each flatMap that wraps a remote call you own
- Get readable failure sites in production, where global debug hooks cost too much
- Escalate one suspicious segment to a full trace with checkpoint(desc, true)
A checkpoint only describes errors that pass THROUGH it: an error handled upstream (say, by an onErrorResume above it) never reaches it, and one thrown downstream never crossed it โ a mis-placed checkpoint yields silence, not wrong data. No-arg checkpoint() pays a full stack capture per assembly, so avoid scattering it through per-request pipelines; prefer descriptions.
Flux.just(4, 2, 0)
.map { 8 / it }
.checkpoint("ratios")
.subscribe(
{ println(it) },
{ e -> println("failed: " + e.message) }
)๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. This pipeline divides by zero on its last element. What does it print?
Picture a checkout service where the pricing step divides an order's total by its quantity. One malformed order later, an ArithmeticException surfaces at 2 a.m. โ and the stack trace names FluxMap and a chain of subscribers, but not one line of your checkout code. checkpoint() is the surgical fix: you plant a signpost right after the segment you suspect, and any error that flows through it gets that signpost stapled onto its stack trace, so the log finally says WHERE in the pipeline things went wrong.
The description flavor, checkpoint("pricing step"), is a light checkpoint: it costs nothing at runtime beyond carrying the string, and when an error crosses it Reactor appends a suppressed OnAssemblyException whose message names your description. The no-argument flavor checkpoint() instead captures a full stack trace at assembly time โ far more precise, and far more expensive, because typical server pipelines are assembled per request, so that capture is paid on every request. checkpoint("desc", true) forces both: your description plus the full trace.
import reactor.core.publisher.Flux
fun main() {
val step = "pricing: unit price = total / quantity"
Flux.just("ord-1:300:3", "ord-2:120:2", "ord-3:500:0")
.map { line ->
val (id, total, qty) = line.split(":")
"$id -> ${total.toInt() / qty.toInt()} per unit"
}
.checkpoint(step)
.subscribe(
{ println(it) },
{ e -> println("FAILED at checkpoint [$step]: $e") }
)
}We have a stream of raw order lines in id:total:quantity form. map parses each one and computes the unit price โ 300/3 and 120/2 go fine โ until ord-3 arrives with quantity 0 and the division throws. The exception becomes an onError signal, flows down through checkpoint(step), and reaches our error consumer, which prints the failure together with the checkpoint description. Note the first two orders had already been delivered: checkpoint diagnoses, it never rolls anything back.
ord-1 -> 100 per unit ord-2 -> 60 per unit FAILED at checkpoint [pricing: unit price = total / quantity]: java.lang.ArithmeticException: / by zero
Placement is everything: a checkpoint only describes errors that cross it โ an error already handled upstream never reaches it, and one thrown further downstream never passed through it. The working habit: put a described checkpoint right AFTER each segment you own, typically after every flatMap that wraps a remote call. And prefer descriptions โ sprinkling no-arg checkpoint() through hot paths pays a full stack capture per assembly, per checkpoint.
name / tag
fun <T> Flux<T>.name(name: String): Flux<T> // fun <T> Flux<T>.tag(key: String, value: String): Flux<T>Both attach assembly-time metadata to the sequence, readable through Reactor's Scannable interface, and forward every signal untouched. Downstream instrumentation โ tap with the Micrometer listener โ reads them: the name becomes the meter family (checkout.payments.flow.duration, .onNext.delay, โฆ) and each tag becomes a meter dimension. Without a name, meters fall into the anonymous default family 'reactor'.
- Give each business pipeline a stable meter family (checkout.payments, search.suggestions)
- Add dimensions dashboards can slice by: region, tenant, tier
- Distinguish sequences in debugging output and Scannable traversals
- As the prerequisite for meaningful tap(Micrometer.metrics(โฆ)) meters
The metadata is read by the instrumentation DOWNSTREAM of it: .name("x").tap(โฆ) works, .tap(โฆ).name("x") leaves meters anonymous. And tag values obey the universal Micrometer rule โ keep cardinality low; a per-user or per-order tag value multiplies your time series until the metrics backend falls over.
Flux.just("a", "b")
.name("letters")
.tag("env", "prod")
.subscribe { println(it.uppercase()) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does the subscriber print?
A service with forty pipelines and one Grafana dashboard has a naming problem: if every sequence reports as the anonymous default "reactor", the dashboard is one meaningless average. name("checkout.payments") gives a sequence a stable identity, and tag("region", "us-east") adds key/value dimensions โ exactly like Micrometer meter tags, because that is precisely what they become.
Neither operator changes a single signal. They attach assembly-time metadata to the sequence โ readable through Reactor's Scannable interface โ and downstream instrumentation picks it up: with the Micrometer listener from the next section, the name becomes the meter family (checkout.payments.flow.duration, checkout.payments.onNext.delay, โฆ) and every tag lands on those meters as a dimension your dashboards can slice by. Skip the name and all your pipelines pile into the default "reactor" bucket, indistinguishable from one another.
import reactor.core.publisher.Flux
fun main() {
val payments = Flux.just(120, 80, 310)
.name("checkout.payments") // meter family: checkout.payments.*
.tag("region", "us-east") // extra dimension on every meter
.tag("tier", "gold")
.map { cents -> "captured ${cents} cents" }
payments.subscribe(
{ println(it) },
{ e -> println("error: $e") },
{ println("checkout.payments completed") }
)
}We have a stream of captured payment amounts. The name and both tags are declared once, right on the sequence; map then formats each amount and the subscriber prints it, with a completion message at the end. Now look at the output: it is exactly what the same pipeline WITHOUT name and tag would print. That is the whole point โ these two operators exist purely for the machinery that reads metadata off the chain: the metrics listener in the next section, and debugging output that names sequences.
captured 120 cents captured 80 cents captured 310 cents checkout.payments completed
Order matters: the metrics instrumentation looks UPSTREAM for its name and tags, so .name("x").tap(Micrometer.metrics(registry)) works, while .tap(โฆ).name("x") leaves the meters anonymous. And the usual Micrometer rule applies to tag values: keep cardinality low โ tag("tier", "gold") is a dimension, tag("userId", โฆ) is a time-series explosion.
tap + Micrometer
fun <T> Flux<T>.tap(listenerFactory: SignalListenerFactory<T, *>): Flux<T> // Micrometer.metrics(registry), Micrometer.observation(registry)At each subscription, tap asks the factory for a fresh SignalListener and then mirrors the subscription's entire life into it โ doFirst, doOnSubscription, doOnNext, doOnComplete, doOnError, doOnCancel, doOnRequest, doFinally โ while forwarding every signal downstream unchanged. Micrometer.metrics(registry) (reactor-core-micrometer) is the canonical factory: it records subscription counts, a flow-duration timer split by outcome, and onNext delays, named after the upstream .name() and tagged with the upstream .tag()s.
- Production metrics per pipeline: throughput, latency, outcome ratios on a MeterRegistry
- Distributed tracing of a sequence via Micrometer.observation(registry)
- Replacing every use of the deprecated .metrics() operator
- Custom cross-cutting instrumentation: write your own SignalListener once, tap it everywhere
The old .metrics() operator is DEPRECATED since Reactor 3.5 (it coupled reactor-core to Micrometer 1) โ tap(Micrometer.metrics(registry)) is its meter-for-meter replacement; do not write new code against metrics(). Also remember listener callbacks run inline on the signal path: keep them fast, never blocking, and never throwing โ a throwing listener poisons the sequence it was meant to observe.
Flux.just(1, 2, 3)
.name("demo")
.tap(Micrometer.metrics(registry))
.map { it * 10 }
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. With a Micrometer listener attached via tap, what reaches the subscriber?
You can hand-roll observability with doOnNext counters and doFinally timers, but it decays fast: five pipelines later you have copy-pasted lambdas, no subscription counts, no latency percentiles, and shared mutable state racing across subscriptions. tap() (Reactor 3.5+) is the structured answer. You hand it a SignalListenerFactory; for every new subscription the factory mints a fresh SignalListener, and Reactor then calls that listener for EVERY event in the subscription's life โ doFirst, doOnSubscription, doOnNext, doOnComplete, doOnError, doOnCancel, doOnRequest, doFinally โ while the data plane flows through completely untouched.
The flagship factory lives in the separate reactor-core-micrometer module: .tap(Micrometer.metrics(registry)) records the classic Reactor meters โ subscription counts, a flow-duration timer split by outcome (completed / error / cancelled), onNext inter-arrival delays โ under the name and tags you declared upstream. Its sibling Micrometer.observation(registry) wraps each subscription in an Observation, the tracing side of the same coin. This pair is the modern replacement for the old .metrics() operator, which is deprecated โ more on that below.
import reactor.core.publisher.Flux
import java.util.concurrent.atomic.AtomicInteger
// The shape of a SignalListener: one instance per subscription,
// one hook per event in the sequence's life.
class PaymentListener {
private val seen = AtomicInteger()
fun doOnNext(v: String) { seen.incrementAndGet() }
fun doOnComplete() =
println("[listener] checkout.payments: recorded ${seen.get()} onNext, outcome=completed")
fun doOnError(e: Throwable) =
println("[listener] checkout.payments: outcome=error (${e.message})")
}
fun main() {
val listener = PaymentListener()
Flux.just("pay-101", "pay-102", "pay-103")
.name("checkout.payments")
.tap { listener } // real Reactor: .tap(Micrometer.metrics(registry))
.doOnNext(listener::doOnNext) // this playground wires the hooks by hand โ
.doOnComplete(listener::doOnComplete) // a real tap() drives them for you
.subscribe { println("captured $it") }
}We have three payment captures. PaymentListener plays the role of a SignalListener: it counts onNext signals and reports the terminal outcome. In the pipeline, tap { listener } marks where the real instrumentation plugs in โ and here is the honest part about this playground: its tap() is a stub that never invokes the listener, so the two doOnโฆ lines below wire the same hooks by hand. On real Reactor 3.5+ you would delete those two lines and keep only .tap(Micrometer.metrics(registry)): the operator itself drives every listener callback. The subscriber prints each capture, and on completion the listener reports what it measured.
captured pay-101 captured pay-102 captured pay-103 [listener] checkout.payments: recorded 3 onNext, outcome=completed
metrics() is DEPRECATED: since Reactor 3.5 the old .metrics() operator is deprecated and slated for removal โ it hard-wired reactor-core to a specific Micrometer version. Do not use it in new code. Its replacement is .tap(Micrometer.metrics(registry)) from reactor-core-micrometer: the same classic meters, a pluggable listener API, and one fresh SignalListener per subscription, which means no shared state to race on. Two cautions: listener callbacks run inline on the signal path, so keep them fast, and they must not throw.
Hooks.onOperatorDebug / ReactorDebugAgent
Hooks.onOperatorDebug() // ReactorDebugAgent.init(); ReactorDebugAgent.processExistingClasses() (io.projectreactor:reactor-tools)Hooks.onOperatorDebug() flips a global JVM switch: every operator assembled after the call captures a stack trace of its declaration site, and any error later flowing through those operators is wrapped with an assembly traceback โ 'Flux.map โข at YourService.method(File.kt:42)' for each operator in the failing chain, plus the error's propagation path. ReactorDebugAgent produces the same tracebacks by instrumenting operator call sites at class-load time, so the runtime capture cost disappears.
- Development-time diagnosis when you cannot guess where to put a checkpoint
- ReactorDebugAgent in production for always-on, near-free assembly tracebacks
- CI test runs: enable the hook in a test listener so failures self-locate
- IDE debugging โ IntelliJ IDEA's Reactor debug mode uses this same mechanism
onOperatorDebug() multiplies assembly cost several-fold โ never ship it enabled, and remember it cannot retrofit pipelines assembled before the call (enable it on the first line of main()). The agent must init before your pipeline classes load; ReactorDebugAgent.processExistingClasses() can retransform already-loaded classes at a one-off cost.
val pipeline = Flux.just(1, 0).map { 10 / it } // assembled here
Hooks.onOperatorDebug() // enabled here
pipeline.subscribe(
{ println(it) },
{ e -> println("err: " + e.message) }
)๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. The debug hook is enabled AFTER the pipeline is assembled. Does the error carry an assembly traceback?
checkpoint is surgical โ but surgery requires knowing where to cut, and the whole problem is that you usually don't. Hooks.onOperatorDebug() is the global alternative: called once at startup, before any pipeline is assembled, it switches Reactor into debug mode, where EVERY operator records a stack trace of its declaration site as it is assembled. From then on, any error anywhere arrives wrapped with an assembly traceback โ a readable list like "Flux.map โข at CheckoutService.priceOrders(CheckoutService.kt:42)" naming where each operator in the failing chain was declared, plus the path the error took on its way to the subscriber.
That power has a price: a stack-trace capture on every assembly of every operator, which typically slows pipeline construction several-fold โ this hook is a development tool, full stop. Its production-grade twin is ReactorDebugAgent, from the reactor-tools artifact: a Java agent that instruments operator call sites at class-load time, baking the same assembly information into the bytecode instead of capturing stack traces at runtime. Its overhead is negligible, which is why it is safe to leave on in production โ Spring Boot even initializes it automatically when reactor-tools is on the classpath, and IntelliJ IDEA's Reactor debug mode rides the same mechanism. Call ReactorDebugAgent.init() as early as possible in main(); for classes loaded before that, processExistingClasses() retransforms them at a one-off cost.
- checkpoint โ targeted and near-free (with a description), but you must anticipate WHERE things will fail
- Hooks.onOperatorDebug() โ catches everything with zero guessing, but pays a stack capture on every operator assembly: development only
- ReactorDebugAgent โ the same global tracebacks via bytecode instrumentation at class-load time: negligible overhead, production-safe
import reactor.core.publisher.Flux
import reactor.core.publisher.Hooks
fun main() {
Hooks.onOperatorDebug() // enable BEFORE any pipeline is assembled
Flux.just(4, 2, 0)
.map { 100 / it }
.filter { it > 10 }
.subscribe(
{ println("ratio: $it") },
{ e -> println("boom: ${e.javaClass.simpleName}: ${e.message}") }
)
}The hook is enabled first, then a pipeline divides 100 by each value and filters the results. 4 and 2 produce 25 and 50; then 0 arrives, the division inside map throws, and the error consumer prints the exception. On real Reactor with debug mode active, that same exception would additionally carry the assembly traceback naming the exact lines where map and filter were declared. This playground's Hooks.onOperatorDebug() is a stub, so the output shows plain error handling โ but the pipeline structure and the hook's placement are exactly what you would write in a real app.
ratio: 25 ratio: 50 boom: ArithmeticException: / by zero
The hook only instruments pipelines assembled AFTER the call โ enable it at the very top of main(), not once you are already staring at a failure (already-built pipelines keep their anonymous traces). And never ship onOperatorDebug() enabled: if you want always-on tracebacks in production, that is exactly what ReactorDebugAgent exists for.
hide
fun <T> Flux<T>.hide(): Flux<T>hide wraps the sequence so that both the Flux's concrete class and its Subscription are anonymized. Downstream operators that optimize by recognizing their upstream โ instanceof checks for scalar sources like Flux.just, Fuseable queue-sharing negotiation โ find nothing to recognize and fall back to the general-purpose Publisher protocol. Every signal (values, error, completion) is forwarded exactly as-is.
- Testing custom operators against the general path (not the scalar/fused shortcut)
- Reproducing or ruling out suspected fusion-related bugs
- API boundaries: return flux.hide() so callers cannot downcast to internal types
- Simulating a 'plain' Publisher where Flux.just would take optimized shortcuts
hide is not a correctness fence: it disables optimizations but adds no thread-safety, no backpressure, no protocol enforcement. Each call allocates a wrapper (cheap, not free), and in business logic it is almost always a smell โ its home is tests, benchmarks and library boundaries.
Flux.just(5, 6)
.hide()
.map { it + 1 }
.subscribe { println(it) }๐ง Challenge: predict the output
0/1 ยท 0/1 answered1. What does this print?
hide() is the smallest operator in this category: it forwards every signal unchanged but conceals the concrete identity of its source. Why would anyone want LESS information? Because Reactor itself inspects identities to optimize: when operators recognize their neighbors โ a Flux.just here, a fusion-capable queue there โ they take shortcuts (macro and micro fusion) that skip the general-purpose code paths. hide() breaks that recognition on purpose, hiding both the Flux's class and its Subscription, and forces the plain, unoptimized Publisher protocol.
That makes it a tool for tests and diagnostics, not business logic. Testing a custom operator? Flux.just(x) would trigger the scalar shortcut and skip the very code path under test โ Flux.just(x).hide() exercises the general one. Chasing a bug you suspect is fusion-related? Hiding the source is the fastest way to rule fusion in or out. Designing a library API? Returning flux.hide() keeps callers from downcasting to your internal types and depending on them.
import reactor.core.publisher.Flux
fun main() {
Flux.just("sensor-a: 21.5", "sensor-b: 22.1", "sensor-c: 19.8")
.hide() // downstream now sees an anonymous Flux
.map { it.uppercase() }
.subscribe(
{ println(it) },
{ e -> println("error: $e") },
{ println("complete") }
)
}We have three sensor readings flowing through hide() and then map. The output is byte-for-byte what the same pipeline would print without hide โ its entire effect lives in what downstream operators can DISCOVER about their upstream, never in the signals themselves. Concretely: map can no longer recognize the array-backed source behind it and negotiate a fused fast path; it just sees an anonymous Flux and speaks vanilla Publisher to it.
SENSOR-A: 21.5 SENSOR-B: 22.1 SENSOR-C: 19.8 complete
If you spot hide() in business logic, treat it as a smell โ it exists for tests, benchmarks and API boundaries. It is not a correctness fence either: it disables optimizations, it does not add thread-safety or backpressure. And each call allocates a wrapper, so it is cheap but not free.