Definition
ScopedValue– An immutable, implicitly passed value bound for the dynamic scope of a bounded operation, declared as astatic finalfield and set withScopedValue.where(KEY, value).run(...)orcall(...).- Immutability – There is no
set()method; once bound, the value can only be read withKEY.get(), making data flow obvious. - Automatic teardown – The binding is removed when the bounded operation returns, so there is nothing to forget and nothing to leak.
- Lightweight inheritance – Child tasks in
StructuredTaskScopeinherit bindings cheaply without copying a per-thread map, which is critical for millions of virtual threads. - Nested rebinding –
ScopedValue.where(KEY, newValue).run(subTask)creates a nested scope that reverts to the previous value when the sub-operation returns.
Imagine National Bank of India gives a visitor a temporary badge valid only for the duration of one guided tour. Everyone the visitor meets during that tour can read the badge (the current user, the request ID), but the moment the tour ends the badge is automatically collected at the door, so it can never be forgotten in a pocket and misused later. A ThreadLocal is more like handing someone a permanent locker key they are supposed to return but often forget, especially when the same staff member (a pooled thread) is reused for the next visitor and still has the old key. Scoped values make the badge strictly temporary and read-only.
Understanding the Concepts
What is a ScopedValue?
A scoped value, finalized as JEP 506 in Java 25, is an immutable value bound for the dynamic scope of a bounded operation. You declare it as a static final field and bind it with ScopedValue.where(KEY, value).run(...) or call(...).
package com.bank.context;
import java.lang.ScopedValue;
public class RequestContext {
// Declare as static final
public static final ScopedValue<String> CURRENT_USER =
ScopedValue.newInstance();
public static final ScopedValue<String> REQUEST_ID =
ScopedValue.newInstance();
}
package com.bank.service;
import com.bank.context.RequestContext;
import java.lang.ScopedValue;
public class TransferEndpoint {
public void handleRequest(String userId, String requestId) {
// Bind for the duration of handleTransfer
ScopedValue
.where(RequestContext.CURRENT_USER, userId)
.where(RequestContext.REQUEST_ID, requestId)
.run(() -> handleTransfer());
// Bindings are automatically removed here
}
private void handleTransfer() {
// Read the bound values anywhere in the call tree
String user = RequestContext.CURRENT_USER.get();
String reqId = RequestContext.REQUEST_ID.get();
System.out.println("Transfer by " + user
+ " [" + reqId + "]");
}
}
Why is ThreadLocal problematic for virtual threads?
ThreadLocal has three problems that get worse with virtual threads. It is mutable, so any code can call set() and change the value, making data flow hard to reason about. It has an unbounded lifetime, so on a pooled platform thread a value you forget to remove lingers and leaks. And inheritance to child threads via InheritableThreadLocal copies the whole map, which is wasteful when you create millions of virtual threads.
package com.bank.service;
public class ThreadLocalLeakDemo {
private static final ThreadLocal<String> CURRENT_USER =
new ThreadLocal<>();
public void handleRequest(String userId) {
CURRENT_USER.set(userId); // Mutable: any code can change
try {
processTransfer();
} finally {
CURRENT_USER.remove(); // Easy to forget!
}
}
// If remove() is not called, the value leaks
// On a pooled thread, the next request sees the old user
// With millions of virtual threads, InheritableThreadLocal
// copies the map for each one
private void processTransfer() {
String user = CURRENT_USER.get();
System.out.println("Transfer by " + user);
}
}
How is ScopedValue different from ThreadLocal? (Comparison)
| Feature | ScopedValue | ThreadLocal |
|---|---|---|
| Mutability | Immutable (no set()) | Mutable (set() anytime) |
| Lifetime | Bounded by run()/call() scope | Unbounded until remove() |
| Leak risk | None, automatic teardown | High if remove() forgotten |
| Child thread inheritance | Cheap, no map copy | InheritableThreadLocal copies map |
| Data flow | Obvious, read-only | Hard to trace, any code can mutate |
| Virtual thread cost | Lightweight | Expensive map per thread |
How does nested rebinding work?
If a sub-operation needs a different value, you rebind in a nested scope. The new binding applies only inside the nested scope and reverts when it ends.
package com.bank.service;
import com.bank.context.RequestContext;
import java.lang.ScopedValue;
public class OperatorService {
// Rajesh (operator) acts on behalf of Priya (customer)
public void actOnBehalf(String operatorId, String customerId) {
ScopedValue
.where(RequestContext.CURRENT_USER, operatorId)
.run(() -> {
System.out.println("Operator: "
+ RequestContext.CURRENT_USER.get()); // Rajesh
// Nested rebind for sub-operation
ScopedValue
.where(RequestContext.CURRENT_USER, customerId)
.run(() -> {
System.out.println("Acting as: "
+ RequestContext.CURRENT_USER.get()); // Priya
processCustomerTransfer();
});
// Reverts to Rajesh after nested scope
System.out.println("Back to: "
+ RequestContext.CURRENT_USER.get()); // Rajesh
});
}
private void processCustomerTransfer() {
System.out.println("Processing for "
+ RequestContext.CURRENT_USER.get());
}
}
Operator: Rajesh
Acting as: Priya
Processing for Priya
Back to: Rajesh
How do scoped values work with structured concurrency?
Scoped values are designed to work with StructuredTaskScope. When a scope forks child tasks, they transparently and cheaply inherit the enclosing scoped-value bindings without copying a map per thread.
package com.bank.service;
import com.bank.context.RequestContext;
import java.lang.ScopedValue;
import java.util.concurrent.StructuredTaskScope;
import java.math.BigDecimal;
public class TransferApprovalService {
public boolean approveTransfer(String userId) throws Exception {
return ScopedValue
.where(RequestContext.CURRENT_USER, userId)
.call(() -> runChecks());
}
private boolean runChecks() throws Exception {
try (var scope = new StructuredTaskScope
.ShutdownOnFailure()) {
// Both child tasks inherit CURRENT_USER cheaply
var creditTask = scope.fork(() -> {
String user = RequestContext.CURRENT_USER.get();
return checkCredit(user);
});
var fraudTask = scope.fork(() -> {
String user = RequestContext.CURRENT_USER.get();
return checkFraud(user);
});
scope.join().throwIfFailed();
return creditTask.get() && fraudTask.get();
}
}
private boolean checkCredit(String user) {
System.out.println("Credit check for " + user);
return true;
}
private boolean checkFraud(String user) {
System.out.println("Fraud check for " + user);
return true;
}
}
What are common mistakes with ScopedValue?
The first mistake is trying to call get() outside a bound scope, which throws NoSuchElementException. The second is using scoped values for mutable state or accumulators, which they are not designed for.
// WRONG: reading outside a bound scope
String user = RequestContext.CURRENT_USER.get();
// Throws NoSuchElementException if not bound!
// CORRECT: check before reading
if (RequestContext.CURRENT_USER.isBound()) {
String user = RequestContext.CURRENT_USER.get();
}
// WRONG: trying to use ScopedValue as mutable state
// ScopedValue has no set() method, so this is impossible
// Use a different mechanism for accumulators
When should you use ScopedValue vs ThreadLocal?
Use ScopedValue for immutable context shared implicitly down a call tree, such as the current user, a request ID, a tenant ID, or a trace context. Use ThreadLocal for mutable per-thread caches or accumulators where the value changes during processing.
What about the call() variant?
When the bounded operation returns a value or throws a checked exception, use call() instead of run().
package com.bank.service;
import com.bank.context.RequestContext;
import java.lang.ScopedValue;
import java.math.BigDecimal;
public class BalanceService {
public BigDecimal getBalance(String userId, String accountId)
throws Exception {
return ScopedValue
.where(RequestContext.CURRENT_USER, userId)
.call(() -> loadBalance(accountId));
}
private BigDecimal loadBalance(String accountId) {
String user = RequestContext.CURRENT_USER.get();
System.out.println("Loading balance for " + user
+ " account " + accountId);
return new BigDecimal("150000.00");
}
}
What should a senior engineer know?
Scoped values are specifically for immutable context shared implicitly down a call tree. The immutability and automatic teardown are the entire point, because they make the context tamper-proof and leak-proof. The lightweight inheritance is what keeps them fast at virtual-thread scale where InheritableThreadLocal would be too expensive. The newest API iterations fold well with StructuredTaskScope, giving context propagation for free.
Visual Overview
┌───────────────────────────────────────────────────┐
│ ScopedValue Lifetime │
├───────────────────────────────────────────────────┤
│ │
│ ScopedValue.where(CURRENT_USER, "Rajesh") │
│ .run(() -> { │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────┐ │
│ │ CURRENT_USER.get() → "Rajesh" │ │
│ │ │ │
│ │ transferService.process() │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ auditService.log() │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ CURRENT_USER.get() → "Rajesh" │ │
│ │ │ │
│ │ StructuredTaskScope.fork() │ │
│ │ ├── child1: get()→"Rajesh"│ (cheap) │
│ │ └── child2: get()→"Rajesh"│ (cheap) │
│ └─────────────────────────────────┘ │
│ │ │
│ ▼ │
│ }); // Binding REMOVED automatically │
│ │
│ CURRENT_USER.get() → NoSuchElementException │
└───────────────────────────────────────────────────┘
Key Takeaway
A scoped value, finalized as JEP 506 in Java 25, is an immutable value that you bind for the dynamic scope of a bounded operation and that is automatically unbound when that operation returns. You declare a static final ScopedValue, bind it with ScopedValue.where(key, value).run() or call(), and inside that operation and anything it invokes you read it with get(). It solves the same problem ThreadLocal solved: carrying per-request context like the current user or a request ID without threading it through every method parameter.
ThreadLocal is mutable, so any code can change it and the data flow becomes hard to reason about. It has an unbounded lifetime, so on a pooled thread a value you forget to remove lingers and leaks. And inheritance to child threads copies the whole map, which is wasteful for millions of virtual threads. A scoped value is immutable because there is no set method. Its lifetime is strictly the dynamic extent of the run or call, and it is torn down automatically. And child tasks in StructuredTaskScope inherit the binding cheaply without copying a per-thread map.
If a sub-operation needs a different value, you rebind in a nested scope with another where and run, and that new binding applies only inside the nested scope and reverts when it ends. That is exactly the pattern for an operator like Rajesh acting on behalf of a customer like Priya for one sub-task.
In banking work at National Bank of India every transfer request runs on its own virtual thread, and the current user and request ID are bound as scoped values for just the request. Every layer, the transfer service, the audit service, the fraud check, reads them with get() without any parameter plumbing, and when the request finishes the bindings vanish automatically with no risk of leaking onto the next request.
Remember one sentence: ScopedValue gives immutable, bounded, automatically-torn-down context sharing that is safer, simpler, and cheaper than ThreadLocal for virtual-thread workloads.
---