You put a request ID in a ThreadLocal so every method in the call stack can see it. Forget to clear it, and the next request on that thread can pick up the old ID. With virtual threads, that pattern gets worse: each short-lived thread may carry its own copy of the same context, and the cost adds up fast.

Scoped values fix that. You set the value for one block of work. Methods called inside that block can read it. When the block ends, the value is gone — no manual cleanup.

A scoped value is an immutable binding visible to a method’s callees (and structured child tasks) for a bounded dynamic scope. Reach for it when you need request IDs, principals, or correlation data without threading parameters through every layer — not when you need a mutable per-thread cache that code rewrites freely.

This post continues the Loom story from Virtual Threads, which already flagged ThreadLocal sprawl as a gotcha on short-lived workers.

The problem scoped values solve

Frameworks often need context deep in the call stack without polluting every application method signature: authenticated user, transaction id, tenant, MDC-style correlation.

Before scoped values, teams usually reached for ThreadLocal:

private static final ThreadLocal<String> REQUEST_ID = new ThreadLocal<>();

void serve(Request request, Response response) {
    REQUEST_ID.set(request.id());
    try {
        application.handle(request, response);
    } finally {
        REQUEST_ID.remove(); // easy to forget on every exit path
    }
}

String currentRequestId() {
    return REQUEST_ID.get();
}

That works until it does not. ThreadLocal has three chronic issues:

  • Unconstrained mutability — anything that can get can also set, so data flow becomes spaghetti.
  • Unbounded lifetime — values stick until remove() or thread death; pool reuse leaks context across unrelated tasks.
  • Expensive inheritance — InheritableThreadLocal copies storage into each child; with millions of virtual threads that cost shows up fast.

Scoped values flip the model: write once at the boundary, read down the stack, auto-unbind when the scope ends.

private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

void serve(Request request, Response response) {
    ScopedValue.where(REQUEST_ID, request.id()).run(() ->
            application.handle(request, response));
}

String currentRequestId() {
    return REQUEST_ID.get();
}

Same intent. Explicit lifetime. No finally cleanup ritual.

When scoped values shipped

ReleaseStatusSpec
Java 20IncubatorJEP 429
Java 21First previewJEP 446
Java 22Second previewJEP 464
Java 23Third previewJEP 481
Java 24Fourth previewJEP 487
Java 25 LTSStandard featureJEP 506

Use Java 25+ for scoped values with no preview flag. One finalization change: ScopedValue.orElse no longer accepts null as the fallback — pass a real default or use isBound() / get() deliberately.

Mental model

Think of a scoped value as a hidden immutable parameter with a clock that stops when the binder returns.

ConcernThreadLocalScopedValue
Mutabilityget / set anytimeBound once per scope; no set
LifetimeUntil remove() or thread endUntil run / call exits
Child threadsCopy via InheritableThreadLocalShared inheritance (esp. with structured concurrency)
Virtual threadsPer-thread copies add upDesigned for cheap sharing at scale
  • Declare a static final ScopedValue<T> (usually private).
  • Bind with ScopedValue.where(KEY, value).run(...) or .call(...).
  • Read with KEY.get(), KEY.orElse(default), or KEY.isBound().
  • Nesting rebinds for the inner scope; the outer binding returns when the inner scope exits.

You still prefer ordinary method parameters when the data is part of the API. Scoped values are for cross-cutting context that would otherwise become signature noise.

Basics in code

Declare and bind:

import static java.lang.ScopedValue.where;

private static final ScopedValue<String> USER = ScopedValue.newInstance();

void handle(String userId, Runnable work) {
    where(USER, userId).run(work);
}

Read inside the scope — including deep callees:

void audit() {
    System.out.println("actor=" + USER.get());
}

void handle(String userId) {
    where(USER, userId).run(() -> {
        prepare();
        audit(); // sees the same binding
    });
}

Return a value or propagate checked exceptions with call:

Order load(String orderId) throws Exception {
    return where(USER, currentUser()).call(() -> orderService.find(orderId));
}

Optional presence without throwing:

String label = REQUEST_ID.orElse("anonymous");
if (REQUEST_ID.isBound()) {
    metrics.tag("requestId", REQUEST_ID.get());
}

Nested rebinding is lexical in time, not a race:

where(USER, "alice").run(() -> {
    System.out.println(USER.get()); // alice
    where(USER, "bob").run(() ->
            System.out.println(USER.get())); // bob
    System.out.println(USER.get()); // alice again
});

When the outer run returns, USER.get() is unbound again for that thread.

Use cases

1. Request / correlation context

Bind once at the edge of a request (servlet filter, gateway handler, message consumer) and let logging, metrics, and deeper services read the same id without parameters.

