Java Scoped Values: Bind Request Context to a Call Tree
Use Java 25 scoped values for immutable request context, understand rebinding and restoration, and avoid ThreadLocal lifetime and inheritance traps.
A Java ScopedValue passes context implicitly down a bounded call tree. Bind the key immediately around the operation that owns the context, read it in faraway callees, and let the binding disappear automatically when the operation returns or throws. It is a good fit for immutable request metadata that flows one way; it is not a mutable thread-wide variable or a replacement for ordinary parameters everywhere.
Scoped values became final in JDK 25 after several preview rounds. Code written for preview releases may use older API shapes, so compile migration examples against JDK 25’s final ScopedValue.where(key, value).run(...) or .call(...) API.
Bind at the request boundary
public class RequestContext {
record Context(String requestId, String user) {}
private static final ScopedValue<Context> CURRENT =
ScopedValue.newInstance();
static String loadProfile() {
var context = CURRENT.get();
return context.requestId() + ":" + context.user();
}
static String handle(String requestId, String user) {
var context = new Context(requestId, user);
return ScopedValue.where(CURRENT, context).call(
RequestContext::loadProfile
);
}
public static void main(String[] args) {
System.out.println(handle("req-17", "duke"));
System.out.println(CURRENT.isBound());
}
}
Compile and run on JDK 25:
javac RequestContext.java
java RequestContext
It prints req-17:duke and then false. The binding exists only during the call; intermediate methods need neither an extra parameter nor permission to access the private key. The Java 25 ScopedValue API describes the key as a capability and recommends restricting access to it, commonly with a private static final field.
get() throws NoSuchElementException outside a binding. Use isBound(), orElse(...), or orElseThrow(...) when absence is part of the contract. For required request context, an early exception is often safer than a fabricated default.
Binding is dynamic scope, not object mutation
ScopedValue.where creates a carrier that maps a key to a value for one bounded operation. It does not assign into the ScopedValue object. A nested operation can rebind the same key, and leaving the nested scope restores the outer value even when an exception unwinds the stack:
static void demonstrateRebinding() {
ScopedValue.where(CURRENT, new Context("outer", "duke")).run(() -> {
System.out.println(CURRENT.get().requestId());
ScopedValue.where(CURRENT, new Context("inner", "admin")).run(() ->
System.out.println(CURRENT.get().requestId())
);
System.out.println(CURRENT.get().requestId());
});
}
The sequence is outer, inner, outer. A callee cannot overwrite the caller’s binding for the rest of the thread; it can only create a nested binding around more execution. This stack-like restoration is the central safety difference from ThreadLocal.set and remove.
Multiple keys can be accumulated before one call:
ScopedValue.where(CURRENT, new Context("req-18", "duke"))
.where(TRACE_ENABLED, true)
.run(RequestContext::process);
Keep the number of keys small. The JDK 25 implementation uses a small per-thread cache; the API documentation recommends grouping related values into one immutable record when many values travel together.
Prefer immutable bound values
The binding cannot be reassigned by a callee, but the bound object can still be mutable. If several methods or child threads share a mutable holder, they share its races too. Bind an immutable record, identifier, security principal, or read-only configuration view. If mutation is essential, its synchronization and ownership remain the application’s responsibility.
Do not bind a request-scoped database session or other resource merely to avoid expressing lifetime. A scoped value does not close its value. Keep resource ownership in try-with-resources and use the binding only when implicit access genuinely improves the call interface.
ThreadLocal and ScopedValue solve different problems
A ThreadLocal supports mutable per-thread state with an open-ended lifetime. Callers must restore or remove values, commonly in finally, and pooled threads can leak stale context into later tasks when cleanup is missed. An InheritableThreadLocal copies mappings into child threads and can be costly when thread counts are high.
A scoped-value binding is immutable, bounded by a method invocation, and automatically restored. Choose it for one-way transmission from caller to callee. Keep ThreadLocal when a library truly needs mutable thread-associated state or must support JDK releases before 25, but wrap its set/restore lifecycle rigorously.
Ordinary method parameters remain the clearest choice when only one or two adjacent layers need the value. Scoped values earn their indirection when context must cross many general-purpose layers that should not add plumbing parameters.
Inheritance is structured, not automatic for arbitrary threads
Bindings are per-thread. Starting a plain Thread or submitting to an unrelated executor does not promise general scoped-value propagation. JDK 25 integrates inheritance with StructuredTaskScope: bindings are captured when the scope is created and inherited by tasks forked within that scope.
Structured concurrency is still a preview feature in JDK 25, so code using it must compile and run with --enable-preview --release 25. Its child tasks must finish within the parent’s bounded scope, which keeps inherited context from escaping the call tree.
The practical rule is to bind once at the operation boundary, keep the value immutable, use explicit parameters for local data flow, and propagate into concurrency only through an API whose inheritance contract you have verified. That preserves the property scoped values are designed to provide: context lives exactly as long as the call tree that owns it.