Java ScopedValue context propagation virtual threads
Md Sanwar Hossain - Senior Software Engineer
Md Sanwar Hossain

Senior Software Engineer · Java · Spring Boot · JVM Performance

Core Java March 19, 2026 18 min read Java Performance Engineering Series

Java ScopedValue: Replacing ThreadLocal for Safe Context Propagation in Virtual Threads

ThreadLocal has been Java's mechanism for per-thread context — request IDs, user sessions, security principals — since Java 1.2. But ThreadLocal was designed for platform threads, where one thread handles one request. Virtual threads break that assumption: a virtual thread can be remounted on different carrier threads, thread pools are replaced by simple executors, and thread reuse patterns create silent ThreadLocal data bleed between requests. Java 21's ScopedValue (JEP 446, now standard in Java 23 via JEP 487) is the production-safe replacement.

Table of Contents

  1. ThreadLocal's Four Production Failure Modes
  2. The ScopedValue Programming Model
  3. Migrating Spring Boot Request Context
  4. Child Thread Inheritance and StructuredTaskScope
  5. Rebinding: Scoped Override Without Mutation
  6. Production Failure Scenarios
  7. Performance: ScopedValue vs ThreadLocal
  8. Trade-offs and Migration Strategy
  9. Key Takeaways

1. ThreadLocal's Four Production Failure Modes

Failure 1 — Memory leaks in thread pools. A classic problem: a web framework stores a user session object in ThreadLocal during request handling and relies on a filter to call remove() afterward. If the filter throws an exception, remove() is never called. With a 100-thread pool, 100 session objects (each potentially holding large object graphs) accumulate permanently. This is invisible to heap profilers until GC pressure forces an investigation.

Real incident: An e-commerce platform ran a Spring Boot application with a custom security filter storing JWTs in ThreadLocal. A buggy exception mapper swallowed errors before the cleanup filter ran. After a high-traffic sale, heap dumps revealed 247 MB of stale JWT payloads pinned by ThreadLocal references. A full GC could not collect them — the thread objects were still alive, rooting the ThreadLocal map entries. The service required a rolling restart every 6 hours.

Failure 2 — Data bleed in virtual thread executors. Virtual threads are cheap to create and are typically not pooled. But when they are pooled (e.g., bounded virtual thread pools in some frameworks), ThreadLocal values from a previous request are visible in the next request handled by the same thread. Unlike platform threads where pooling semantics are well-understood, virtual thread lifecycle semantics around ThreadLocal are subtly different and easy to misunderstand.

Failure 3 — Mutable state in concurrent access. ThreadLocal semantics guarantee thread isolation, but mutable objects stored inside a ThreadLocal (e.g., ThreadLocal<List<Event>>) can be accidentally shared via object references if not defensively copied. This produces intermittent concurrency bugs that are nearly impossible to reproduce.

Failure 4 — Inheritance surprises. InheritableThreadLocal propagates values to child threads created via new Thread(). However, it does not propagate correctly to virtual threads created by Thread.ofVirtual() or to threads from Executors.newVirtualThreadPerTaskExecutor(). Security-sensitive context — like the authenticated principal — may be missing in virtual thread callbacks, producing authorization bypasses.

2. The ScopedValue Programming Model

ScopedValue has three key properties that address all four ThreadLocal failure modes: immutability (once bound, the value cannot be mutated within the scope), lexical scoping (the value is automatically unbound when the binding's run/call method returns — no remove() needed), and explicit inheritance (child tasks created within a StructuredTaskScope automatically inherit the parent's bound scoped values).

import java.lang.ScopedValue;

// Declare ScopedValue as a public static final — typically in a context holder class
public class RequestContext {
    public static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
    public static final ScopedValue<String> TRACE_ID   = ScopedValue.newInstance();
}

// In your request filter / interceptor:
public void handle(HttpRequest request, HttpResponse response) {
    User user = authenticate(request);
    String traceId = request.header("X-Trace-Id");

    ScopedValue.where(RequestContext.CURRENT_USER, user)
               .where(RequestContext.TRACE_ID, traceId)
               .run(() -> processRequest(request, response));
    // Both bindings are automatically released when run() returns —
    // whether normally, via exception, or via Thread.interrupt().
}

// Deep in the call stack — no parameter threading required:
public void auditService() {
    User user = RequestContext.CURRENT_USER.get();
    String traceId = RequestContext.TRACE_ID.get();
    // safe read-only access; no mutation possible
}

The critical difference from ThreadLocal: there is no set() method on ScopedValue. Values are bound declaratively via ScopedValue.where(...).run(...). Immutability eliminates the mutable-object-reference failure mode entirely. Lexical scoping eliminates the need for cleanup code — and therefore eliminates the memory leak from missing remove() calls.

3. Migrating Spring Boot Request Context

