Skip to content
TobussSystems in Practice

Java Reactive Programming & Virtual Threads Cheat Sheet

3 min read

A practical reference covering why thread-per-request breaks at scale, Reactor’s Mono/Flux model, the flatMap vs concatMap distinction, backpressure, and Project Loom’s virtual threads.


Thread-per-Request vs Reactive

Thread-per-request breaks at scale. 10k simultaneous connections = 10k threads = ~80 GB of stack memory + enormous context-switch overhead. Reactive uses a small event-loop thread pool (CPU core count) and never blocks — I/O waits become callbacks.

Thread-per-request (blocking)        Reactive (non-blocking)
─────────────────────────────        ────────────────────────
Thread 1: ████░░░░░░████░░░░░        Event loop: ████████████
Thread 2: ██░░░░░░████░░░░░░░        (2-4 threads handle 10k connections)
Thread 3: ░░████░░░░░░████░░░
...
Thread N: ░░░░████░░░░░░████░
█ = working  ░ = blocked on I/O

Mono & Flux

Mono: 0 or 1 item. Flux: 0 to N items. Both are lazy — nothing executes until someone subscribes. Operators are composable and form a processing pipeline.

// Mono — single async value
Mono<Account> account = accountRepository.findById(id); // lazy, no DB call yet
account.subscribe(a -> System.out.println(a));          // NOW it runs

// Flux — stream of values
Flux<Transaction> txns = transactionRepository.findByAccount(id);
txns
    .filter(t -> t.amount().compareTo(BigDecimal.ZERO) > 0)
    .map(t -> new TxnDTO(t.id(), t.amount()))
    .take(100)
    .subscribe(dto -> send(dto));

// Common operators
Flux.range(1, 10)
    .map(i -> i * 2)                     // transform each element
    .filter(i -> i > 5)                  // keep matching
    .flatMap(i -> fetchAsync(i))          // concurrent async for each
    .collectList()                        // gather into Mono<List>
    .block();                             // subscribe + block (only in tests!)

// Combining
Mono.zip(fetchUser(id), fetchAccount(id))
    .map(tuple -> new UserAccount(tuple.getT1(), tuple.getT2()));

flatMap vs concatMap

The most important Reactor distinction for banking: flatMap is concurrent and unordered, concatMap is sequential and ordered. Use concatMap when processing order matters (ledger entries, event sourcing).

Input:  [A, B, C]  (each triggers async work)

flatMap (concurrent, unordered):
  A ──────────────▶ result_A   (A and B run at the same time)
  B ──────▶ result_B           (B finishes first)
  C ──────────────────▶ result_C
Output: [result_B, result_A, result_C]  ← order NOT preserved

concatMap (sequential, ordered):
  A ──────────────▶ result_A
                              B ──────▶ result_B   (B starts only after A done)
                                                  C ──▶ result_C
Output: [result_A, result_B, result_C]  ← order preserved
// flatMap — concurrent, good for independent lookups
Flux.fromIterable(accountIds)
    .flatMap(id -> accountService.findById(id))  // all fire concurrently
    .collectList();

// flatMap with concurrency limit
Flux.fromIterable(accountIds)
    .flatMap(id -> accountService.findById(id), 5)  // max 5 concurrent

// concatMap — sequential, use for ordered operations
Flux.fromIterable(ledgerEvents)
    .concatMap(event -> applyEvent(event))  // strictly one at a time
    .doOnNext(result -> log.info("applied: {}", result));

// Gotcha: flatMap error handling
flux.flatMap(item ->
    process(item)
        .onErrorResume(e -> Mono.empty())  // skip failed items
);

Backpressure

When a producer emits faster than a consumer can process, backpressure prevents buffer overflow. Reactor implements the Reactive Streams spec — the subscriber requests N items, and the producer respects that demand.

// Backpressure strategies
Flux.range(1, 1_000_000)
    .onBackpressureBuffer(1000)      // buffer up to 1000, error if exceeded
    .onBackpressureDrop()            // silently drop items consumer can't keep up with
    .onBackpressureLatest()          // keep only the most recent unprocessed item
    .onBackpressureError();          // throw OverflowException immediately

// Subscriber controlling demand
Flux.range(1, 100)
    .subscribe(new BaseSubscriber<Integer>() {
        protected void hookOnSubscribe(Subscription s) {
            request(10);  // request 10 items initially
        }
        protected void hookOnNext(Integer value) {
            process(value);
            request(1);   // request 1 more after each processed
        }
    });

// Connecting a slow consumer to a fast producer
Flux.interval(Duration.ofMillis(1))   // emits every 1ms
    .onBackpressureDrop()
    .publishOn(Schedulers.boundedElastic())
    .subscribe(i -> {
        Thread.sleep(100); // processing takes 100ms — far slower than producer
    });

Virtual Threads (Project Loom)

Project Loom brings cheap threads — millions can exist simultaneously. Each virtual thread is a thin wrapper; the JVM mounts them onto carrier (OS) threads only when running. Blocking I/O automatically unmounts the virtual thread, freeing the carrier. Structured concurrency brings task lifecycle management.

Virtual Threads               Platform Thread
┌──────┐ ┌──────┐ ┌──────┐       ┌──────────┐
│  VT1 │ │  VT2 │ │  VT3 │  ───▶ │Carrier 1 │ (OS thread)
└──────┘ └──────┘ └──────┘       └──────────┘
┌──────┐ ┌──────┐                 ┌──────────┐
│  VT4 │ │  VT5 │            ───▶ │Carrier 2 │ (OS thread)
└──────┘ └──────┘                 └──────────┘
VT blocks on I/O → unmounted → carrier picks up next runnable VT
// Create virtual threads (Java 21)
Thread vt = Thread.ofVirtual().start(() -> handleRequest(req));

// ExecutorService with virtual threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 100_000; i++) {
        executor.submit(() -> {
            // blocking I/O here is fine — the carrier thread is freed
            String data = httpClient.get(url); // blocks VT, not carrier
            process(data);
        });
    }
} // auto-shutdown + await

// Structured concurrency (Java 21 preview)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Future<User>    user    = scope.fork(() -> fetchUser(id));
    Future<Account> account = scope.fork(() -> fetchAccount(id));
    scope.join();           // wait for both
    scope.throwIfFailed();  // propagate any error
    return new UserAccount(user.get(), account.get());
}
// If fetchUser fails, fetchAccount is automatically cancelled

// Virtual threads vs Reactor:
// Virtual threads: simpler code, blocking style, good for thread-per-request
// Reactor: more control, backpressure, composable pipelines, mature ecosystem