skip to content

How is transaction state propagated in reactive transactions, and why not ThreadLocal?

level: middleimportance: must knowfreq 50%

answer

  1. ThreadLocal safe only when one thread owns the call
  2. reactive hops threads -> ThreadLocal lost
  3. Reactor Context = immutable map on the subscription
  4. TransactionContext bound into Context, connection too
  5. forCurrentTransaction() returns a Mono

basics

~20 s

Reactive transaction state travels in the Reactor Context, an immutable map attached to the subscription. ThreadLocal can't be used because reactive operators run on different threads, so thread-bound state would be lost when execution hops threads.

solid answer

~40 s

In imperative Spring, TransactionSynchronizationManager stores the active transaction, bound connection, and synchronizations in ThreadLocal — safe because a blocking call keeps one thread. Reactive pipelines are different: a Mono/Flux chain can execute across many threads as operators schedule work. A ThreadLocal set when the transaction begins would be invisible after any thread switch. So Spring stores a TransactionContext in the Reactor Context, the immutable key/value map that Reactor propagates downstream with the subscription regardless of thread. R2dbcTransactionManager binds the R2DBC Connection into that context; DatabaseClient and repositories retrieve it from the Context, not from the thread. You can reach the reactive synchronization state via TransactionSynchronizationManager.forCurrentTransaction(), which returns a Mono because the state is Context-scoped, not thread-scoped.

code

java · 8 lines
java
// The state is Context-scoped: you read it INSIDE the reactive chain.
Mono<Boolean> inTransaction =
    TransactionSynchronizationManager.forCurrentTransaction()
        .map(TransactionSynchronizationManager::isActualTransactionActive)
        .defaultIfEmpty(false);

// Contrast: imperative version is a plain static (ThreadLocal) read
// boolean active = TransactionSynchronizationManager.isActualTransactionActive();

go deeper

for a junior

Know state is in Reactor Context, not ThreadLocal.

for a middle

Explain why threads hop and how Context rides the subscription; know forCurrentTransaction returns Mono.

for a senior

Detail connection binding via TransactionContext and the break-the-chain pitfall.

for a principal

Reason about Context propagation across library boundaries (MDC, tracing) and failure modes of losing the Context.

## The problem ThreadLocal solves in blocking code A transaction needs a stable place to keep: *which transaction is active*, *the JDBC connection bound to it*, and *the list of synchronizations* to run at commit/rollback. In imperative Spring this lives in **`TransactionSynchronizationManager`**, which is backed by `ThreadLocal`. This is safe because a blocking method call occupies **one thread** from start to finish — set the ThreadLocal at the start, read it anywhere in the call stack, clear it at the end. ## Why that breaks in reactive code A reactive pipeline is a *deferred* description of work. When subscribed, its operators may run on **different threads**: an event-loop thread, then a thread from `publishOn(Schedulers.boundedElastic())`, then back. There is no single owning thread. A `ThreadLocal` written when the transaction begins would simply be **absent** on the thread that later executes a query — the transaction would look non-existent, or worse, a *different* request's ThreadLocal could leak in on a reused pooled thread. ## The solution: Reactor Context Reactor provides the **`Context`** — an *immutable* key/value map that is attached to the **subscription**, not the thread. It propagates **upstream to downstream implicitly** along the operator chain and is available at every operator no matter which thread runs it. It is the reactive replacement for ThreadLocal. Spring stores a **`TransactionContext`** (holding the current `ReactiveTransaction`, the bound connection, and synchronizations) inside this Reactor Context under a well-known key, managed by `TransactionContextManager`. Because the Context rides with the subscription, the transaction is visible on whatever thread each step happens to run. ## How R2dbcTransactionManager uses it When a reactive transaction starts, `R2dbcTransactionManager` obtains an R2DBC `Connection`, sets autocommit off / begins the transaction, and **binds the connection into the TransactionContext**. `DatabaseClient` and Spring Data R2DBC repositories then call `ConnectionFactoryUtils.getConnection(...)`, which looks up the bound connection **from the Context** (via the reactive `TransactionSynchronizationManager`) rather than opening a fresh one. That is what makes all statements in the pipeline run on the *same* transactional connection. ## Reaching the state yourself ```java Mono<Void> work = TransactionSynchronizationManager.forCurrentTransaction() .flatMap(sync -> { boolean active = sync.isActualTransactionActive(); // register a synchronization, inspect resources, etc. return Mono.empty(); }); ``` Note it returns a **`Mono`** — proof the state is Context-scoped: you can only read it *inside* the reactive chain where the Context exists, not by a plain static getter as in the blocking API. ## Gotchas - **Don't break the chain.** If you subscribe to an inner publisher independently (e.g. `.subscribe()` inside an operator), it starts with an *empty* Context and loses the transaction. Compose with `flatMap`/`then` so the Context flows through. - **Blocking bridges lose it.** Wrapping blocking JDBC that reads ThreadLocal won't see the reactive Context transaction, and vice versa. - **Context propagation to ThreadLocal-based libraries** (logging MDC, Micrometer) needs the `context-propagation` library; the transaction itself, though, is handled internally by Spring.

  • Why does TransactionSynchronizationManager.forCurrentTransaction() return a Mono in the reactive API?
    Because the transaction state lives in the Reactor Context, which only exists inside the subscription. Returning a Mono lets Spring read it from the Context at subscription time instead of from a thread-bound static field.
  • What happens if you call .subscribe() on an inner publisher inside a transactional flow?
    That inner subscription starts with an empty Context, so it loses the transaction and its statements run outside it (or on a new connection). Compose with flatMap/then to keep the Context flowing.

saying these in an interview costs you the question

  • Saying reactive transactions use ThreadLocal on the event-loop thread
  • Claiming the transaction follows the thread rather than the subscription
  • Thinking you can read reactive tx state with a plain static getter
  • Assuming a nested independent subscribe() still sees the transaction

context