Skip to content
Md. Moudud Hassan
rss

8 min readArchitecture

Scoped Values Shipped. Structured Concurrency Didn't.

Project Loom is usually presented as a single architectural shift: virtual threads, structured concurrency and scoped values, arriving together to change how Java handles concurrency.

The vision is right. The timeline is not, and the difference matters if you are planning real work.

These three features are at three very different levels of maturity, and treating them as one story is how a team ends up with --enable-preview in a production build, or discovers during an upgrade that an API they built on has changed shape twice.

Feature Status
Virtual Threads Final since JDK 21 (JEP 444)
Scoped Values Final in JDK 25 (JEP 506)
Structured Concurrency Still preview — JDK 25 was the fifth, JDK 26 the sixth, with a seventh proposed

Two of these you can adopt today. The third you can learn, prototype and prepare for — but shipping it means enabling preview features, and its API has genuinely moved recently: JDK 25 replaced the public constructors of StructuredTaskScope with static factory methods, and JDK 26 renamed anySuccessfulResultOrThrow() to anySuccessfulOrThrow(), changed allSuccessfulOrThrow() to return a list rather than a stream, and added onTimeout().

That is not a criticism of the JDK process — it is preview precisely so this churn happens before people depend on it. But it does mean the honest advice differs sharply per feature.

The problem all three are circling

Take a fund transfer. A request arrives carrying a customer ID, a correlation ID, a security principal and a tenant ID. That context has to reach authentication, the business service, fraud checks, audit logging and the outbound event — through several layers, some of which run concurrently.

Java’s answer for twenty years has been ThreadLocal. It works, and it carries three long-standing problems that virtual threads make sharper.

It is mutable and unbounded in lifetime. Anything can call set() at any point. Nothing tells you where the value was bound, and there is no scope after which it is definitionally gone.

Cleanup is manual, and forgetting it is a security bug. On a pooled platform thread, a ThreadLocal you fail to remove() survives into the next request handled by that thread. When the value is a tenant ID or a security principal, that is cross-tenant leakage — not a memory issue, a data-exposure issue.

It scales with threads. That was fine at a few hundred. With virtual threads you may have a million, and a per-thread map attached to each of them is a very different memory proposition.

Interestingly, virtual threads fix the second problem by accident: they are not pooled, so a virtual thread’s ThreadLocal dies with the task. But they make the third much worse. Which is exactly the gap scoped values were designed for.

Scoped Values: use these now

The model inverts ThreadLocal. Instead of binding a value to a thread for an unbounded period, you bind an immutable value for the lexical duration of a call.

public final class RequestScope {

    public static final ScopedValue<RequestContext> CONTEXT = ScopedValue.newInstance();

    private RequestScope() {}
}
@Component
public class ContextFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) {

        RequestContext ctx = new RequestContext(
            request.getHeader("X-Correlation-Id"),
            request.getHeader("X-Tenant-Id"),
            SecurityContextHolder.getContext().getAuthentication());

        // Bound for exactly this call, then gone. No remove(), no finally block.
        ScopedValue.where(RequestScope.CONTEXT, ctx)
                   .run(() -> chain.doFilter(request, response));
    }
}

Anything called beneath that run can read it, with no parameter threading:

@Service
public class TransferService {

    public TransferResult transfer(TransferCommand cmd) {
        RequestContext ctx = RequestScope.CONTEXT.get();

        auditLog.record(ctx.correlationId(), "TRANSFER_INITIATED", cmd);
        fraudCheck.evaluate(cmd, ctx.tenantId());
        return ledger.post(cmd);
    }
}

Three properties follow, and they are the whole point:

  • Immutable. No method deep in the stack can rewrite the tenant ID for everyone above it. To vary it you nest a new binding, which is visible in the code rather than hidden in a side effect.
  • Automatically unbound. The binding ends when run returns. There is no cleanup to forget, so the cross-tenant leak simply cannot be written.
  • Cheap at scale. The design targets a world of very many threads rather than a few pooled ones.

Two caveats worth knowing before you commit.

Propagation only follows calls you control. A scoped value is visible to code called within the run block, and it is inherited by subtasks forked inside a structured task scope. It is not magically visible to work you hand to an arbitrary executor — a @Async method or a plain executor.submit() runs outside the binding and get() will fail there. If your context vanishes, that boundary is almost always why.

ThreadLocal is not deprecated. Scoped values are the better tool for request context; migrating away from ThreadLocal wholesale is explicitly not a goal, and plenty of library code will keep using it. Expect both in the same codebase for years.

