skip to content

Why does transaction context (and TransactionSynchronizationManager state) fail to carry over to @Async, executor, or reactive threads — and how do you handle it?

level: principalimportance: should knowfreq 35%

answer

  1. ThreadLocal = single-thread transaction
  2. worker thread: active=false, nothing bound
  3. async @Transactional = its own new tx
  4. LazyInitializationException on handoff
  5. reactive uses Reactor Context not TSM

basics

~20 s

Because TSM stores everything in ThreadLocals tied to the calling thread. A new thread starts empty, so isActualTransactionActive() is false and no connection is bound. You must start a fresh transaction on that thread or hand off after commit.

solid answer

~50 s

TransactionSynchronizationManager keeps the active transaction, bound resources, and synchronizations in thread-local storage. That state is intentionally not shared or copied, so the moment work jumps to another thread — `@Async`, a raw `ExecutorService`, a `CompletableFuture` on a different pool, or a reactive scheduler — the new thread's TSM is empty: `isActualTransactionActive()` returns false and no Connection/EntityManager is bound. Any `@Transactional` on the async method simply starts its *own* new transaction on the worker thread; it does not join the caller's. Practical consequences: lazy JPA associations fail with `LazyInitializationException` on the worker, and you can't 'continue' the caller's transaction. The right patterns are: keep the whole unit of work on one thread; or split — commit first, then hand the *result* to the async worker (often via `@TransactionalEventListener(AFTER_COMMIT)`); or explicitly open a new transaction inside the async task. For reactive stacks, TSM doesn't apply at all — you use `TransactionalOperator`/`R2DBC` with context propagation.

code

java · 21 lines
java
// ANTI-PATTERN: expecting the caller's transaction to extend into async work
@Transactional
public void bad(Long id) {
    Order o = repo.findById(id).orElseThrow();
    CompletableFuture.runAsync(() -> o.getLineItems().size()); // LazyInitializationException
}

// PATTERN: commit first, then hand off after commit
@Transactional
public void good(Long id) {
    repo.save(new Order(id));
    // fires only after this tx commits; runs on its own thread/transaction
    events.publishEvent(new OrderPlaced(id));
}

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onCommitted(OrderPlaced e) {
    // fresh thread + fresh transaction; re-load by id, never a lazy entity
    orderProcessor.process(e.id());
}

go deeper

for a junior

Know that a new thread doesn't see the caller's transaction.

for a middle

Explain that it's because state is thread-local and that async @Transactional starts a new transaction; watch for LazyInitializationException.

for a senior

Prescribe commit-then-handoff via AFTER_COMMIT and per-task transactions; predict the exact failure modes.

for a principal

Reason about the ThreadLocal design trade-off, reactive context propagation, and generalize to SecurityContext/MDC; design a reliable async pipeline (outbox/AFTER_COMMIT) around it.

