Aug 28, 2026 · Ove Lindström

Why you should use ScopedValue in Java

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:

  1. The binding lives in a static field, shared by every thread that ever passes through this filter.
  2. The tenant is resolved once per request and pushed into the binding before the rest of the chain runs.
  3. CustomerRepository — which has never heard of TenantFilter and receives no such parameter — can still read the tenant three call frames away.
  4. The finally block with remove() 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:

  1. CURRENT_TENANT is now a ScopedValue, created once via newInstance() — there’s no mutable holder to synchronize or copy, just a key.
  2. where(...).call(...) binds the tenant for exactly the duration of that lambda. There is no remove() to forget, because the binding’s lifetime is the closure — it ends the moment call returns on this thread, even if chain.doFilter throws.
  3. current() reads it through orElse(...), falling back to a default TenantContext instead of throwing if it’s ever called outside a bound scope — no null check 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:

  1. When the data is part of the surrounding context, like a tenant id, request id, transaction id or a token.
  2. When you REALLY don’t want to risk that the value is changed by the called context.
  3. When you clearly know the lifespan of the value.
  4. 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

  BlueSky