private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

void onRequest(Request request, Response response) {
    where(REQUEST_ID, request.id()).run(() ->
            dispatcher.dispatch(request, response));
}

2. Framework context without signature pollution

Same pattern as the JEP motivation: framework code creates context, calls user code, then reads the context again on the way back into framework helpers — without forcing every app method to take a FrameworkContext argument.

3. Virtual threads without ThreadLocal tax

With virtual threads, “one task, one thread” is cheap. Stuffing each task with large ThreadLocal maps is not. Scoped values share immutable bindings efficiently, which is why they landed as part of the same Loom story.

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (Request request : batch) {
        executor.submit(() ->
                where(REQUEST_ID, request.id()).run(() -> process(request)));
    }
}

4. Structured child tasks (preview / companion API)

Bindings established in a parent are visible to children started inside a structured concurrency scope (StructuredTaskScope on modern JDKs) without copying ThreadLocal maps. Treat that as the natural hand-off when you adopt structured concurrency for fan-out with cancellation.

When they win vs when they don’t

NeedPrefer
Immutable request / security / correlation contextScoped values
Cross-cutting framework context without signature churnScoped values
Millions of short-lived virtual threads sharing contextScoped values
Mutable per-thread cache (buffers, formatters rewritten in place)ThreadLocal (or redesign)
Library that already owns a mature ThreadLocal contractLeave it; migrate only your app-owned context

Scoped values are not a drop-in rename of every ThreadLocal. If code relies on mid-stack mutation, you need a different design — pass a mutable holder explicitly, or keep ThreadLocal for that niche.

Gotchas

Calling get() outside a binding

KEY.get() throws if the value is unbound. Prefer isBound() or orElse(nonNullDefault) at optional boundaries (filters, shared helpers used both inside and outside requests).

orElse(null) is gone

On Java 25 (JEP 506), orElse rejects null. If “absent” is meaningful, use isBound() or an explicit sentinel object — do not pass null as the fallback.

Still prefer parameters for domain data

If OrderService.find needs an orderId, pass orderId. Scoped values are for ambient context, not for hiding the arguments that define the operation.

Framework-owned ThreadLocals

Servlet containers, security filters, and tracing agents may still use ThreadLocal. Migrate your request context first. Replacing every framework internal is not the goal of JEP 506 (and the JEP explicitly does not deprecate ThreadLocal).

Mutability by smuggling

The binding is immutable; the object you bind might not be. Binding a mutable HashMap and mutating it from callees reintroduces spaghetti. Prefer records / immutable snapshots for context payloads.

Cheat sheet

ScopedValue.newInstance()
ScopedValue.where(KEY, value).run(runnable)
ScopedValue.where(KEY, value).call(callable)
KEY.get() / KEY.orElse(default) / KEY.isBound()

Java 25+ (JEP 506); no preview flag
Good: request IDs, principals, correlation, framework context
Avoid: mutable mid-stack rewrites; domain args that belong in signatures
Watch: get() when unbound; orElse(null) removed at finalization
Pairs with: virtual threads; structured concurrency for child inheritance

Do:

  • Bind at a clear boundary (serve, filter, consumer) with where(...).run/call.
  • Keep bound objects immutable (or treat them as read-only).
  • Prefer scoped values over ThreadLocal for app-owned request context on virtual threads.

Don’t:

  • Use scoped values to dodge every method parameter.
  • Forget that get() fails when unbound.
  • Bind mutable bags and pretend the scope made mutation safe.

Pros and cons

Pros

  • Bounded lifetime — binding ends when run / call returns; no remove() ritual
  • Immutable one-way data flow from binder to callees
  • Lower cost than inherited ThreadLocal copies, especially with virtual threads
  • Natural fit with structured concurrency child tasks when you adopt that API

Cons

  • Not for mutable per-thread caches — different problem than ambient context
  • get() outside a scope fails; call sites must know they are inside a binding
  • Requires Java 25+ for the final API (earlier releases were preview)
  • Does not replace every existing ThreadLocal in the ecosystem overnight

Wrap-up

Scoped values make “pass this context down the stack” explicit, immutable, and short-lived. They became a standard feature in Java 25 (JEP 506) after several preview rounds, with one sharp edge at finalization: orElse no longer takes null.

Start by replacing app-owned request ThreadLocals at the edge of each request with ScopedValue.where(...).run(...). Keep domain arguments in method signatures. Pair them with virtual threads when you scale to many short-lived tasks. When you want structured cancellation and automatic context inheritance into forked work, step up to structured concurrency (still preview) — the next chapter in the same Loom story.