skip to content

How is a reactive R2DBC transaction propagated across operators and thread hops when there is no ThreadLocal? What breaks that propagation?

level: seniorimportance: should knowfreq 45%

answer

  1. Reactor Context = subscription-scoped, not thread
  2. flows upstream from subscriber at subscribe time
  3. thread hop keeps the connection
  4. self .subscribe() = new empty Context = no tx
  5. blocking JDBC = different connection, not enlisted

basics

~20 s

The transactional connection is stored in the Reactor subscriber Context, which travels with the reactive chain regardless of which thread runs each step. Anything that starts a new, separate subscription (a fresh subscribe, a detached publisher, or blocking code) escapes that Context and the transaction.

solid answer

~40 s

Blocking Spring binds a transaction to a ThreadLocal; reactive code hops threads, so R2DBC instead stores the transaction's `Connection` in the **Reactor `Context`** — an immutable key/value map that Reactor propagates upstream through the operator chain during subscription. Because the Context is attached to the *subscription*, not the thread, `publishOn`/`subscribeOn` thread switches keep the same transaction. Propagation breaks when you leave that subscription: calling `.subscribe()` yourself mid-flow (new, contextless subscription), returning a publisher that is assembled/subscribed independently, using `Mono.fromCallable` around a blocking JDBC call (different resource, not enlisted), or bridging to imperative code. Since Reactor 3.5+/Spring 6, `Context`↔`ThreadLocal` can be bridged with `ContextSnapshot`/`@ContextSnapshot` for observability, but the transactional connection still must flow inside one reactive chain subscribed once by the framework.

code

java · 16 lines
java
// GOOD: one chain, thread hops are fine, tx propagates via Context
@Transactional
public Mono<Void> ok(long id) {
    return repo.debit(id, TEN)
        .publishOn(Schedulers.parallel())   // thread switch, still same tx
        .then(repo.credit(id, TEN))
        .then();
}

// BAD: inner subscribe() starts a NEW, contextless subscription -> outside the tx
@Transactional
public Mono<Void> broken(long id) {
    return repo.debit(id, TEN)
        .doOnSuccess(v -> repo.credit(id, TEN).subscribe()) // NOT enlisted, may run after commit
        .then();
}

go deeper

for a junior

Know the transaction rides in the Reactor Context, not a ThreadLocal, so keep everything in one chain.

for a middle

Explain that Context is subscription-scoped and survives thread hops, unlike ThreadLocal.

for a senior

Enumerate precisely what breaks propagation (self-subscribe, detached publishers, blocking bridges) and why.

for a principal

Reason about Context↔ThreadLocal bridging for observability/security, event-loop safety, and conventions that keep the transactional subscription intact across a codebase.

**Why ThreadLocal fails reactively.** Classic Spring `@Transactional` stores the transaction (and its JDBC `Connection`) in a `ThreadLocal` via `TransactionSynchronizationManager`. A reactive pipeline may execute operator A on thread T1 and operator B on T2 (`publishOn`, work-stealing schedulers, non-blocking driver callbacks). A ThreadLocal set on T1 is invisible on T2, so binding there is unusable. **The Reactor `Context`.** Reactor's `Context` is an **immutable, subscription-scoped** key/value map. Unlike ThreadLocal it flows **from the subscriber upstream** to the source at subscribe time, and every operator sees it regardless of the executing thread. Spring's reactive transaction infrastructure writes the current transaction's `Connection`/resource holder into this Context (Spring's `TransactionContext` / `ReactiveTransactionSynchronizationManager` machinery). When a repository operator needs a connection, it reads it from the Context of the ongoing subscription. Because the Context is bound to the subscription and not the thread, **thread hops preserve the transaction** — this is the whole reason reactive transactions work. **Consequences / correct usage:** - Keep the entire unit of work in **one chain, subscribed once** by the framework (WebFlux subscribes your controller's returned `Mono`/`Flux`). The `@Transactional` interceptor or `TransactionalOperator` seeds the Context for that subscription. - `publishOn(Schedulers.parallel())` etc. inside the chain are fine — same subscription, same Context, same transaction/connection. **What breaks propagation:** 1. **Calling `.subscribe()` yourself inside the method.** That starts a *new* subscription with a fresh, empty Context — it does not inherit the transactional connection. The inner work runs outside the transaction (and often after the outer chain has already committed). 2. **Detached/independently subscribed publishers** (e.g. firing a repository call in a side-effect that isn't chained via `flatMap`/`then`). Not part of the transactional subscription → not enlisted. 3. **Blocking JDBC inside `Mono.fromCallable`/`publishOn(boundedElastic)`.** Even if you block-bridge a JDBC call, it uses a *different* connection managed by ThreadLocal-based JDBC, not the R2DBC connection in the Context — two separate transactions, and you've also blocked a thread. 4. **Bridging to imperative code / crossing into a non-reactive layer** loses the Context unless explicitly restored. 5. **Manually rewriting the Context** (`contextWrite`) in a way that drops the transaction key. **Context ↔ ThreadLocal bridging.** Since Reactor 3.5 / Spring Framework 6 there is `ContextSnapshot` (micrometer-context-propagation) to bridge ThreadLocal values (observability, security, MDC) to/from the Reactor Context. This is primarily for cross-cutting context (tracing, `ReactiveSecurityContextHolder`), not a substitute for keeping the transaction inside the reactive chain. **Gotchas:** - A `@Transactional` method returning `void` never establishes the Context around the async work — no transaction. - Self-invocation still bypasses the proxy (same as imperative). - Reading `TransactionSynchronizationManager` (the blocking, ThreadLocal one) inside a reactive flow gives wrong/empty results — the reactive equivalent lives in the Context. - Don't `block()` inside the chain to 'get' a value — it detaches and can deadlock the event loop.

  • Why does publishOn(Schedulers.parallel()) NOT lose the transaction, but a manual .subscribe() does?
    publishOn only switches which thread executes downstream operators within the same subscription — the Reactor Context (holding the transactional connection) is attached to the subscription, so it travels along. A manual .subscribe() creates a brand-new subscription with its own empty Context that never received the transaction key, so that work runs outside the transaction.
  • Can you read the current reactive transaction with TransactionSynchronizationManager the way you would in blocking Spring?
    No. That class is ThreadLocal-based and reflects blocking JDBC/JPA synchronization; in a reactive flow it's empty or wrong. The reactive transaction state lives in the Reactor Context (Spring's TransactionContext), accessible only within the subscription — typically you never touch it directly.

saying these in an interview costs you the question

  • Thinking a thread switch (publishOn) loses the reactive transaction
  • Calling .subscribe() inside a transactional method and expecting enlistment
  • Block-bridging JDBC into an R2DBC transaction and assuming one shared transaction
  • Using ThreadLocal-based TransactionSynchronizationManager in reactive code

context