How Spring Boot Handles Reactive Transactions
Spring Boot has made it easier to work with reactive programming, especially when dealing with databases. Traditional JDBC transactions…
How Spring Boot Handles Reactive Transactions

Image Source
Spring Boot has made it easier to work with reactive programming, especially when dealing with databases. Traditional JDBC transactions follow a blocking model, which doesn’t fit well with the non-blocking nature of reactive programming. To address this, Spring Boot integrates with R2DBC (Reactive Relational Database Connectivity), allowing applications to handle database transactions without blocking threads. This article will go over how Spring Boot manages reactive transactions, how TransactionalOperator is used, and how transaction boundaries work in this model.
How Spring Boot Uses R2DBC for Reactive Transactions
Reactive database transactions in Spring Boot are supported through R2DBC, a specification designed for handling database operations without blocking execution. Traditional JDBC transactions are thread-bound, meaning they hold up execution until a database operation completes. In contrast, R2DBC transactions run asynchronously, which changes how transaction control is handled. Because there are no dedicated threads waiting for results, the usual thread-local transaction management methods do not apply.
Instead of using @Transactional, which is built for blocking database transactions, Spring Boot manages reactive transactions with TransactionalOperator. This class provides a way to handle transactions within a reactive stream, making it possible to group multiple database operations into a single transactional unit without blocking execution.
How R2DBC Transactions Differ from JDBC Transactions
JDBC transactions rely on a single-threaded connection, meaning all database commands in a transaction run sequentially within the same thread. Each transaction is tied to a thread-local database connection, making sure that the operations execute in a controlled, blocking manner. This design makes it possible to use thread-scoped transaction management features like propagation levels and rollbacks.
R2DBC transactions work differently because they are not bound to a dedicated thread. Instead of relying on a blocking execution model, R2DBC transactions operate asynchronously within a reactive pipeline. Since execution is based on publishers and subscribers, transactions do not hold up execution while waiting for database responses. This means that transaction context must be explicitly managed across different points in the reactive flow, rather than being automatically handled through thread-local storage.
With JDBC, once a transaction starts, all operations within the same thread automatically belong to that transaction. In R2DBC, a transaction remains valid only within the scope of the reactive sequence, and every operation must be part of that sequence to participate in the transaction. If an operation executes outside the active transaction context, it runs independently, which can lead to unintended behavior if not managed correctly.
For example, in JDBC:
@Transactional
public void transferMoney(Long fromAccount, Long toAccount, Double amount) {
debit(fromAccount, amount);
credit(toAccount, amount);
}
Here, the @Transactional annotation keeps both debit() and credit() within the same thread-local transaction. The transaction stays active as long as the method is running because JDBC ties it to the thread handling the request.
With R2DBC, this structure does not work the same way. A reactive pipeline can execute across multiple event loops, and without proper handling, each database call might run in a separate transaction. Using @Transactional does not automatically apply transaction management across the pipeline.
Using TransactionalOperator for Reactive Transactions
Spring Boot provides TransactionalOperator as the standard way to manage transactions in a reactive setting. Instead of being bound to a thread, transactions wrap the execution of a reactive stream, making sure that all operations within the sequence execute within the same transactional scope.
Example — Implementing a Reactive Transaction in Spring Boot
import org.springframework.r2dbc.connection.TransactionDefinition;
import org.springframework.r2dbc.connection.TransactionalOperator;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
@Service
public class AccountService {
private final AccountRepository accountRepository;
private final TransactionalOperator transactionalOperator;
public AccountService(AccountRepository accountRepository, TransactionalOperator transactionalOperator) {
this.accountRepository = accountRepository;
this.transactionalOperator = transactionalOperator;
}
public Mono<Void> transferMoney(Long fromAccount, Long toAccount, Double amount) {
return accountRepository.debit(fromAccount, amount)
.then(accountRepository.credit(toAccount, amount))
.as(transactionalOperator::transactional);
}
}
In this, the TransactionalOperator is injected as a bean, allowing transaction control over reactive sequences. When transferMoney() runs, it starts a transaction by first subtracting money from fromAccount and then adding it to toAccount. Wrapping the sequence with .as(transactionalOperator::transactional) makes sure that both operations run within the same transaction. If any part of the sequence fails, the transaction is rolled back.
How Transaction Context Works in Reactive Transactions
Because R2DBC transactions don’t use thread-local storage, transaction context has to be handled directly using Reactor’s ContextView. This makes it possible to pass transaction details across reactive operators, keeping all database operations in the same transaction as long as they stay within the pipeline.
Example — Passing Transaction Context Explicitly
public Mono<Void> updateAccounts(Long accountId, Double amount) {
return TransactionalOperator.create(r2dbcTransactionManager)
.execute(status -> accountRepository.updateBalance(accountId, amount));
}
Here, execute() takes a function that receives the TransactionStatus and applies transaction control to the operation. The transaction remains valid as long as the function runs within the reactive execution chain.
Managing Reactive Transaction Boundaries and Limitations
Reactive transactions remain active only while the reactive sequence is executing. As soon as the sequence completes or encounters an error, the transaction is either committed or rolled back. Any operation executing outside the active sequence will not be part of the transaction. Because reactive execution does not guarantee sequential operations on the same thread, transaction boundaries must be explicitly defined to prevent unintended transactional states.
Controlling Transaction Scope
For reactive transactions, all operations must be within the same sequence to make sure they share the same transaction context.
Example — Keeping a Transaction Scoped to a Sequence
public Mono<Void> processTransaction(Long accountId, Double amount) {
return transactionalOperator.transactional(
accountRepository.updateBalance(accountId, amount)
.then(accountRepository.logTransaction(accountId, amount))
);
}
- The
transactionalOperator.transactional()method keeps both operations within the same transaction. - If
updateBalance()orlogTransaction()fails, the entire transaction is rolled back.
Now, here’s an example where the transaction boundary is not applied correctly:
public Mono<Void> processTransactionIncorrect(Long accountId, Double amount) {
accountRepository.updateBalance(accountId, amount).subscribe();
return accountRepository.logTransaction(accountId, amount);
}
In this version, updateBalance() executes outside the transaction since .subscribe() runs it immediately. If logTransaction() later fails, the update has already been committed, leading to inconsistent data.
Handling Nested Transactions in Reactive Flows
Traditional blocking transactions allow nested transactions where operations inside a method can either join an existing transaction or start a new one. R2DBC transactions do not support nested transactional calls in the same way, so all dependent operations must be executed within the same transaction context.
Example — Keeping a Nested Transaction Inside a Single Context
public Mono<Void> processWithNestedTransaction(Long userId) {
return transactionalOperator.transactional(
userRepository.updateUser(userId)
.then(logService.logAction(userId))
.then(reportService.generateReport(userId))
);
}
updateUser(),logAction(), andgenerateReport()all run within the same transaction.- The transaction does not complete until the entire sequence is finished.
- If any operation fails, the whole transaction is rolled back.
If a nested transaction is started separately, it can break transaction consistency:
public Mono<Void> processWithBrokenTransaction(Long userId) {
return userRepository.updateUser(userId)
.then(transactionalOperator.transactional(
logService.logAction(userId)
));
}
Because updateUser() completes before logAction() starts, its changes are committed regardless of what happens next. If logAction() fails, only that operation is rolled back, leading to inconsistent data—just like the .subscribe() issue discussed earlier. All operations must share the same transaction scope to prevent this.
R2DBC Transaction Limitations
While R2DBC transactions provide non-blocking database interactions, they also introduce some constraints that affect how they can be used in real-world applications.
No Distributed Transactions (XA Support)
- R2DBC does not support XA transactions, meaning a single transaction cannot span multiple databases.
- If an application needs to interact with multiple databases within a single transactional scope, manual compensation logic must be used to undo changes if one operation fails.
Connection Pinning for Active Transactions
- In a traditional setup, connection pooling helps improve database efficiency.
- In R2DBC, an active transaction pins the connection until it completes, preventing that connection from being reused by other operations during the transaction. This effectively reduces the number of connections available for concurrent transactions.
No Savepoints for Partial Rollbacks
- JDBC transactions allow savepoints, meaning part of a transaction can be rolled back while keeping earlier operations intact.
- R2DBC does not provide this capability, meaning once a rollback happens, everything within the transaction is undone.
Handling Backpressure in Transactional Queries
- Because R2DBC is designed for reactive, event-driven execution, backpressure control must be managed to prevent overloading the database with too many transactions.
- Queries that fetch large amounts of data must use pagination or controlled batching to avoid consuming excessive resources.
Conclusion
Spring Boot’s integration with R2DBC changes how transactions are managed by shifting from thread-local storage to a reactive pipeline. Since transactions exist only within the execution flow of a reactive sequence, they require explicit handling through TransactionalOperator. This model avoids blocking threads but comes with constraints, such as the lack of XA transactions and savepoints. Managing transaction boundaries properly is necessary to keep operations grouped together while handling errors in a way that prevents inconsistent data. Reactive transactions bring a different way of working with relational databases, requiring careful structuring to match the mechanics of non-blocking execution.
- *Spring Boot R2DBC Documentation*
- *Project Reactor Documentation*
- *Spring Data R2DBC Guide*
- *Spring Transaction Management*
Thank you for reading! If you find this article helpful, please consider highlighting, clapping, responding or connecting with me on Twitter/X as it’s very appreciated and helps keeps content like this free!

Spring Boot icon by Icons8
메타데이터
- post_id
- 8e8b3cbae8fc
- slug
- how-spring-boot-handles-reactive-transactions-8e8b3cbae8fc
- url
- https://medium.com/@AlexanderObregon/how-spring-boot-handles-reactive-transactions-8e8b3cbae8fc
- canonical_url
- https://medium.com/@AlexanderObregon/how-spring-boot-handles-reactive-transactions-8e8b3cbae8fc
- author_url
- https://medium.com/@AlexanderObregon
- status
- ok
- fetched_at
- 2026-07-20 20:20:46