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
getcan alsoset, so data flow becomes spaghetti. - Unbounded lifetime — values stick until
remove()or thread death; pool reuse leaks context across unrelated tasks. - Expensive inheritance —
InheritableThreadLocalcopies 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
| Release | Status | Spec |
|---|---|---|
| Java 20 | Incubator | JEP 429 |
| Java 21 | First preview | JEP 446 |
| Java 22 | Second preview | JEP 464 |
| Java 23 | Third preview | JEP 481 |
| Java 24 | Fourth preview | JEP 487 |
| Java 25 LTS | Standard feature | JEP 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.
| Concern | ThreadLocal | ScopedValue |
|---|---|---|
| Mutability | get / set anytime | Bound once per scope; no set |
| Lifetime | Until remove() or thread end | Until run / call exits |
| Child threads | Copy via InheritableThreadLocal | Shared inheritance (esp. with structured concurrency) |
| Virtual threads | Per-thread copies add up | Designed for cheap sharing at scale |
- Declare a
static final ScopedValue<T>(usuallyprivate). - Bind with
ScopedValue.where(KEY, value).run(...)or.call(...). - Read with
KEY.get(),KEY.orElse(default), orKEY.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
| Need | Prefer |
|---|---|
| Immutable request / security / correlation context | Scoped values |
| Cross-cutting framework context without signature churn | Scoped values |
| Millions of short-lived virtual threads sharing context | Scoped values |
| Mutable per-thread cache (buffers, formatters rewritten in place) | ThreadLocal (or redesign) |
Library that already owns a mature ThreadLocal contract | Leave 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) withwhere(...).run/call. - Keep bound objects immutable (or treat them as read-only).
- Prefer scoped values over
ThreadLocalfor 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/callreturns; noremove()ritual - Immutable one-way data flow from binder to callees
- Lower cost than inherited
ThreadLocalcopies, 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
ThreadLocalin 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.