## Root cause: ThreadLocal, by design Every piece of transaction state — `isActualTransactionActive()`, the bound resources map (`DataSource -> ConnectionHolder`), the registered `TransactionSynchronization` list, current name/isolation/read-only — lives in `static ThreadLocal` fields on `TransactionSynchronizationManager`. ThreadLocals are, by definition, invisible to other threads. So a transaction is fundamentally a **single-thread** construct in Spring's imperative model. This is not a limitation to fix; it mirrors the JDBC reality that a `Connection` is used by one thread at a time. ## What breaks when you cross a thread boundary Consider: ```java @Transactional public void process(Long id) { Order o = repo.findById(id).orElseThrow(); executor.submit(() -> { // DIFFERENT THREAD boolean active = TransactionSynchronizationManager.isActualTransactionActive(); // false! o.getLineItems().size(); // LazyInitializationException — no EntityManager bound here }); } ``` - `isActualTransactionActive()` on the worker is **false**: the worker's TSM is empty. - No `ConnectionHolder`/`EntityManagerHolder` is bound, so JPA lazy loading throws `LazyInitializationException`; `DataSourceUtils.getConnection` would pull a *fresh, non-transactional* Connection. - If the worker method is itself `@Transactional`, it opens a **brand-new independent transaction** — it does not extend the caller's. Its commit/rollback is unrelated to the caller's. - The caller may commit or roll back **concurrently** with the worker, so you can even read data mid-flight or see the worker act on rolled-back state. ## Same problem with @Async and CompletableFuture `@Async` runs on a `TaskExecutor` thread; `CompletableFuture.supplyAsync(...)` without an explicit executor runs on the common `ForkJoinPool`. Both are different threads, both start with empty TSM. Wrapping the async method in `@Transactional` gives it *its own* transaction, not the caller's. ## Correct patterns 1. **Keep the unit of work on one thread.** Simplest and safest; only go async when you truly need to. 2. **Commit-then-handoff.** Finish the transaction, then dispatch the async work with the *data you need* (DTOs/ids), not lazy entities. Idiomatic form: `@TransactionalEventListener(phase = AFTER_COMMIT)` publishing an event; the listener (which may itself be `@Async`) runs after the commit is durable. 3. **Open a fresh transaction inside the async task.** Give the async component its own `@Transactional` boundary and re-load what it needs by id. Accept that it's a *separate* transaction with separate atomicity. 4. **Propagate context explicitly when you must.** Spring's `TaskDecorator` (e.g. on `ThreadPoolTaskExecutor.setTaskDecorator`) can copy selected context to the worker thread, but copying a live JDBC transaction across threads is unsafe and not supported — copy *diagnostic* context (MDC, SecurityContext), not the transaction. ## Reactive stacks WebFlux/R2DBC do **not** use `TransactionSynchronizationManager` at all — there is no thread affinity to hang a ThreadLocal on. Instead transactions live in the **Reactor `Context`**, driven by `TransactionalOperator` or reactive `@Transactional` with `ReactiveTransactionManager` (e.g. `R2dbcTransactionManager`). There's a reactive analogue, `TransactionSynchronizationManager` in `org.springframework.transaction.reactive` returning a `Mono` of the context. Mixing the imperative `support` TSM into reactive code is a classic mistake — it'll read as 'no transaction' because the context is in the subscriber's Reactor `Context`, not a ThreadLocal. ## Security/observability corollary The same ThreadLocal limitation affects `SecurityContextHolder` and MDC logging — which is why Spring provides `DelegatingSecurityContextExecutor`, MDC task decorators, and Micrometer context propagation. Understanding TSM's thread-locality generalizes to all of these. ## Interview framing The strong answer names the ThreadLocal root cause, predicts the exact failure (`isActualTransactionActive()==false`, `LazyInitializationException`, independent transaction), and reaches for AFTER_COMMIT handoff or per-task transactions rather than trying to 'share' the transaction.

  • If an @Async method is annotated @Transactional, does it join the caller's transaction?
    No. It runs on a different thread with empty TSM, so it starts its own independent transaction. Commit/rollback of the async transaction is unrelated to the caller's.
  • Why do reactive (WebFlux/R2DBC) transactions not rely on TransactionSynchronizationManager?
    Reactive execution hops threads freely, so there's no stable thread to attach a ThreadLocal to. Transaction state lives in the Reactor Context instead, managed via ReactiveTransactionManager/TransactionalOperator (there is a separate reactive TSM returning a Mono).
  • You must pass some context to worker threads. What's safe to copy and what isn't?
    Copy diagnostic/identity context — MDC, SecurityContext, tracing — via a TaskDecorator. Do NOT copy a live JDBC transaction/Connection; a Connection isn't safe to share across threads and Spring doesn't support it.

saying these in an interview costs you the question

  • Assuming @Async inherits/continues the caller's transaction
  • Passing lazy JPA entities to another thread and expecting lazy loading to work
  • Trying to copy the bound Connection to a worker thread with a TaskDecorator
  • Using the imperative support.TransactionSynchronizationManager inside WebFlux/R2DBC code

context