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.
answer
- Entity managed only while its EntityManager/thread context is open
- Async thread = detached entity = LazyInitializationException
- Concurrent run = read of uncommitted row
- Pass id or DTO, not the entity
- AFTER_COMMIT + REQUIRES_NEW, then re-findById
basics
~20 sThe 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.
solid answer
~50 sA JPA entity is bound to the `EntityManager`/persistence context of the transaction that loaded it, and that context is thread-scoped. When `@Async` moves execution to a pool thread, that thread has no active persistence context, and the caller's context may already be closed — so the entity is **detached**. Reading an untouched lazy association then throws `LazyInitializationException`. The stale/absent reads come from the concurrency race: the async thread runs while the caller's transaction is still open, so `findById` may not see the yet-uncommitted row. Correct hand-off: pass immutable data — the primary key (or a fully-initialized DTO) — never the managed entity. Trigger the async work from `@TransactionalEventListener(phase = AFTER_COMMIT)` so it starts only after the caller commits, and inside the async method open its own `@Transactional` and re-`findById` the entity so it's managed by the async thread's own context.
code
java · 16 lines// WRONG: hand the managed entity to another thread
@Async
public void notify(Order order) {
order.getLines().size(); // LazyInitializationException: no Session on this thread
}
// RIGHT: pass the id, fire after commit, re-load in a fresh tx
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onOrderPlaced(OrderPlaced event) {
Order order = orderRepo.findById(event.orderId()).orElseThrow();
order.getLines().forEach(line -> emailService.queue(line)); // managed here
}
public record OrderPlaced(Long orderId) {}go deeper
May only know 'don't pass entities across threads, pass ids'.
Explains detached entity + LazyInitializationException and the re-fetch fix.
Adds the visibility race, AFTER_COMMIT + REQUIRES_NEW, and rejects OSIV/lazy-no-trans hacks.
Chooses between DTO mapping, re-fetch, synchronous, and outbox based on atomicity/delivery needs.
## Root cause 1 — detached entity / persistence-context scope In JPA, a loaded entity is **managed** only while its `EntityManager` (the persistence context) is open. In Spring, that context is tied to the transaction and therefore to the **thread** (bound in `TransactionSynchronizationManager`). Lazy associations (`@OneToMany`, `@ManyToOne(fetch = LAZY)`, lazy `@Basic`) are backed by proxies that call back into the persistence context on first access. When you pass the entity to an `@Async` method: - Execution moves to a pool thread with **no** open persistence context. - The caller's transaction/context is likely already closing or closed. - The entity is now **detached**; touching an un-fetched lazy proxy → `org.hibernate.LazyInitializationException: could not initialize proxy - no Session`. ## Root cause 2 — the visibility race `@Async` returns immediately and runs **concurrently** with the caller. If the async code does `repo.findById(id)` while the caller's transaction is still open and uncommitted, with normal `READ_COMMITTED` isolation it won't see the new/updated row, producing intermittent `empty`/stale results depending on timing. ## The correct hand-off recipe 1. **Pass IDs or immutable DTOs, not managed entities.** Primitives/records carry no persistence-context dependency. 2. **Start after commit.** Use `@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)` so the work fires only once the caller's data is durably committed and visible. 3. **Open a fresh transaction on the async thread.** Combine `@Async` with `@Transactional` (or `REQUIRES_NEW` if the listener isn't async) and re-`findById` — now the entity is managed by *this* thread's context and lazy loading works. ```java @Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @Transactional(propagation = Propagation.REQUIRES_NEW) public void handle(OrderPlaced e) { Order o = orderRepo.findById(e.orderId()).orElseThrow(); o.getLines().forEach(...); // lazy load works: managed here } ``` ## Alternatives when you can't re-fetch - **Initialize eagerly before hand-off**: fetch-join or `Hibernate.initialize(...)` everything you need, then pass a detached but fully-populated object. Fragile — easy to miss an association — so IDs+refetch is preferred. - **Map to a DTO inside the transaction** and pass the DTO. Clean and explicit; the async thread never touches JPA lazy state. ## Ordering gotcha Even `@TransactionalEventListener` **without** `@Async` runs synchronously on the committing thread but *after* commit, where there is no active transaction — so DB writes there need `REQUIRES_NEW`. Adding `@Async` additionally moves it to a pool thread (its own transaction). Don't confuse 'after commit' (ordering) with 'async' (threading); you often want both. ## Why not just make the whole thing synchronous? If the follow-up must be atomic with the business change, keep it in the same transaction (no async, no events) or use a **transactional outbox** so the follow-up intent is committed atomically and dispatched later exactly-/at-least-once.
- Why not just set hibernate.enable_lazy_load_no_trans (or Open-Session-In-View) to dodge the exception?Both are anti-patterns for this: enable_lazy_load_no_trans silently opens a temporary session per lazy access (N+1, non-transactional reads, hides the real bug), and OSIV keeps a session open on web threads — neither helps a pool thread reliably. Re-fetch inside a proper transaction instead.
- If you pass a DTO instead of an id, do you still need AFTER_COMMIT?The DTO fixes the LazyInitializationException, but you still want AFTER_COMMIT so the async side effect (email, webhook) doesn't fire when the outer transaction ultimately rolls back — otherwise you notify about something that never persisted.
saying these in an interview costs you the question
- Solving LazyInitializationException with enable_lazy_load_no_trans or OSIV instead of re-fetching
- Passing the managed entity and expecting lazy loading to work on the async thread
- Assuming re-fetch before commit will see the row
- Confusing 'after commit' with 'async' — thinking one implies the other