A typical Spring Boot application stores the authenticated principal in a SecurityContextHolder backed by ThreadLocal (Spring Security's default strategy). When enabling virtual threads via spring.threads.virtual.enabled=true in Spring Boot 3.2+, the SecurityContextHolder must be reconfigured to use MODE_INHERITABLETHREADLOCAL — or migrated to ScopedValue in custom security middleware.

// Custom ScopedValue-based security context (Spring Boot 3.x with virtual threads)
@Component
public class ScopedSecurityFilter extends OncePerRequestFilter {

    public static final ScopedValue<Authentication> AUTH =
        ScopedValue.newInstance();

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws IOException, ServletException {
        Authentication auth = resolveAuthentication(request);
        if (auth == null) {
            response.sendError(401);
            return;
        }
        // Bind auth for the entire request processing tree, including
        // virtual thread subtasks created via StructuredTaskScope
        ScopedValue.where(AUTH, auth).run(() -> {
            try {
                chain.doFilter(request, response);
            } catch (IOException | ServletException e) {
                throw new RuntimeException(e);
            }
        });
        // No cleanup needed — binding released automatically
    }
}

// In any downstream service:
@Service
public class OrderService {
    public void createOrder(OrderRequest req) {
        Authentication auth = ScopedSecurityFilter.AUTH.get();
        if (!auth.hasRole("ORDER_WRITE")) throw new AccessDeniedException(...);
        // ...
    }
}

4. Child Thread Inheritance and StructuredTaskScope

Scoped values bound in a parent scope are automatically visible in child scopes created within a StructuredTaskScope. This is the critical feature that makes ScopedValue work correctly with virtual threads and structured concurrency:

// CURRENT_USER is bound in the request filter above.
// Inside a structured concurrent fan-out:
public OrderSummary enrichOrder(Order order) throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        // These subtasks automatically see CURRENT_USER binding
        var inventoryTask = scope.fork(() -> inventoryClient.fetch(order));
        var pricingTask   = scope.fork(() -> pricingClient.fetch(order));
        var auditTask     = scope.fork(() -> {
            // This virtual thread inherits CURRENT_USER without any explicit passing
            User user = RequestContext.CURRENT_USER.get();
            return auditLog.record(order, user);
        });

        scope.join().throwIfFailed();
        return buildSummary(inventoryTask.get(), pricingTask.get());
    }
}

With InheritableThreadLocal this was fragile — inheritance worked for new Thread() but not for virtual threads or thread pool executor threads. With ScopedValue inside a StructuredTaskScope, inheritance is guaranteed by specification for all forked subtasks, regardless of the underlying thread carrier.

5. Rebinding: Scoped Override Without Mutation

A powerful feature of ScopedValue is that inner scopes can rebind a value without affecting the outer scope. This is useful for impersonation, testing, and multi-tenant context switching:

// Impersonation: admin acts as another user temporarily
public void impersonateAndAudit(User admin, User target) {
    // admin is the current user in the outer scope
    assert RequestContext.CURRENT_USER.get() == admin;

    ScopedValue.where(RequestContext.CURRENT_USER, target).run(() -> {
        // Inside this lambda: CURRENT_USER == target
        performAuditActions();
        // target is visible to all downstream calls, including subtasks
    });

    // Back in outer scope: CURRENT_USER == admin (unchanged)
    assert RequestContext.CURRENT_USER.get() == admin;
}

This is fundamentally safer than ThreadLocal.set(target) followed by ThreadLocal.set(admin) — the restore step is guaranteed by the scoping mechanism, not by a try-finally block that developers must write correctly.

6. Production Failure Scenarios

Scenario: Unbound access. Calling ScopedValue.get() when the value is not bound in the current scope throws NoSuchElementException. This is an explicit, detectable failure — unlike ThreadLocal.get() which returns null silently. Production code should call ScopedValue.isBound() or use ScopedValue.orElse(default) for optional context.

Scenario: Async callbacks outside scope. CompletableFuture.thenApplyAsync() runs on a different thread that may not inherit the scoped value. Only subtasks forked within a StructuredTaskScope are guaranteed to inherit. For reactive pipelines (WebFlux, Project Reactor), use Reactor's context propagation API with ReactorContextAccessor to bridge scoped values into reactive chains.

7. Performance: ScopedValue vs ThreadLocal

ScopedValue.get() is consistently 2–10x faster than ThreadLocal.get() in JMH benchmarks on virtual threads. The reason: ThreadLocal uses a hash map (Thread.threadLocals) that must be looked up by identity hash. ScopedValue uses a lightweight snapshot-based binding structure that is O(depth-of-scope-nesting) — typically O(1) for flat request scopes. For virtual threads specifically, the performance improvement is larger because virtual threads were designed with ScopedValue semantics in mind, while ThreadLocal on virtual threads involves additional carrier-thread synchronization overhead.

8. Trade-offs and Migration Strategy

Migration is not always straightforward. ThreadLocal allows mutation — you can accumulate values across a request (e.g., an audit event collector). ScopedValue's immutability means you need a different pattern for accumulation: pass a mutable holder object as the scoped value (e.g., ScopedValue<List<AuditEvent>>) or use a separate event-collection mechanism outside the ScopedValue model.

Incremental migration strategy: (1) Enable virtual threads, observe ThreadLocal-related issues in testing. (2) Replace security principal and trace ID context with ScopedValue first — these are the highest-risk, most-read contexts. (3) Migrate mutable contexts last, refactoring to immutable patterns where possible. (4) Do not migrate framework-managed ThreadLocal (e.g., Hibernate's session context) — these require framework-level support.

Key Takeaways

Conclusion

ScopedValue is the missing companion to virtual threads and structured concurrency in Java's Project Loom. While virtual threads solve the scalability problem and StructuredTaskScope solves the reliability problem, ScopedValue solves the context propagation problem — the silent, hard-to-debug class of bugs where the wrong user context, missing trace ID, or stale security principal produces incorrect behavior deep in a distributed call chain. For any team enabling virtual threads in Spring Boot 3.2+, auditing and migrating critical ThreadLocal usage to ScopedValue is not optional — it is a prerequisite for reliable production operation.

Discussion / Comments

Related Posts

Core Java

Java Virtual Threads in Production

Project Loom virtual threads: scalable concurrency without thread pool tuning overhead.

Core Java

Java Structured Concurrency

Scoped parallel tasks with StructuredTaskScope in Java 21+ — the sibling API to ScopedValue.

Core Java

Java Memory Leaks in Production

Heap dump analysis and prevention strategies for the most common JVM memory leak patterns.

Last updated: March 2026 — Written by Md Sanwar Hossain