- Published on
Reactive Spring Boot 4.1 with WebFlux, R2DBC, and Kafka: Boundaries, Backpressure, and Transactions
- Authors

- Name
- Maria
A reactive Spring application is not defined by returning Mono and Flux. It is defined by preserving non-blocking behavior across the complete request path and by making demand, cancellation, concurrency, and transaction boundaries explicit.
One blocking database driver inside a map operator can stall an event-loop thread. One unbounded flatMap can overwhelm PostgreSQL. One misplaced Kafka acknowledgement can lose a record. Reactive code makes these problems composable, but it does not remove them.
This guide builds an order-event query service with Spring Boot 4.1, Spring WebFlux, R2DBC, PostgreSQL, and Reactor Kafka. The goal is not to convert every application to reactive programming. The goal is to show where the model is useful and what must remain true for it to work.
TL;DR Choose WebFlux for a measured concurrency or streaming need, not because
Monolooks modern. Keep blocking work off Netty event-loop and Kafka receiver threads. Let demand bound concurrency; do not use unboundedflatMap. UseTransactionalOperatoror a correctly proxied reactive@Transactionalmethod for R2DBC work. A PostgreSQL transaction and a Kafka transaction do not form one atomic transaction. Commit Kafka offsets only after the intended side effect succeeds, and make that side effect idempotent.
Decide Whether Reactive Is the Right Model
WebFlux is a strong fit when:
- the service maintains many concurrent, mostly waiting connections;
- responses are streamed over Server-Sent Events or another streaming protocol;
- all major dependencies expose non-blocking APIs;
- backpressure is part of the integration design;
- the team can debug asynchronous pipelines and context propagation.
Spring MVC with virtual threads can be simpler when:
- the application is primarily request/response CRUD;
- JDBC and blocking SDKs dominate the path;
- the expected concurrency fits available resource pools;
- imperative code is easier for the team to maintain;
- streaming and reactive composition provide little value.
Both models can scale. Compare them with the same payloads, connection pools, latency objectives, failure injection, and hardware.
Understand the Threading Boundary
Reactor does not assign one thread per request. Operators normally continue on the thread that emits the signal unless a scheduler boundary changes it.
In a typical WebFlux application using Reactor Netty:
HTTP event loop
-> controller
-> service
-> R2DBC driver
-> response encoding
That efficiency depends on each step avoiding blocking.
This is unsafe:
Mono<OrderView> findOrder(UUID orderId) {
return Mono.just(orderId)
.map(id -> jdbcTemplate.queryForObject(
"select * from orders where id = ?",
orderMapper,
id));
}
The method returns a Mono, but JdbcTemplate still blocks the subscribing thread.
If a blocking dependency cannot yet be replaced, isolate it:
Mono<LegacyRiskScore> loadLegacyRiskScore(UUID customerId) {
return Mono.fromCallable(
() -> legacyRiskClient.load(customerId))
.subscribeOn(Schedulers.boundedElastic());
}
This is a migration boundary, not proof of an end-to-end non-blocking architecture. The bounded elastic scheduler has finite capacity, the legacy client needs timeouts, and the operation still consumes a blocking resource while it waits.
Project Setup
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version>
<relativePath />
</parent>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-r2dbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>r2dbc-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.projectreactor.kafka</groupId>
<artifactId>reactor-kafka</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
The JDBC PostgreSQL driver is present for Flyway or Liquibase migrations. Schema migration tools generally use JDBC even when application data access uses R2DBC.
spring:
r2dbc:
url: r2dbc:postgresql://localhost:5432/orders
username: orders_app
password: ${ORDERS_DB_PASSWORD}
pool:
initial-size: 5
max-size: 30
max-acquire-time: 2s
Do not set a large pool merely because the application can maintain many reactive requests. PostgreSQL has finite CPU, memory, and connection capacity.
Create the Schema
CREATE TABLE order_event (
event_id UUID PRIMARY KEY,
order_id UUID NOT NULL,
event_type VARCHAR(100) NOT NULL,
aggregate_version BIGINT NOT NULL,
payload JSONB NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL,
UNIQUE (order_id, aggregate_version)
);
CREATE INDEX order_event_order_time_idx
ON order_event (order_id, occurred_at DESC);
The unique constraint gives duplicate event writes a durable definition. Application-level distinct() is not a replacement for a database invariant.
Use R2DBC Without Hiding SQL Behavior
public record OrderEvent(
UUID eventId,
UUID orderId,
String eventType,
long aggregateVersion,
String payload,
Instant occurredAt) {
}
@Repository
public class OrderEventRepository {
private final DatabaseClient database;
public OrderEventRepository(DatabaseClient database) {
this.database = database;
}
public Flux<OrderEvent> findByOrderId(
UUID orderId,
int limit) {
return database.sql("""
SELECT event_id,
order_id,
event_type,
aggregate_version,
payload::text AS payload,
occurred_at
FROM order_event
WHERE order_id = :orderId
ORDER BY occurred_at DESC
LIMIT :limit
""")
.bind("orderId", orderId)
.bind("limit", limit)
.map((row, metadata) -> new OrderEvent(
row.get("event_id", UUID.class),
row.get("order_id", UUID.class),
row.get("event_type", String.class),
requireNonNull(
row.get("aggregate_version", Long.class)),
row.get("payload", String.class),
row.get("occurred_at", Instant.class)))
.all();
}
public Mono<Void> insert(OrderEvent event) {
return database.sql("""
INSERT INTO order_event (
event_id,
order_id,
event_type,
aggregate_version,
payload,
occurred_at
)
VALUES (
:eventId,
:orderId,
:eventType,
:aggregateVersion,
CAST(:payload AS jsonb),
:occurredAt
)
ON CONFLICT (event_id) DO NOTHING
""")
.bind("eventId", event.eventId())
.bind("orderId", event.orderId())
.bind("eventType", event.eventType())
.bind("aggregateVersion", event.aggregateVersion())
.bind("payload", event.payload())
.bind("occurredAt", event.occurredAt())
.fetch()
.rowsUpdated()
.then();
}
}
Binding parameters protects the query and allows the driver to handle types. A dynamic sort column cannot be safely bound as a value; map it from an allow-list instead of concatenating arbitrary input.
Expose a Bounded WebFlux Endpoint
@RestController
@RequestMapping("/api/orders")
public class OrderEventController {
private final OrderEventRepository repository;
public OrderEventController(
OrderEventRepository repository) {
this.repository = repository;
}
@GetMapping(
value = "/{orderId}/events",
produces = MediaType.APPLICATION_JSON_VALUE)
public Flux<OrderEvent> events(
@PathVariable UUID orderId,
@RequestParam(defaultValue = "50") int limit) {
int boundedLimit = Math.max(
1,
Math.min(limit, 200));
return repository.findByOrderId(
orderId,
boundedLimit);
}
}
For ordinary JSON, Spring collects and encodes the response according to the selected writer. Returning a Flux does not guarantee that each item reaches the client immediately.
For a live stream, select a streaming media type:
@GetMapping(
value = "/{orderId}/events/live",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<OrderEvent>> liveEvents(
@PathVariable UUID orderId) {
return eventFeed.forOrder(orderId)
.map(event -> ServerSentEvent
.builder(event)
.id(event.eventId().toString())
.event("order-event")
.build())
.timeout(Duration.ofMinutes(5))
.doOnCancel(
() -> log.debug(
"client cancelled order event stream"));
}
Decide what reconnect means. If events must not be missed, use an event identifier and a replay source. A live in-memory Flux alone cannot guarantee recovery after disconnect.
Backpressure Is a Capacity Contract
Reactive Streams allow a subscriber to signal demand. That does not mean every external system supports backpressure.
- R2DBC can fetch rows incrementally, subject to driver behavior.
- HTTP response writing can slow demand when a client reads slowly.
- Kafka retains records independently of one consumer's demand.
- a callback-only legacy SDK may continue producing even when downstream demand stops.
Bound concurrency explicitly:
Flux<EnrichedEvent> enriched = events
.flatMap(
event -> customerClient
.findCustomer(event.orderId())
.map(customer -> enrich(event, customer)),
16);
The second argument limits concurrent inner subscriptions to 16. Without a justified limit, a sudden batch can create hundreds of downstream calls.
Ordering changes the operator choice:
events.concatMap(this::processInOrder);
uses one-at-a-time ordering, while:
events.flatMapSequential(this::process, 16);
allows concurrent work but emits in source order. The correct choice depends on whether processing order, result order, or throughput is the invariant.
Avoid onBackpressureBuffer() without a maximum size and overflow policy. An unbounded buffer converts temporary slowness into eventual memory exhaustion.
Reactive Transactions with R2DBC
Reactive transaction state travels through Reactor context, not an ordinary thread-local transaction.
@Configuration
class TransactionConfiguration {
@Bean
TransactionalOperator transactionalOperator(
ReactiveTransactionManager transactionManager) {
return TransactionalOperator.create(transactionManager);
}
}
@Service
public class OrderEventWriter {
private final OrderEventRepository events;
private final OrderProjectionRepository projections;
private final TransactionalOperator transactions;
public OrderEventWriter(
OrderEventRepository events,
OrderProjectionRepository projections,
TransactionalOperator transactions) {
this.events = events;
this.projections = projections;
this.transactions = transactions;
}
public Mono<Void> append(OrderEvent event) {
return events.insert(event)
.then(projections.apply(event))
.as(transactions::transactional);
}
}
Both operations must use the same reactive connection factory for one local transaction. Subscribing manually inside the method breaks composition:
// Wrong: creates an independent execution.
events.insert(event).subscribe();
Return the composed publisher and let the framework subscribe.
Also avoid swallowing an error inside the transaction:
events.insert(event)
.onErrorResume(error -> Mono.empty())
.then(projections.apply(event));
That may allow a partial result to commit. Handle expected conflicts deliberately and propagate failures that must roll back the transaction.
PostgreSQL and Kafka Are Separate Transaction Boundaries
This pipeline is not atomic:
repository.insert(event)
.then(kafkaSender.send(...).then());
The database commit can succeed and the Kafka send can fail, or the send can succeed while the database transaction rolls back. Reactive composition controls execution order; it does not create a distributed transaction.
For reliable publication, write an outbox row in the same PostgreSQL transaction as the business change, then relay committed rows to Kafka.
CREATE TABLE integration_outbox (
event_id UUID PRIMARY KEY,
aggregate_id UUID NOT NULL,
event_type VARCHAR(120) NOT NULL,
payload JSONB NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'READY',
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
published_at TIMESTAMPTZ
);
CREATE INDEX integration_outbox_ready_idx
ON integration_outbox (next_attempt_at, created_at)
WHERE status = 'READY';
An outbox still needs a relay, retry policy, claim mechanism, idempotent publication strategy, retention, and monitoring. It solves the database-change/publication gap; it does not provide exactly-once business effects across every consumer.
Consume Kafka with an Explicit Delivery Contract
@Bean
KafkaReceiver<String, String> orderReceiver(
ReceiverOptions<String, String> receiverOptions) {
return KafkaReceiver.create(
receiverOptions.subscription(
Set.of("orders.v1")));
}
@Component
public class OrderEventConsumer {
private final KafkaReceiver<String, String> receiver;
private final OrderEventWriter writer;
public OrderEventConsumer(
KafkaReceiver<String, String> receiver,
OrderEventWriter writer) {
this.receiver = receiver;
this.writer = writer;
}
@EventListener(ApplicationReadyEvent.class)
public void start() {
receiver.receive()
.groupBy(record ->
record.receiverOffset()
.topicPartition())
.flatMap(partition ->
partition.concatMap(this::process))
.retryWhen(
Retry.backoff(
Long.MAX_VALUE,
Duration.ofSeconds(1))
.maxBackoff(
Duration.ofMinutes(1))
.jitter(0.5))
.subscribe();
}
private Mono<Void> process(
ReceiverRecord<String, String> record) {
OrderEvent event = decode(record.value());
return writer.append(event)
.then(
record.receiverOffset()
.commit());
}
}
This provides at-least-once processing: a crash after the database commit but before the offset commit can deliver the record again. The database write must therefore be idempotent.
The groupBy plus concatMap structure preserves order within each Kafka partition while allowing different partitions to progress concurrently. Confirm that the Kafka key places records requiring order in the same partition.
Do not retry malformed records forever. Classify:
- transient infrastructure failure: retry with bounded backoff;
- known business rejection: record the outcome and acknowledge if appropriate;
- invalid schema or poison message: send to a governed dead-letter flow or stop for intervention;
- unknown programming failure: alert and avoid silently skipping the record.
Exactly-Once Has a Narrow Scope
Reactor Kafka supports Kafka transactions for consume-transform-produce pipelines. That can atomically commit consumed offsets with records produced to Kafka when configured correctly.
It does not atomically include an external PostgreSQL transaction. If the pipeline writes to PostgreSQL, design for at-least-once delivery and idempotent database effects, or use a different consistency pattern.
Document exactly which boundary has exactly-once behavior:
Kafka input offset + Kafka output records
is different from:
Kafka input + PostgreSQL row + email + HTTP call
Context, Logging, and Security
Reactive execution can move between threads. Do not depend on raw ThreadLocal state for request identity or tracing.
Use Reactor context for request-scoped values:
Mono<OrderView> load(UUID orderId) {
return Mono.deferContextual(context -> {
String correlationId =
context.getOrDefault(
"correlationId",
"missing");
return repository.find(orderId)
.doOnEach(signal ->
logSignal(signal, correlationId));
});
}
Spring Security's reactive support also uses Reactor context. Test authentication across custom schedulers and integrations rather than assuming context follows every manual bridge.
Do not put access tokens, email addresses, order IDs, or arbitrary request values into metric tags.
Test Demand, Cancellation, and Transactions
Use StepVerifier for publisher behavior:
@Test
void returnsEventsNewestFirst() {
Flux<OrderEvent> result =
repository.findByOrderId(orderId, 2);
StepVerifier.create(result)
.assertNext(event ->
assertThat(event.aggregateVersion())
.isEqualTo(3))
.assertNext(event ->
assertThat(event.aggregateVersion())
.isEqualTo(2))
.verifyComplete();
}
Test rollback:
@Test
void rollsBackWhenProjectionFails() {
Mono<Void> operation =
writer.append(eventCausingProjectionFailure);
StepVerifier.create(operation)
.expectError()
.verify();
StepVerifier.create(
repository.exists(eventCausingProjectionFailure.eventId()))
.expectNext(false)
.verifyComplete();
}
For meaningful integration coverage, use PostgreSQL in a disposable environment rather than replacing R2DBC with an unrelated in-memory database.
Also test:
- a subscriber that requests one item at a time;
- cancellation during a slow database query;
- a client that disconnects from an SSE stream;
- duplicate Kafka delivery;
- out-of-order records across different keys;
- poison-message handling;
- database pool exhaustion;
- a blocked legacy dependency on the bounded elastic scheduler.
Observability
Track:
- HTTP request duration and active streaming responses;
- R2DBC pool acquired, pending, and timeout counts;
- Kafka consumer lag by group and partition;
- processing duration and failure category;
- retry and dead-letter counts;
- bounded elastic scheduler saturation;
- dropped or overflowed elements;
- cancellations and timeouts.
Reactor checkpoints and assembly tracing can help in development, but global debug hooks add overhead. In production, prefer targeted checkpoints, correlation IDs, Micrometer observations, and distributed traces.
Common Failure Modes
The application is reactive but throughput collapses
Search for JDBC, file I/O, synchronous DNS, blocking SDKs, Thread.sleep, Future.get, and .block() on event-loop paths. Inspect database pool saturation before increasing concurrency.
block() works in a test but fails or stalls in production
Do not bridge back to blocking code inside the reactive server path. Compose the publisher through the controller boundary. At a truly imperative edge, apply a timeout and ensure the blocking call is not on a non-blocking scheduler.
Kafka lag grows while PostgreSQL looks healthy
Check per-partition ordering, concurrency limits, offset commit latency, poison records, retry loops, and whether one hot key owns a partition.
Memory grows during traffic spikes
Look for unbounded collectList, cache, replay sinks, backpressure buffers, large request bodies, and excessive flatMap concurrency.
An R2DBC transaction commits only half the intended work
Check for manual subscribe(), swallowed errors, work performed outside the publisher passed to TransactionalOperator, and calls using another connection factory.
Operational Checklist
- every dependency in the hot path is known to be blocking or non-blocking;
- blocking bridges use a bounded scheduler and their own timeouts;
- database pool size is based on PostgreSQL capacity;
- query results and HTTP request bodies are bounded;
flatMapconcurrency is explicit;- ordering requirements map to Kafka keys and partition processing;
- offset commit happens after the intended durable effect;
- consumers are idempotent;
- database-to-Kafka publication uses an outbox or an equivalent durable design;
- live streams have replay, overflow, heartbeat, and disconnect policies;
- cancellation and pool exhaustion are tested;
- metrics do not contain sensitive or high-cardinality tags.
Conclusion
WebFlux, R2DBC, and Reactor Kafka can form an efficient non-blocking pipeline, but only when the entire path respects the model. Returning reactive types while hiding JDBC or unbounded concurrency produces complexity without the capacity benefit.
The most important design choices are boundaries: where blocking work is isolated, how much demand is allowed, what one transaction can actually commit, when a Kafka offset is safe to advance, and what happens after cancellation. Make those choices visible in code and tests, and reactive programming becomes an operational model rather than a collection of operators.
Recommended Articles for Further Reading
- Production gRPC with Spring Boot, Deadlines, and Virtual Threads
- Transactional Outbox with Spring Boot, Kafka, and PostgreSQL