skip to content

Async / New-Thread Tx Boundary Loss

Transaction state is bound to the thread, so work handed to @Async or a spawned thread runs in its own transaction and survives the caller's rollback. A favourite question because the resulting inconsistency is invisible in tests that never fail.

part ofSpring Frameworkoverview, primer and where to startread it →
on this pageshow

questions

5

If you call an @Async method from within a @Transactional method, does the async code run inside the same transaction?

level: juniorimportance: must knowfreq 35%

answer

  1. Transaction is thread-bound (ThreadLocal)
  2. @Async = different pool thread
  3. New/independent tx, not caller's
  4. Outer rollback won't undo it
  5. Connection can't be shared across threads

basics

~20 s

No. @Async runs the method on a different thread, and Spring ties a transaction to the thread that started it. So the async code runs in its own separate transaction (or none), not the caller's.

solid answer

~40 s

No. Spring's declarative transactions are thread-bound: the JDBC Connection / JPA EntityManager for the active transaction is stored in a ThreadLocal on the thread that opened it. @Async hands the method to a different thread from a TaskExecutor pool, and that thread has none of the caller's thread-local transaction state. So the async method starts fresh — if it (or a method it calls) is @Transactional it opens a brand-new independent transaction; otherwise it runs with no transaction. The practical consequences: the async work commits or rolls back on its own timeline, it is not rolled back if the outer method fails, and it cannot see the caller's still-uncommitted changes. Treat the async boundary as a hard transaction boundary.

code

java · 22 lines
java
@Service
public class OrderService {

    @Transactional
    public void placeOrder(Order order) {
        orderRepo.save(order);      // tx A, on THIS thread
        auditService.recordAsync(); // hops to a pool thread -> tx B (or none)
        throw new RuntimeException("boom"); // rolls back tx A only
    }
}

@Service
public class AuditService {
    @Async
    @Transactional
    public void recordAsync() {
        // Runs on a TaskExecutor thread; no thread-bound tx inherited,
        // so this opens a fresh, independent transaction that commits
        // regardless of the "boom" above.
        auditRepo.save(new AuditEntry("order placed"));
    }
}

go deeper

for a junior

Must know: @Async = different thread = separate transaction; outer rollback does not undo it.

for a middle

Should be able to name ThreadLocal / TransactionSynchronizationManager as the reason.

for a senior

Connects it to entity hand-off, LazyInitializationException, and the AFTER_COMMIT fix.

for a principal

Frames it as a deliberate design (one connection per tx) and knows the reliable-delivery patterns.

## The core idea Spring's `@Transactional` is **thread-bound**. When a transaction starts, Spring stores the transactional resources — the JDBC `Connection` (or JPA `EntityManager`/`Session`) — in a `ThreadLocal` managed by `TransactionSynchronizationManager`. Every repository/`JdbcTemplate`/`EntityManager` call on that same thread looks up that thread-local resource, which is how they all join the one transaction. `@Async` (enabled by `@EnableAsync`) makes Spring's proxy submit the method to a `TaskExecutor`, so it runs on a **different thread** from a pool. `ThreadLocal` values are per-thread and are **not** copied to that pool thread. Therefore the async method sees an empty transaction context. ## What actually happens on the async thread - If the async method (or something it calls through a Spring proxy) is `@Transactional` with the default `Propagation.REQUIRED`, since there is *no* existing transaction on this thread, a **new** transaction is opened. - If nothing on the async path is transactional, the DB work runs **non-transactionally** (auto-commit per statement). - Either way it is **independent** of the caller's transaction. ## Consequences to remember 1. **No shared rollback.** If the outer `@Transactional` method throws and rolls back, the async insert has already committed (or will) and stays in the database. 2. **No dirty reads across the boundary.** The async thread runs in a different transaction, so with normal isolation it cannot read rows the caller has written but not yet committed. 3. **Detached entities / `LazyInitializationException`.** If you pass a JPA entity to the async method, the persistence context that owned it lives on the caller's thread and may already be closed; touching a lazy association on the async thread throws `LazyInitializationException`. ## Why it's designed this way A transaction ≈ one physical DB connection. Two threads cannot safely share one connection concurrently, so Spring deliberately does not propagate the transaction across threads. This is the same reason a manually spawned `new Thread(...)`/`ExecutorService` task also loses the transaction. ## The fix in one line Do not rely on the outer transaction covering async work. Kick off the async work **after** the transaction commits (e.g. `@TransactionalEventListener(phase = AFTER_COMMIT)`), and pass **IDs, not entities**, re-fetching inside the async thread's own transaction.

  • Does a manually created `new Thread(...)` behave differently from @Async here?
    No. Both run on a thread that doesn't carry the caller's thread-local transaction, so both lose the transaction context the same way. @Async just uses a managed TaskExecutor pool instead of a raw thread.
  • If the async method has no @Transactional at all, what transaction does its DB write use?
    None — it runs non-transactionally (statement-level auto-commit). It still isn't part of the caller's transaction.

saying these in an interview costs you the question

  • Thinking @Async 'inherits' the caller's transaction
  • Assuming an outer rollback will undo the async insert
  • Believing the async thread can read the caller's uncommitted rows

context

open as a page

Your @Transactional service saves an entity, kicks off an @Async task that inserts an audit row, then throws and rolls back. Is the audit row rolled back? Walk through what happens.

level: middleimportance: must knowfreq 45%

basics

~20 s

No, the audit row is not rolled back. The async task ran on another thread in its own transaction and committed independently, so the outer rollback (which only affects the caller's thread/transaction) leaves it in the database.

open as a page

Explain the mechanism that makes a new thread (e.g. from @Async) lose the caller's transaction context.

level: middleimportance: should knowfreq 40%

basics

~10 s

Spring stores the current transaction's connection in a ThreadLocal (via TransactionSynchronizationManager). ThreadLocals are per-thread, so a new/pool thread has none, and thus no active transaction to join.

open as a page

You pass a managed JPA entity into an @Async method and get LazyInitializationException plus intermittent stale reads. Explain the root cause and the correct way to hand work across the async/transaction boundary.

level: seniorimportance: should knowfreq 35%

basics

~20 s

The entity is tied to the caller's persistence context, which lives on the caller's thread and may already be closed. On the async thread the entity is detached, so lazy associations throw. Pass the entity's ID instead and re-load it inside the async method's own transaction.

open as a page

Design a reliable 'perform this background action only if the transaction commits' mechanism. Discuss the async/transaction boundary, ordering, failure modes, and delivery guarantees.

level: principalimportance: should knowfreq 25%

basics

~20 s

Publish a domain event inside the transaction; handle it with @TransactionalEventListener(AFTER_COMMIT) so it runs only on commit. For at-least-once reliability across crashes, write an outbox row in the same transaction and have a separate dispatcher deliver it after commit.

open as a page