Getting Started with Project Reactor and Kotlin Flow: A Practical Guide Part 2
In Part 1, we broke down the fundamentals of reactive programming — the why, the when, and the key principles behind the Reactive Streams model.
Now it’s time to get our hands dirty: let’s build our first simple reactive pipelines in Java with Project Reactor and in Kotlin with Flow.
By the end of this guide, you’ll know:
- How to create and subscribe to reactive streams in Java and Kotlin
- The basic building blocks: `Mono`, `Flux`, `flow {}`, `collect`
- How operators like `map` and `filter` work in a stream
- The basics of Kotlin coroutines that power `Flow
🚀 Tools & Setup
To follow along, you’ll need:
- Java 17+
- Kotlin 1.8+
- Gradle or Maven (your choice)
- An IDE like IntelliJ IDEA (recommended for Kotlin)
Add these dependencies:
Project Reactor (Gradle):
dependencies { implementation 'io.projectreactor:reactor-core:3.6.0' }Kotlin Coroutines (Flow is part of this):
dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0") }📌 Optional: Use Spring Boot with WebFlux for Reactor — but here we’ll keep it simple.
Your First Reactive Stream in Java (Project Reactor)
Mono and Flux
- Mono<T>: a stream that emits 0 or 1 item.
- Flux<T>: a stream that emits 0 to N items.
public static void main(String[] args) {
Mono<String> mono = Mono.just("Hello Reactor");
Flux<Integer> flux = Flux.range(1, 5);
mono.subscribe(System.out::println);
flux.subscribe(
value -> System.out.println("Next: " + value),
error -> System.err.println("Error: " + error),
() -> System.out.println("Done")
);
}📌 Key point: Nothing happens until you call `subscribe()` — the stream is just a blueprint.
The subscribe() Method — How It Works
In Project Reactor, `subscribe()` is how you connect your Publisher (Mono/Flux) to a Subscriber — triggering the flow of data.
It can take up to three callbacks:
- onNext → runs for each emitted item
- onError → runs if an error occurs
- onComplete → runs once the stream finishes successfully
Example:
flux.subscribe(
value -> System.out.println("Next: " + value), // onNext
error -> System.err.println("Error: " + error), // onError
() -> System.out.println("Done") // onComplete
);Is `subscribe()` blocking?
No — it’s non-blocking by default.
When you call `subscribe()`, Reactor sets up the data flow and returns immediately. The data is pushed asynchronouslywhen it becomes available.
Does it process items when they come?
Yes — as soon as the `Publisher` emits data, the `Subscriber` reacts. If there’s backpressure, the stream will respect the demand signaled by the `Subscriber`.
How does it know when to stop?
The `Publisher` knows the sequence is done when:
- It runs out of data (like reaching the end of `Flux.range()`)
- Or if it hits an error (e.g., you throw an exception)
- It then sends an `onComplete` signal to the `Subscriber`.
Basic Operators
Project Reactor gives you tons of operators to transform your data.
Example:
Flux<Integer> numbers = Flux.range(1, 5);
numbers
.filter(n -> n % 2 == 0)
.map(n -> n * n)
.subscribe(n -> System.out.println("Processed: " + n));Here, we:
- Filter out odd numbers
- Square the even ones
- Print each result
Your First Reactive Stream in Kotlin (Flow)
Creating a Flow
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.delay
fun main() = runBlocking {
val myFlow = flow {
emit(1)
delay(100)
emit(2)
delay(100)
emit(3)
}
myFlow.collect { value -> println("Received: $value") }
}📌 Key points:
- `flow {}` builder emits values one by one.
- `collect` is a terminal suspending function: it runs the flow.
- `delay` inside `flow` shows non-blocking suspension.
Operators in Flow
val flow = (1..5).asFlow()
.filter { it % 2 == 0 }
.map { it * it }
flow.collect { println("Processed: $it") }Looks familiar, right? The idea is the same: transform data step by step.
A Quick Word on Coroutines
Kotlin Flow is part of the Kotlin Coroutines ecosystem.
A coroutine is like a lightweight thread that lets you write async code sequentially — without callbacks or blocking threads.
Key ideas:
- Coroutines are suspendable: they pause and resume efficiently.
- `Flow` uses coroutines to emit items asynchronously and backpressure is handled by suspending the producer if the consumer is slower.
⚡️ Tiny Example
Goal: Stream some numbers, process them reactively.
Java / Reactor
Flux.range(1, 10)
.map(n -> n * 2)
.filter(n -> n > 10)
.subscribe(n -> System.out.println("Result: " + n));Kotlin / Flow
runBlocking {
(1..10).asFlow()
.map { it * 2 }
.filter { it > 10 }
.collect { println("Result: $it") }
}Same logic, same result — just two different idioms.
⚠️ Common Pitfalls
Remember:
- Forget to `subscribe()` in Reactor or `collect` in Flow → nothing happens.
- Blocking calls inside flows defeat the purpose — use suspending functions instead.
- Debugging: `log()` in Reactor or `onEach` in Flow help trace emissions.
Going Beyond Java Streams — What Makes Reactive Different
At first glance, reactive streams can look like Java’s Stream API — `map()`, `filter()`, etc. But there’s much more under the hood.
Here’s what makes Reactor and Flow reactive:
1️⃣ Asynchronous & Non-Blocking
Unlike `Stream`, which runs top-to-bottom, `Flux` and `Flow` can:
- React to real-time events (data can come in at any time)
- Continue running until a source closes (like an endless Kafka topic)
2️⃣ Backpressure Handling
Reactor implements the Reactive Streams spec, so a `Subscriber` can say “I can handle 10 items at a time” — controlling flow to avoid memory overload.
Get Alexander Mazanov’s stories in your inbox
Join Medium for free to get updates from this writer.
Example:
flux
.onBackpressureBuffer()
.subscribe();3️⃣ Error Handling & Retry
Reactive streams have built-in, chainable error strategies:
flux
.map(this::process)
.onErrorResume(e -> Flux.just(0)) // fallback
.retry(3) // auto-retry up to 3 times
.subscribe();4️⃣ Schedulers & Parallelism
Reactive streams can switch threads in the chain:
flux
.publishOn(Schedulers.boundedElastic()) // run on I/O pool
.subscribe();`This makes async I/O easy without blocking main threads.
5️⃣ Time-Based Operators
Reactor and Flow can:
- Delay emissions
- Combine data streams with intervals
- Window streams over time
Example:
Flux.interval(Duration.ofSeconds(1))
.take(5)
.subscribe(System.out::println);`This emits an increasing value every second — a pure reactive timer.
✅ Bottom line: while `map` and `filter` feel familiar, the reactive toolkit unlocks:
- Infinite or streaming data
- Backpressure awareness
- Robust error flows
- Fine-grained threading
- Non-blocking I/O at scale
How reactive streams work under the hood
What happens in a plain `main()` method
Example:
public static void main(String[] args) {
Flux<Integer> flux = Flux.range(1, 5)
.map(n -> n * 2)
.doOnNext(System.out::println);
flux.subscribe();
System.out.println("Main method ends.");
}🔍 What happens here?
1️⃣ You create a cold pipeline → Flux.range(…) → .map(…) → .doOnNext(…).
No work happens yet. It’s just a blueprint.
2️⃣ You call .subscribe() → now the Publisher starts emitting → onNext handlers run.
3️⃣ The `main` method doesn’t know or care — it just keeps going.
BUT:
👉 `Flux.range(…)` is fully synchronous by default.
So `subscribe()` runs the flow on the current thread — the main thread.
It blocks until it’s done emitting.
So in this case, the whole `Flux` finishes in the same thread. Then `System.out.println(“Main method ends.”)` runs.
What if you use async operators?
Flux.interval(Duration.ofMillis(500))
.take(5)
.doOnNext(System.out::println)
.subscribe();
System.out.println("Main method ends.");1️⃣ `Flux.interval` is asynchronous → it runs on a Scheduler (`Schedulers.parallel()` by default).
2️⃣ The `subscribe()` call returns immediately.
3️⃣ The main thread prints “Main method ends.”
4️⃣ Then the JVM exits before the async work finishes.
❗️ So here’s the catch:
When using async Flux in a `main()` — your app can exit before your work finishes.
How do you handle that?
You need to keep the JVM alive:
public static void main(String[] args) throws InterruptedException {
Flux.interval(Duration.ofMillis(500))
.take(5)
.doOnNext(System.out::println)
.subscribe();
Thread.sleep(3000); // Keep JVM alive long enough for Flux to finish
System.out.println("Done");
}Or:
- Use `blockLast()` for a `Flux`
- Use `block()` for a `Mono`
Example:
Flux.interval(Duration.ofMillis(500))
.take(5)
.doOnNext(System.out::println)
.blockLast();Here, `.blockLast()` blocks the calling thread until `onComplete`.
🧩 Key takeaway
- In Spring WebFlux: Spring calls `.subscribe()` for you and the Netty server keeps threads alive.
- In plain main(): you call `.subscribe()` and If async: you must keep JVM alive
So: Reactive streams themselves are not magic. They don’t “hold the thread open.” Your main thread can exit any time — unless you manage that lifecycle.
This is exactly why in real-world apps you don’t run Reactor code in plain `main()` forever — you wire it into a server, a scheduler, or a long-running process (like Spring Boot, Kafka consumer, etc).
What happens if the main thread exits before the async resource finishes?
If your `Flux` or `Mono` is doing asynchronous work — for example, `Flux.interval(…)` or a DB call — and the main thread finishes, the whole JVM process ends.
The Reactor scheduler (or any threads Reactor uses internally) can’t keep the JVM alive by itself — those are typically daemon threads.
👉 So if there’s no non-daemon work left, the JVM exits — and any unfinished async work simply stops. No more emissions, no completion, nothing. It’s as if you yanked the power cord.
Does `subscribe()` create a new thread?
It depends entirely on the source and the operators you use.
Basic truth:
👉 `subscribe()` by itself does not automatically spin up new threads.
Reactor streams run on the calling thread by default, unless you explicitly switch context.
Flux.range(1, 5)
.map(n -> n * 2)
.subscribe(System.out::println);This runs entirely synchronously on the calling thread.
`subscribe()` does not create threads — the `range` publisher just pushes data downstream immediately.
But:
If your pipeline uses an operator that introduces asynchrony, then `subscribe()` will cause work to happen on other threads, managed by Reactor.
Flux.interval(Duration.ofMillis(500))
.doOnNext(System.out::println)
.subscribe();- `interval` always uses Schedulers.parallel() internally → it creates a worker thread.
- That worker thread emits signals every 500 ms.
- `subscribe()` attaches your handler.
- The emissions happen on the worker thread, not the main thread.
So how do you keep it alive properly?
For local testing:
Use `blockLast()` (for `Flux`) or `block()` (for `Mono`). These block the calling thread until the stream is complete.
For real apps:
You don’t care — your app is long-running (HTTP server, Kafka consumer, etc.) so there’s always a non-daemon main thread running.
Key takeaway
Reactive streams themselves are just pipelines. The source or an operator determines whether the flow is async or not — `subscribe()` just hooks you in.
🔗 What’s Next?
You just built your first streams!
In Part 3, we’ll tackle:
- Backpressure: what happens when producers are faster than consumers?
- Error handling: `onErrorResume`, retries, and safe recovery.
✅ Conclusion
Project Reactor and Kotlin Flow both give you powerful tools to build scalable, asynchronous apps. The big difference?
- Reactor is library-based, built on the Reactive Streams spec.
- Flow is natively integrated with Kotlin coroutines — a more structured concurrency approach.
👉 Follow me here and on LinkedIn to get Part 3!
Have questions? Drop them below or share your first Flow/Reactor use case!
#Java #Kotlin #ReactiveProgramming #ProjectReactor #Coroutines #Backend








