Back to Advanced Topics
Q31Question 31 of 41

What are Scoped Values (JEP 506, Java 25), and how are they a safer/faster alternative to ThreadLocal for virtual-thread workloads?

Key Concepts
  • A ScopedValue is an immutable value bound for the dynamic scope of a run or call and automatically unbound when the operation returns.
  • Unlike ThreadLocal, a ScopedValue has no set method, so the value cannot be changed once bound, making data flow obvious.
  • Scoped values have a strictly bounded lifetime, so there is nothing to forget and nothing to leak, unlike ThreadLocal on pooled threads.
  • Child tasks in StructuredTaskScope inherit scoped-value bindings cheaply without copying a per-thread map.
  • Nested rebinding with ScopedValue.where().run() creates a clean, stack-like scope that reverts automatically.
  • Use scoped values for immutable context like the current user or request ID shared implicitly down a call tree.
  • ThreadLocal remains appropriate for mutable per-thread caches or accumulators that scoped values are not designed for.

Definition

A scoped value is an immutable value bound for the duration of a run or call and automatically unbound afterward, giving the context-sharing of ThreadLocal without the mutability, leak risk, and per-thread cost that hurt virtual-thread workloads.
  • ScopedValue – An immutable, implicitly passed value bound for the dynamic scope of a bounded operation, declared as a static final field and set with ScopedValue.where(KEY, value).run(...) or call(...).
  • Immutability – There is no set() method; once bound, the value can only be read with KEY.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 StructuredTaskScope inherit 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(...).

JAVA
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();
}
JAVA
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.

JAVA
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.

JAVA
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());
    }
}
TEXT
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.

JAVA
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.

JAVA
// 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().

JAVA
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

DIAGRAM
┌───────────────────────────────────────────────────┐
│            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.

---

Share
Use ← → arrow keys to navigate