The one that catches people in practice is logging. SLF4J’s MDC is ThreadLocal-backed, so correlation IDs in log output do not automatically ride along with scoped values, and they do not propagate into forked subtasks the way a scoped value does. If you move request context to ScopedValue, plan explicitly for how MDC gets populated — usually by setting it from the scoped value at the point where you actually log.

Structured Concurrency: learn it, don’t ship it yet

The idea is genuinely good. Concurrent subtasks that belong to one logical operation get a scope with a defined lifetime: if one fails, the others are cancelled; if the caller is interrupted, everything unwinds; nothing outlives the block.

Compared to a pile of futures, the difference is that failure and cancellation are structural rather than something you remember to wire up.

The current shape, after the JDK 25 change to static factory methods:

// Requires --enable-preview. Still a preview API.
TransferView loadTransferView(String transferId) throws Exception {
    try (var scope = StructuredTaskScope.open()) {          // default: fail if any subtask fails

        var account   = scope.fork(() -> accountService.load(transferId));
        var fraud     = scope.fork(() -> fraudService.assess(transferId));
        var limits    = scope.fork(() -> limitService.check(transferId));

        scope.join();                                       // wait for all, as a unit

        return new TransferView(account.get(), fraud.get(), limits.get());
    }
}

If the fraud check fails, the other two are cancelled rather than left running to complete pointlessly. When the block exits, nothing from it is still alive. Subtasks also inherit the caller’s scoped value bindings, which is where the two features compose neatly — context flows into concurrent work without being passed by hand.

Other completion policies come from passing a Joiner to open(). I am deliberately not showing those in detail, because those are exactly the method names that changed between JDK 25 and 26, and a code sample here would age badly. Check the API for your JDK.

My recommendation: prototype it, do not deploy it. Write a spike, get a feel for how it reshapes fan-out code, form a view on whether it suits your codebase. But an API on its sixth or seventh preview, with breaking changes in consecutive releases, does not belong in a payment path. CompletableFuture is not elegant, but it is stable, and stability is a feature in production.

Virtual threads: available, with sharp edges

Final since JDK 21, and in Spring Boot 3.2+ it is one property:

spring:
  threads:
    virtual:
      enabled: true

I covered the main traps in the outbox post, and they are worth restating because they are what people actually hit:

Virtual threads do not enlarge your connection pool. A hundred thousand virtual threads still queue for the same ten database connections. They remove thread scarcity, not the downstream limit — and if you have not thought about that limit, you have moved the queue somewhere less visible rather than removing it.

Blocking inside synchronized pins the carrier thread, defeating the purpose. Older libraries still do this. -Djdk.tracePinnedThreads=full finds it.

There is a third that follows directly from the discussion above: virtual threads make ThreadLocal more expensive. Frameworks that attach substantial per-thread state — some security, tracing and transaction implementations do — were designed for hundreds of threads. Profile memory before assuming a million threads is free.

What I would actually do

If you are on JDK 21+: turn on virtual threads for I/O-bound request handling. Measure. Check for pinning. Revisit your pool sizes, because that is where the real ceiling now sits.

If you are on JDK 25+: move request context from ThreadLocal to ScopedValue. It is final, the API is small, and it removes an entire class of cleanup bug. Do it filter-by-filter rather than as a big-bang migration, and sort out MDC as part of it.

For structured concurrency: build a spike. Keep it out of the production path until it goes final. When it does, the concurrent fan-out code you have already identified will be straightforward to convert.

The broader point is that “adopt Loom” is not a single decision. It is three, with different risk profiles, and the useful skill is telling them apart.

The short version

  1. Loom is three features at three maturity levels. Do not plan them as one.
  2. Virtual threads are final (JDK 21) — adopt, then go and look at your connection pools.
  3. Scoped values are final (JDK 25) — the right home for request context, and cleanup becomes structural rather than remembered.
  4. Structured concurrency is still preview after six iterations, with breaking changes in consecutive releases. Prototype only.
  5. Scoped values propagate down calls and into forked subtasks, not into arbitrary executors.
  6. ThreadLocal is not going away, and MDC is the integration you will have to think about.
  7. Virtual threads do not create downstream capacity. They never did.

The most valuable thing here is not any single API. It is knowing which of the three you can bet on this quarter — and being able to say why, when someone proposes putting a sixth-preview API in the payment path.