skip to content

Why does ThreadLocal-bound context (SecurityContextHolder, MDC, transaction context) break under WebFlux's event-loop model, and how does Reactor solve it?

level: seniorimportance: must knowfreq 65%

answer

  1. ThreadLocal = value pinned to a Thread
  2. WebFlux hops threads + shares threads → ThreadLocal wrong/null
  3. Reactor Context = immutable map on the Subscription
  4. Flows upstream: contextWrite below, read above
  5. ReactiveSecurityContextHolder + context-propagation for MDC

basics

~20 s

ThreadLocal stores data on a specific thread, and the thread-per-request model keeps one thread for the whole request. In WebFlux a request hops across event-loop threads, so ThreadLocal values are lost. Reactor replaces it with a Context carried inside the reactive chain.

solid answer

~40 s

A ThreadLocal binds a value to a single thread; the servlet model works because one thread runs the whole request, so SecurityContextHolder, SLF4J MDC, and transaction bindings stay attached. WebFlux breaks this: a request's pipeline suspends on I/O and resumes on *different* event-loop threads, and one thread multiplexes many requests — so ThreadLocal lookups return the wrong value or nothing. Reactor's fix is the **Reactor Context**: an immutable key/value map that rides *inside the subscription*, flowing upstream through the operator chain regardless of which thread executes. Spring Security's reactive stack uses ReactiveSecurityContextHolder (backed by Context) instead of the ThreadLocal SecurityContextHolder; you read it via `ReactiveSecurityContextHolder.getContext()` or the `Mono.deferContextual` API. For logging/MDC, the `context-propagation` library bridges Reactor Context to ThreadLocals at the right moments.

code

java · 23 lines
java
// WRONG in WebFlux: SecurityContextHolder is ThreadLocal-backed.
// On the event loop this returns null or another request's principal.
@GetMapping("/me/broken")
Mono<String> broken() {
    var auth = SecurityContextHolder.getContext().getAuthentication(); // bug!
    return Mono.just(auth.getName());
}

// RIGHT: read auth from the Reactor-Context-backed reactive holder.
@GetMapping("/me")
Mono<String> me() {
    return ReactiveSecurityContextHolder.getContext()
            .map(ctx -> ctx.getAuthentication().getName());
}

// Writing/reading your own Context value (flows upstream):
Mono<String> tenantAware() {
    return Mono.deferContextual(ctx -> Mono.just(ctx.get("tenantId")))
            .contextWrite(ctx -> ctx.put("tenantId", "acme")); // written below, read above
}

// Boot 3 / Reactor 3.5: bridge Context <-> ThreadLocal (MDC, tracing) automatically
// Hooks.enableAutomaticContextPropagation();  // typically enabled by Spring Boot

go deeper

for a junior

Understand that ThreadLocal ties data to a thread and WebFlux doesn't keep one thread per request, so it breaks.

for a middle

Name the affected mechanisms (SecurityContextHolder, MDC, transactions) and know Reactor Context is the replacement carried in the chain.

for a senior

Explain the two broken assumptions (thread hopping + thread sharing), the upstream flow of contextWrite, and ReactiveSecurityContextHolder.

for a principal

Treat ambient cross-cutting state as a migration-cost design problem; wire context-propagation for tracing/MDC and know the wrong-principal security risk.

