Sitemap
Alexander Mazanov

Alexander Mazanov

Senior Backend Developer with 7 years of commercial experience. Expert in designing systems across banking and fintech domains using Java, Kotlin and Spring.

Getting Started with Project Reactor and Kotlin Flow: A Practical Guide Part 2

7 min readJul 9, 2025

--

Press enter or click to view image in full size

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

--

--

Written by Alexander Mazanov

Senior Backend Developer with 7 years of commercial experience. Expert in designing systems across banking and fintech domains using Java, Kotlin and Spring.