In September 2025, I wrote a post on Impacts of Java 25 - what will they learn and focused on how the Java language has changed to the better for new developers. But I never really wrote about the things that make the senior Java developer life better.
One of those things is Scoped Values (JEP 506). It has been around since Java 20 (incubation) and now got finalised.
The Goals:
- Ease of use - It should be easy to reason about dataflow.
- Comprehensibility — The lifetime of shared data should be apparent from the syntactic structure of code.
- Robustness — Data shared by a caller should be retrievable only by legitimate callees.
- Performance — Data should be efficiently sharable across a large number of threads.
What’s the problem?
If you have been writing Java code, you have come across the very common pattern that you want to share a context like a request id, a session, a transaction id, a trace id or similar across multiple methods. And you are not too keen about sending it as a parameter through every method call.
Until now, we have had to reach for the ThreadLocal but as with anything that has to do with threads, it is somewhat hard to manage. One of the most annoying things with ThreadLocal is that any code that has access to it can change the binding at any point using set(). This makes dataflows confusing and VERY hard to debug. One of the reasons it’s hard to debug is that you don’t really know if it is still in your thread or if someone has called remove() on it. And if someone forgets to call remove(), we have a memory leak. In an application that uses a lot of threads, you also get a high memory consumption. The thought about ThreadLocal is good, but the memory management will get out of hand if your system creates a lot of threads due to the copying of memory maps.
What is the difference?
A ThreadLocal stores a value as a copy in each thread. This is really nice if you are running a traditional thread pool but bad if you have a modern Java application that creates huge amounts of virtual and lightweight threads.
This is where ScopedValue comes in. It is designed with virtual threads in mind. Most of the time, ThreadLocal was only used to send data downstream, but any code with a reference to it could still call set() and change it for the rest of that thread’s lifetime. Instead of having to copy and sync the whole ThreadLocal data structure to each calling thread, ScopedValue only sends references to the parent thread memory scope. It allows a method to share a defined context with anything it calls, without having to pass it as a parameter. It is also scoped to the same context as the call and will be automatically released when the scope exits.
A called method can’t change the binding, but in theory, it can change the bound context object. If that is not desirable, use a record.
How to use ScopedValue
Lets look at ThreadLocal vs. ScopedValue, with a variant of the example from the JEP. Instead of the generic Framework/Application sketch from the JEP itself, here is the same shape of problem as it actually shows up day to day: a servlet filter resolves the current tenant for an incoming request, and a repository several layers down the call stack needs that tenant to pick the right database — all done the ThreadLocal way.
public class TenantFilter implements Filter {
private static final ThreadLocal<TenantContext> CURRENT_TENANT
= new ThreadLocal<>(); // (1)
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
var tenantId = ((HttpServletRequest) request).getHeader("X-Tenant-Id");
CURRENT_TENANT.set(new TenantContext(tenantId)); // (2)
try {
chain.doFilter(request, response);
} finally {
CURRENT_TENANT.remove(); // (3)
}
}
public static TenantContext current() {
return CURRENT_TENANT.get(); // (4)
}
}
public record TenantContext(String tenantId) {}
public class CustomerRepository {
public Customer findById(String customerId) {
var tenant = TenantFilter.current();
var dataSource = DataSourceRouter.forTenant(tenant.tenantId());
try (var connection = dataSource.getConnection()) {
// ... run the query against this tenant's schema
}
}
}
A few things worth noticing:
- The binding lives in a
staticfield, shared by every thread that ever passes through this filter. - The tenant is resolved once per request and pushed into the binding before the rest of the chain runs.
CustomerRepository— which has never heard ofTenantFilterand receives no such parameter — can still read the tenant three call frames away.- The
finallyblock withremove()is not optional. Skip it, and on a pooled thread the next unrelated request served by that same thread will silently inherit the previous tenant.
Now, let us look at the same code using ScopedValue:
import static java.lang.ScopedValue.where;
public class TenantFilter implements Filter {
private static final ScopedValue<TenantContext> CURRENT_TENANT
= ScopedValue.newInstance(); // (1)
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
var tenantId = ((HttpServletRequest) request).getHeader("X-Tenant-Id");
try {
where(CURRENT_TENANT, new TenantContext(tenantId)) // (2)
.call(() -> {
chain.doFilter(request, response);
return null;
});
} catch (IOException | ServletException e) {
throw e;
} catch (Exception e) {
throw new ServletException(e);
}
}
public static TenantContext current() {
return CURRENT_TENANT.orElse(new TenantContext("unknown")); // (3)
}
}
public record TenantContext(String tenantId) {}
public class CustomerRepository {
public Customer findById(String customerId) {
var tenant = TenantFilter.current();
var dataSource = DataSourceRouter.forTenant(tenant.tenantId());
try (var connection = dataSource.getConnection()) {
// ... run the query against this tenant's schema
}
}
}
CustomerRepository doesn’t change at all — it never knew about ThreadLocal and it doesn’t need to know about ScopedValue either, it just calls .get() on the binding.
The filter is where all the difference is:
CURRENT_TENANTis now aScopedValue, created once vianewInstance()— there’s no mutable holder to synchronize or copy, just a key.where(...).call(...)binds the tenant for exactly the duration of that lambda. There is noremove()to forget, because the binding’s lifetime is the closure — it ends the momentcallreturns on this thread, even ifchain.doFilterthrows.current()reads it throughorElse(...), falling back to a defaultTenantContextinstead of throwing if it’s ever called outside a bound scope — nonullcheck needed.
Unsettling
Java has really well-defined access control, and that’s what allows ScopedValue to restrict access to internal data. A ScopedValue object is a capability object — only code that holds a reference to it has the ability to bind or read the value. There is no set() method on a ScopedValue, so there is no way for third-party libraries or careless developers to alter it by mistake.
Forced value?
In my example above, I use a default unknown value and handle the flow accordingly.
If we assume that the tenantId must be set, we can alter that method and use the orElseThrow() method like this:
public static TenantContext current() {
return CURRENT_TENANT.orElseThrow(() -> new IllegalStateException("Filter requires a known tenant"));
}
Another approach is to explicitly check if it is bound:
if (CURRENT_TENANT.isBound()) {
TenantContext tenant = CURRENT_TENANT.get();
}
Nested scopes hiding in the shadows
One thing I find very pleasing with scoped values is the way you can have nested values. A block can temporarily bind the used ScopedValue to something else that is only valid in the next calling scope. Since the value is bound to the scope, you don’t have to remember the previous value like you had to do with ThreadLocal. It is still there when you come back up the contexts.
This is a little silly demo of that concept, where I use two scoped values and re-bind one of them at every sub-call.
import static java.lang.ScopedValue.where;
public class NestedScopedValueDemo {
// Who is currently acting — rebound (shadowed) at deeper layers below.
private static final ScopedValue<String> ACTING_AS = ScopedValue.newInstance();
// A second ScopedValue bound once at the root and left untouched.
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
public static void main(String[] args) {
where(ACTING_AS, "alice")
.where(REQUEST_ID, "req-1")
.run(NestedScopedValueDemo::handleRequest);
}
// Layer 1 (root): the identity the request arrived with.
private static void handleRequest() {
report("layer 1 handleRequest");
runPrivilegedMaintenance();
report("layer 1 handleRequest (restored)");
}
// Layer 2: shadow ACTING_AS to run an internal task as "system".
private static void runPrivilegedMaintenance() {
where(ACTING_AS, "system").run(() -> {
report("layer 2 runPrivilegedMaintenance");
tagMetrics();
report("layer 2 runPrivilegedMaintenance (restored)");
});
}
// Layer 3: shadow ACTING_AS a second time, one level deeper still.
private static void tagMetrics() {
where(ACTING_AS, "metrics-agent").run(() ->
report("layer 3 tagMetrics"));
}
// Reporting utility method.
private static void report(String site) {
System.out.printf("%-38s actingAs=%-14s requestId=%s%n",
site, ACTING_AS.get(), REQUEST_ID.get());
}
}
When should I use this?
My recommendations:
- When the data is part of the surrounding context, like a tenant id, request id, transaction id or a token.
- When you REALLY don’t want to risk that the value is changed by the called context.
- When you clearly know the lifespan of the value.
- When the value is used across multiple subcontexts.
The last one is sort of important. There’s no need to reach for a ScopedValue if you only need the value on that one call — just pass it as a parameter. Use it when you find yourself having to pass the same value downstream a lot.
Quick comparison: ThreadLocal vs ScopedValue
| Property | ThreadLocal | ScopedValue |
|---|---|---|
| Data structure | Mutable (set()) |
Immutable (read-only) |
| Lifetime | Undefined (requires manual remove()) |
Clearly bounded by the scope (run/call) |
| Memory usage | Each thread holds its own copy; nothing is shared or inherited automatically | Low (shares the same binding in memory) |
| Safety | Anyone with a reference can change the value | No one can change the value within an active scope |