## What a ThreadLocal actually is `ThreadLocal<T>` stores a value in a map hanging off the `Thread` object itself, so `threadLocal.get()` returns whatever *the current thread* stored. It's a hidden, ambient, thread-scoped variable. Java frameworks lean on it heavily for **request-scoped ambient state**: - **Spring Security:** `SecurityContextHolder` holds the authenticated `Authentication` in a ThreadLocal by default. - **Logging:** SLF4J **MDC** (Mapped Diagnostic Context) stores correlation IDs / user IDs per thread so every log line can include them. - **Transactions:** `TransactionSynchronizationManager` binds the JDBC `Connection`/`EntityManager` to the thread so `@Transactional` code shares one transaction. - **Request scope:** `RequestContextHolder`. ## Why it works in thread-per-request and breaks in WebFlux In Spring MVC, **one thread runs the entire request** start to finish. So anything stored in a ThreadLocal at the start (the authenticated user, the trace ID) is still there in the controller, service, and repository — same thread, same ThreadLocal map. WebFlux violates both assumptions ThreadLocal depends on: 1. **A request is not pinned to one thread.** The pipeline suspends when it hits non-blocking I/O and *resumes on whatever event-loop thread is free* when data returns — often a different thread than it started on. A value set on thread A is invisible after the hop to thread B. 2. **One thread serves many requests, interleaved.** An event-loop thread multiplexes thousands of connections. A per-thread ThreadLocal can't represent per-*request* state when requests share threads — you'd read another request's user. This is a correctness/security bug, not just a nuisance. So naively calling `SecurityContextHolder.getContext().getAuthentication()` inside a WebFlux handler returns `null` or, worse, the wrong principal. ## Reactor's replacement: the Context Reactor introduces **`Context`** (and `ContextView`) — an **immutable** key/value map that is attached to the **Subscription**, not to any thread. Because it travels *inside* the reactive chain, it follows the request across every thread hop. Mechanics worth knowing: - The Context flows **bottom-up (upstream)**: `subscriberContext`/`contextWrite` is written at subscription time and read by operators *above* it. This trips people up — you write it "below" and read it "above." - You read it with `Mono.deferContextual(ctx -> ...)` / `Flux.deferContextual(...)` or the operator `.contextWrite(ctx -> ctx.put(key, value))` to add entries. ### Spring's reactive equivalents (real APIs) - **Security:** `ReactiveSecurityContextHolder.getContext()` returns a `Mono<SecurityContext>` backed by the Reactor Context, not a ThreadLocal. The `@AuthenticationPrincipal` argument resolver and method security (`@PreAuthorize`) work off this reactive holder in WebFlux. - **Logging/MDC:** the **`io.micrometer:context-propagation`** library plus Reactor's `Hooks.enableAutomaticContextPropagation()` (Reactor 3.5+/Boot 3) bridge Reactor Context values into ThreadLocals (MDC, Micrometer tracing) precisely around the code that reads them, then clear them — restoring correct logs and distributed tracing. - **Transactions:** reactive transactions use `TransactionalOperator` / `@Transactional` backed by R2DBC's reactive transaction manager, which binds the connection to the Reactor Context rather than a ThreadLocal. ## Gotchas - **Silent wrong-value bugs.** ThreadLocal on the event loop may return a *stale or foreign* value rather than throwing — a security landmine. - **Context write direction.** `contextWrite` affects operators *upstream* of it; placing it in the wrong spot yields an empty read. - **Manual bridging when offloading.** If you `subscribeOn(boundedElastic())` to run blocking code that reads MDC, you must ensure context propagation is enabled or manually copy the values. - **Don't reintroduce ThreadLocal habits.** Passing the user as a method parameter or through the Reactor Context is the reactive way; reaching for `SecurityContextHolder` (the blocking one) in WebFlux is a classic mistake. ## When it matters Any cross-cutting concern that was ambient in MVC — auth, tracing/correlation IDs, tenant, transactions — must be redesigned around the Reactor Context in WebFlux. This is one of the biggest hidden migration costs from MVC to WebFlux.

  • In which direction does the Reactor Context propagate through the operator chain, and why does that surprise people?
    It propagates upstream (bottom-up): you write it with contextWrite/subscriberContext at the bottom, and operators above it read it. It surprises people because data flows downstream but Context flows the opposite way — placed 'below' the readers.
  • How do you keep MDC-based correlation IDs working in logs under WebFlux?
    Use the micrometer context-propagation library with Reactor's automatic context propagation (Hooks.enableAutomaticContextPropagation, on by default in Boot 3). It copies Reactor Context values into the MDC ThreadLocal around the logging call and clears them after.

saying these in an interview costs you the question

  • Using SecurityContextHolder (blocking ThreadLocal holder) in WebFlux handlers
  • Thinking a WebFlux request stays on one thread so ThreadLocal is fine
  • Assuming ThreadLocal on the event loop simply returns null (it can return another request's value)
  • Believing Reactor Context flows downstream like data

context