# Replacing ThreadLocal with ScopedValue for Request IDs (Java 25)

Here's a log line from the demo in this post. Request #3 came in without an `X-Request-Id` header, yet it was logged with the ID of a different request:

```text
http-nio-1     requestId=req-A    handling request #1
http-nio-1     requestId=req-B    handling request #2
http-nio-1     requestId=req-B    request failed: downstream timeout
http-nio-1     requestId=req-B    handling request #3
```

Request B threw before the filter reached `MDC.remove("requestId")`. MDC stores its values in a `ThreadLocal`, the worker thread went back to the pool still holding `req-B`, and the next request on that thread picked it up. Now picture someone searching the logs for `req-B` during an incident.

Java 25 finalized `ScopedValue` ([JEP 506](https://openjdk.org/jeps/506)), and request IDs are exactly what it was built for. Most articles stop at `ScopedValue.where(...).run(...)`, though, and leave out the part that matters in a real service: your logging still reads MDC, and MDC is still a `ThreadLocal`. So in this post I migrate a real request-ID / MDC setup step by step: the leak, child threads, the `ScopedValue` version, fan-out with `StructuredTaskScope`, and the bridge into Logback. All the output below is from running the code on JDK 25.0.4 (and JDK 21.0.12 for one comparison).

## The short version

- Keep **one** `ScopedValue<String> REQUEST_ID`, bind it once in the filter with `ScopedValue.where(REQUEST_ID, id).run(...)`, and read it anywhere with `REQUEST_ID.get()`. When `run()` returns or throws, it's unbound. There's nothing to `remove()`.
- **MDC doesn't know about it.** SLF4J delegates MDC to Logback, and Logback's `LogbackMDCAdapter` is two `ThreadLocal` maps. Copy the value into MDC at the boundary, and restore the old MDC value in `finally`.
- **Child threads:** only `StructuredTaskScope.fork` inherits scoped values, and in Java 25 `StructuredTaskScope` is still a preview API ([JEP 505](https://openjdk.org/jeps/505)). Executors, `Thread.ofVirtual().start`, and `CompletableFuture` all showed the value as *unbound* in my runs. Capture the ID and bind it again inside the task.
- Even forked subtasks log `requestId=NONE` unless you bridge MDC inside them too, or use Logback's new ScopedValue-based MDC (`logback-scoped-mdc`, a 1.0.0 release candidate right now).
- If a tutorial uses `ScopedValue.runWhere(...)` or `callWhere(...)`, it was written for an old preview. Those methods were removed in JDK 24 ([JEP 487](https://openjdk.org/jeps/487)) and don't compile on 25.

![ThreadLocal vs ScopedValue request ID flow](https://cdn.hashnode.com/uploads/covers/658c3a46e339366b1a80b9f3/d009f7bb-899b-4418-9368-47d3d5856c5b.png)

## Setup

| What | Value |
|---|---|
| JDK | OpenJDK 25.0.4.1 (one comparison on OpenJDK 21.0.12.1) |
| Logging | SLF4J 2.0.19, Logback 1.6.6, and logback-scoped-mdc 1.0.0-rc0, all from Maven Central |
| Build | plain `javac`. Only the `StructuredTaskScope` demos need `--enable-preview` |
| Machine | 8-core Linux box |

Every demo prints the thread name and Logback's `%X{requestId}`, so you can see what the log encoder sees:

```xml
<configuration>
  <!-- %X{requestId} reads SLF4J/Logback MDC, which is ThreadLocal-backed -->
  <appender name="OUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder><pattern>%-14thread requestId=%-8X{requestId:-NONE} %msg%n</pattern></encoder>
  </appender>
  <root level="INFO"><appender-ref ref="OUT"/></root>
</configuration>
```

## Step 1: reproduce the leak

This is the filter I see most often in real code. It puts the ID only when the header is present, and it doesn't use `try/finally`:

```java
static final ExecutorService workers = Executors.newFixedThreadPool(1, r -> new Thread(r, "http-nio-1"));

/** Buggy filter: no try/finally, and only sets MDC when the header is present. */
static void buggyFilter(String headerRequestId, Runnable handler) {
    if (headerRequestId != null) {
        MDC.put("requestId", headerRequestId);
    }
    handler.run();               // if this throws, the line below never runs
    MDC.remove("requestId");
}

public static void main(String[] args) throws Exception {
    submit("req-A", false);   // fine: put ... remove
    submit("req-B", true);    // throws before remove(): req-B stays on the thread
    submit(null, false);      // no header: logs with req-B, a request it has nothing to do with
    submit("req-D", false);   // overwrites, so the leak "heals" and is hard to spot later
    submit(null, false);
}
```

(`submit` runs the filter on the one pool thread and logs any exception with `log.warn("request failed: {}", ...)`. The pool has a single thread so the reuse happens every time, the way it would on a busy Tomcat worker.)

```text
http-nio-1     requestId=req-A    handling request #1
http-nio-1     requestId=req-B    handling request #2
http-nio-1     requestId=req-B    request failed: downstream timeout
http-nio-1     requestId=req-B    handling request #3
http-nio-1     requestId=req-D    handling request #4
http-nio-1     requestId=NONE     handling request #5
```

Request #3 is logged as `req-B`. Then request #4 overwrites the value and removes it normally, so request #5 looks fine. That's why this bug is so hard to find in production: it only shows up after a failure, it only affects requests without the header, and the next clean request quietly fixes it.

The [Logback MDC manual](https://logback.qos.ch/manual/mdc.html) explains why: "The MDC manages contextual information on a per thread basis", and "a server that recycles threads might lead to false information contained in the MDC". The usual fix is `try/finally`. That works, but only as long as everyone remembers it on every code path, forever. JEP 506 calls this "unbounded lifetime" and lists it as one of three design flaws of thread-local variables.

## Step 2: child threads don't see it (and InheritableThreadLocal is a trap)

The next problem shows up when a request fans out. I set the same value in a `ThreadLocal` and an `InheritableThreadLocal`, then read both from different kinds of child threads:

```java
static final ThreadLocal<String> TL = new ThreadLocal<>();
static final InheritableThreadLocal<String> ITL = new InheritableThreadLocal<>();

static String show() {
    return "TL=" + TL.get() + "  ITL=" + ITL.get();
}

public static void main(String[] args) throws Exception {
    TL.set("req-1");
    ITL.set("req-1");
    System.out.println("request thread:            " + show());

    Thread vt = Thread.ofVirtual().start(() -> System.out.println("new virtual thread:        " + show()));
    vt.join();

    ExecutorService pool = Executors.newFixedThreadPool(1);
    pool.submit(() -> System.out.println("pool task (pool made now): " + show())).get();

    // Next request on the same "request thread"; the pool thread already exists
    TL.set("req-2");
    ITL.set("req-2");
    System.out.println("request thread, next req:  " + show());
    pool.submit(() -> System.out.println("pool task, next req:       " + show())).get();
    pool.shutdown();

    try (var vexec = Executors.newVirtualThreadPerTaskExecutor()) {
        vexec.submit(() -> System.out.println("virtual-per-task executor: " + show())).get();
    }
    // ... then measureInheritCost(), below
}
```

```text
request thread:            TL=req-1  ITL=req-1
new virtual thread:        TL=null  ITL=req-1
pool task (pool made now): TL=null  ITL=req-1
request thread, next req:  TL=req-2  ITL=req-2
pool task, next req:       TL=null  ITL=req-1
virtual-per-task executor: TL=null  ITL=req-2
```

- A plain `ThreadLocal` is `null` in every child. That's the "MDC is empty in my `@Async` method" bug.
- `InheritableThreadLocal` is copied **when the thread is created**. A new virtual thread gets the right value. A pool thread gets the value from whoever caused the pool to create that thread, and keeps it. On the second request, the pool task still says `req-1`. So switching MDC to an inheritable map isn't a fix for pools. It's a second version of the leak.

The copy also costs memory. Here's how many bytes the parent thread allocated to *create* (not start) one virtual thread, with 0 to 32 inheritable thread-locals set. The copy happens in `Thread`'s constructor, so the parent's `com.sun.management.ThreadMXBean.getCurrentThreadAllocatedBytes()` picks it up. Each row is 100,000 `Thread.ofVirtual().unstarted(...)` calls, averaged over 5 rounds after one warm-up round:

```text
bytes allocated per Thread.ofVirtual().unstarted(..), 100000 threads, avg of 5 rounds:
   0 InheritableThreadLocals set:  312 bytes/thread
   1 InheritableThreadLocals set:  448 bytes/thread
   8 InheritableThreadLocals set:  672 bytes/thread
  32 InheritableThreadLocals set: 1632 bytes/thread
```

I ran it in five separate JVMs. Four gave exactly these numbers. One gave 411 / 632 / 1,592 for 1 / 8 / 32, and the 312-byte baseline was the same every time. These bytes are the copied map, not deep copies of your values. They're paid on every thread creation, though, and with one virtual thread per task, that's every task. (If you have to keep inheritable thread-locals, `Thread.ofVirtual().inheritInheritableThreadLocals(false)` turns the copy off for threads you create yourself.) For comparison, the [`ScopedValue` Javadoc](https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/ScopedValue.html) says inheritance into `StructuredTaskScope` threads is "in essence, no more than copying a pointer". I didn't measure that side, so treat it as the JDK's claim, not my number.

## Step 3: the ScopedValue version

`ScopedValue` has no `set()` and no `remove()`. You bind a value for the duration of a `run` (or `call`), and the binding ends when that method returns or throws. This file uses only final APIs, so no preview flag is needed on 25. Here's the core of it, with the print statements for steps 3, 5 and 6 trimmed:

```java
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
static final ScopedValue<String> TENANT = ScopedValue.newInstance();

// 1. Outside any scope
System.out.println("1. isBound=" + REQUEST_ID.isBound() + "  orElse=" + REQUEST_ID.orElse("no-request"));
try {
    REQUEST_ID.get();
} catch (NoSuchElementException e) {
    System.out.println("   get() -> " + e);
}

// 2. Bind, read deep in the call stack, rebind for a nested call
ScopedValue.where(REQUEST_ID, "req-42").where(TENANT, "acme").run(() -> {
    service();                                    // service() -> repository() -> REQUEST_ID.get()
    ScopedValue.where(REQUEST_ID, "req-42/retry-1").run(ScopedValueBasics::service);
    System.out.println("   back in outer scope: " + REQUEST_ID.get());
});

// 3. call() returns a value
String body = ScopedValue.where(REQUEST_ID, "req-43").call(() -> "response for " + REQUEST_ID.get());

// 4. The binding is gone after the scope, even if the scope threw
try {
    ScopedValue.where(REQUEST_ID, "req-44").run(() -> {
        throw new IllegalStateException("handler blew up");
    });
} catch (IllegalStateException e) {
    System.out.println("4. after a throwing scope: isBound=" + REQUEST_ID.isBound());
}

// 5. Gotcha: a missing header becomes a null binding
String header = null;
ScopedValue.where(REQUEST_ID, header).run(() -> ...);

// 6. orElse(null)
REQUEST_ID.orElse(null);
```

Output on JDK 25:

```text
$ java -cp out25 ScopedValueBasics
java 25.0.4.1
1. isBound=false  orElse=no-request
   get() -> java.util.NoSuchElementException: ScopedValue not bound
2. repository sees requestId=req-42 tenant=acme
2. repository sees requestId=req-42/retry-1 tenant=acme
   back in outer scope: req-42
3. call() returned: response for req-43
4. after a throwing scope: isBound=false (handler blew up)
5. bound to null: isBound=true get=null orElse=null
6. orElse(null) -> java.lang.NullPointerException
```

The same file compiled with `--release 21 --enable-preview` and run on JDK 21 gives the same lines 1 to 5 (except that the exception on line 1 has no message), and then:

```text
6. orElse(null) = null
```

What I'd point out to a teammate:

- **Rebinding is shadowing, not mutation.** The retry call saw `req-42/retry-1`, and the outer code saw `req-42` again afterwards. A callee can't change what its caller sees.
- **Line 4 is the fix for the Step 1 bug.** A throwing handler can't leave the value behind.
- **Line 5 is a new trap.** `where(key, null)` is allowed (the Javadoc says the value "can be null"), and then `isBound()` is `true` and `get()` returns `null`. So don't bind a raw header. Generate an ID when it's missing.
- **Line 6 changed in 25.** JEP 506's only API change at finalization: "`ScopedValue.orElse` method no longer accepts `null` as its argument". Code written against the JDK 21 preview that calls `orElse(null)` now throws.

And the stale-tutorial check. `runWhere` compiles on 21 with `--enable-preview`, but on 25:

```text
$ javac --release 25 -d out25 StaleApi.java
StaleApi.java:6: error: cannot find symbol
        ScopedValue.runWhere(REQUEST_ID, "req-1", () -> System.out.println(REQUEST_ID.get()));
                   ^
  symbol:   method runWhere(ScopedValue<String>,String,()->System[...]et()))
  location: class ScopedValue
1 error
```

## Step 4: child tasks (StructuredTaskScope is preview in 25)

> **Preview API.** `StructuredTaskScope` is in its fifth preview in JDK 25 ([JEP 505](https://openjdk.org/jeps/505)). Compile and run with `--enable-preview`, and expect the API to change in later JDKs. `ScopedValue` itself is final.

```java
ScopedValue.where(REQUEST_ID, "req-7").call(() -> {
    // 1. StructuredTaskScope.fork: inherited, no copying
    try (var scope = StructuredTaskScope.open(StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow(),
            cfg -> cfg.withThreadFactory(Thread.ofVirtual().name("fork-", 1).factory()))) {
        var a = scope.fork(() -> Thread.currentThread().getName() + " sees " + seen());
        var b = scope.fork(() -> Thread.currentThread().getName() + " sees " + seen());
        scope.join();
    }

    // 2. Everything unstructured: not inherited
    try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
        exec.submit(ChildTaskDemo::seen).get();
    }
    Thread.ofVirtual().start(...);
    CompletableFuture.supplyAsync(ChildTaskDemo::seen).get();

    // 3. Fix for an executor: capture the value, re-bind inside the task
    String id = REQUEST_ID.get();
    try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
        exec.submit(() -> ScopedValue.where(REQUEST_ID, id).call(ChildTaskDemo::seen)).get();
    }

    // 4. Bindings are captured when the scope is OPENED. Re-binding then forking is rejected.
    try (var scope = StructuredTaskScope.open()) {
        ScopedValue.where(REQUEST_ID, "req-7/child").run(() -> {
            try {
                scope.fork(ChildTaskDemo::seen);
            } catch (StructureViolationException e) {
                System.out.println("fork after re-bind: " + e);
            }
        });
        scope.join();
    }
    return null;
});
```

(This is trimmed: the `println` calls are left out. `seen()` returns `REQUEST_ID.get()`, or `(unbound)` if `isBound()` is false.)

```text
$ java --enable-preview -cp out25p:out25 ChildTaskDemo
request thread:                 req-7
StructuredTaskScope.fork:       fork-1 sees req-7, fork-2 sees req-7
newVirtualThreadPerTaskExecutor: (unbound)
Thread.ofVirtual().start:        (unbound)
CompletableFuture.supplyAsync:   (unbound)
executor + re-bind in task:      req-7
fork after re-bind:              java.util.concurrent.StructureViolationException: Scoped value bindings have changed
after the scope:                 (unbound)
```

This is the part that matters most when you migrate:

1. **Only `fork` inherits.** JEP 506 explains why: the scope's `join()` guarantees the children finish before `run()` returns, so the binding can't outlive its scope. Plain threads, executors, and the common pool behind `CompletableFuture` can't promise that, so they get nothing. JEP 506 says the same about `ForkJoinPool`. If your code used to depend on `InheritableThreadLocal` for `new Thread(...)`, moving to `ScopedValue` makes that value disappear. Check every thread hop.
2. **Executors: pass the value, re-bind in the task.** `ScopedValue.where(REQUEST_ID, id).call(...)` inside the task is two lines. A small `Contexts.wrap(Callable)` helper can do it at every `submit`.
3. **Open the scope inside the binding you want children to see.** The Javadoc says bindings "are captured when creating a StructuredTaskScope". If you re-bind after `open()` and then fork, you get `StructureViolationException`. That's the JDK enforcing the structure, not a bug.

## Step 5: bridge to MDC at the boundary

Your log pattern still says `%X{requestId}`, and SLF4J's MDC [delegates to the underlying framework's MDC](https://www.slf4j.org/manual.html). In Logback 1.6.6 that's `LogbackMDCAdapter`, and `javap` shows it plainly:

```text
public class ch.qos.logback.classic.util.LogbackMDCAdapter implements org.slf4j.spi.MDCAdapter {
  final java.lang.ThreadLocal<java.util.Map<java.lang.String, java.lang.String>> readWriteThreadLocalMap;
  final java.lang.ThreadLocal<java.util.Map<java.lang.String, java.lang.String>> readOnlyThreadLocalMap;
```

So the plan is: the `ScopedValue` is the source of truth, and MDC becomes a copy that lives only as long as the scope. One class owns both:

```java
public final class RequestContext {
    private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
    private RequestContext() {}

    /** Called once, at the boundary (servlet filter, message listener, scheduled job). */
    public static void runRequest(String headerValue, Runnable handler) {
        ScopedValue.where(REQUEST_ID, idFrom(headerValue)).run(() -> withMdc(handler));
    }

    /** Same, for code that returns a value or throws checked exceptions (a servlet filter chain). */
    public static <T> T callRequest(String headerValue, Callable<T> handler) throws Exception {
        return ScopedValue.where(REQUEST_ID, idFrom(headerValue)).call(() -> withMdc(handler).call());
    }

    private static String idFrom(String headerValue) {
        return (headerValue == null || headerValue.isBlank())
                ? "gen-" + UUID.randomUUID().toString().substring(0, 4)   // never bind null
                : headerValue;
    }

    public static String requestId() {
        return REQUEST_ID.orElse("none");
    }

    /**
     * The MDC bridge. Copies the scoped value into Logback's ThreadLocal MDC for the
     * duration of 'work' on THIS thread, then restores whatever was there before.
     */
    public static void withMdc(Runnable work) {
        String previous = MDC.get("requestId");
        MDC.put("requestId", REQUEST_ID.get());
        try {
            work.run();
        } finally {
            if (previous == null) MDC.remove("requestId");
            else MDC.put("requestId", previous);
        }
    }

    /** Same bridge for a value-returning subtask (for StructuredTaskScope.fork). */
    public static <T> Callable<T> withMdc(Callable<T> work) {
        return () -> {
            String previous = MDC.get("requestId");
            MDC.put("requestId", REQUEST_ID.get());
            try {
                return work.call();
            } finally {
                if (previous == null) MDC.remove("requestId");
                else MDC.put("requestId", previous);
            }
        };
    }
}
```

Two details: `REQUEST_ID` is `private` (the JEP calls a scoped value a "capability", so only this class can bind it), and the bridge *restores* the previous MDC value instead of just removing it, in case an outer filter or tracing agent put one there.

In a servlet app the boundary is a filter. This one compiles against `jakarta.servlet-api` 6.1.0. I didn't run it in a container, so the runnable demo below drives `RequestContext` directly:

```java
public class RequestIdFilter implements Filter {
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        String header = ((HttpServletRequest) req).getHeader("X-Request-Id");
        try {
            RequestContext.callRequest(header, () -> {
                ((HttpServletResponse) res).setHeader("X-Request-Id", RequestContext.requestId());
                chain.doFilter(req, res);
                return null;
            });
        } catch (IOException | ServletException | RuntimeException e) {
            throw e;
        } catch (Exception e) {
            throw new ServletException(e);
        }
    }
}
```

Now the same request sequence as Step 1, on the same one-thread pool. Request #3 also fans out to two subtasks: one forked as-is, one wrapped in `RequestContext.withMdc(...)`:

```java
static void fanOut() throws InterruptedException {
    try (var scope = StructuredTaskScope.open(StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow(),
            cfg -> cfg.withThreadFactory(Thread.ofVirtual().name("fork-", 1).factory()))) {
        scope.fork(() -> {
            log.info("price lookup, no bridge  (RequestContext.requestId()={})", RequestContext.requestId());
            return 1;
        });
        scope.fork(RequestContext.withMdc(() -> {
            log.info("stock lookup, withMdc    (RequestContext.requestId()={})", RequestContext.requestId());
            return 2;
        }));
        scope.join();
    }
}
```

```text
$ java --enable-preview -Dlogback.configurationFile=logback.xml -cp "out25p:out25:lib/*" MdcBridgeDemo
http-nio-1     requestId=req-A    handling request #1 (RequestContext.requestId()=req-A)
http-nio-1     requestId=req-B    handling request #2 (RequestContext.requestId()=req-B)
http-nio-1     requestId=NONE     request failed: downstream timeout
http-nio-1     requestId=gen-17dd handling request #3 (RequestContext.requestId()=gen-17dd)
fork-1         requestId=NONE     price lookup, no bridge  (RequestContext.requestId()=gen-17dd)
fork-2         requestId=gen-17dd stock lookup, withMdc    (RequestContext.requestId()=gen-17dd)
http-nio-1     requestId=NONE     pool thread between requests, MDC map = {}
```

Line by line:

- **The leak is gone.** Request #3 has no header and got its own generated ID, not `req-B`. Between requests the pool thread's MDC is `{}`.
- **`fork-1` is the trap nobody mentions.** The scoped value reached the subtask (`RequestContext.requestId()=gen-17dd`), but Logback's `%X` printed `NONE`, because MDC is a `ThreadLocal` and the forked virtual thread has its own, empty one. The Logback manual warns that "a child thread does not automatically inherit a copy of the mapped diagnostic context of its parent". So you need the bridge in every subtask, not just in the filter.
- **The failure line has no ID.** In this demo the `catch` that logs "request failed" sits *outside* `runRequest`, so by then the scope has ended. That's correct behaviour, and it's also a reminder: log errors inside the boundary (for example in an exception handler that runs inside the filter chain), or that log line loses its ID.

### Option: let Logback read the ScopedValue directly

QOS.ch (the Logback maintainers) publishes [logback-scoped-mdc](https://github.com/qos-ch/logback-scoped-mdc), a `ScopedValue`-backed MDC with a `%scopedContext` pattern converter. The only version on Maven Central today is **1.0.0-rc0** (a release candidate), so I'd try it but not standardize on it yet. Config and code:

```xml
<conversionRule conversionWord="scopedContext" class="ch.qos.logback.classic.scoped.ScopedMDCConverter"/>
<!-- pattern: %-14thread requestId=%-8scopedContext{requestId:-NONE} %msg%n -->
```

```java
ScopedMDC.put("requestId", "req-S").run(() -> {
    log.info("request thread");
    try (var scope = StructuredTaskScope.open(StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow(),
            cfg -> cfg.withThreadFactory(Thread.ofVirtual().name("fork-", 1).factory()))) {
        scope.fork(() -> { log.info("price lookup"); return 1; });
        scope.fork(() -> { log.info("stock lookup"); return 2; });
        scope.join();
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
});
log.info("after the scope");
```

```text
main           requestId=req-S    request thread
fork-1         requestId=req-S    price lookup
fork-2         requestId=req-S    stock lookup
main           requestId=NONE     after the scope
```

Both subtasks got the ID with no bridge, and it was gone after the scope. The cost: you change `%X{...}` to `%scopedContext{...}` in your patterns, and anything else that reads the classic MDC (some appenders and JSON encoders do) won't see these values. I haven't checked how specific encoders behave, so test yours.

## When to keep ThreadLocal

JEP 506 says outright that deprecating `ThreadLocal` is a non-goal, and that migration only fits "one-way transmission of unchanging data". A request ID is the textbook case. These aren't:

- **Two-way state**, where a callee writes something for the caller to read later. A scoped value can't do this, by design.
- **Per-thread caches** of expensive, non-thread-safe objects. The JEP's example is `SimpleDateFormat`, though it points out that `DateTimeFormatter` in a `static final` field is the better fix today.
- **Libraries that read their own `ThreadLocal`** (MDC, security-context holders, some tracing agents). You don't migrate those. You bridge at the boundary, as in Step 5.

Also, the Javadoc says scoped values "are designed to be used in fairly small numbers", because each thread has only a small cache for them. One `RequestContext` record holding the ID, tenant, and user is better than five separate scoped values.

On virtual threads specifically: JEP 506 notes that a short-lived virtual thread clears its thread-locals when it ends, so the *pool* leak from Step 1 mostly belongs to platform-thread pools. On a platform-thread Tomcat pool it's real, and on virtual threads you still have the inheritance and fan-out problems from Steps 2 and 4. (For how virtual threads behave under load, see [Virtual Thread Pinning in Java 25](https://gowthammuthuvel.hashnode.dev/virtual-thread-pinning-java-25) and [Spring Boot Virtual Threads and HikariCP](https://gowthammuthuvel.hashnode.dev/spring-boot-virtual-threads-hikaricp-pool-exhausted). If virtual threads are new to you, start with [Mastering Concurrency in Java](https://gowthammuthuvel.hashnode.dev/mastering-concurrency-in-java-from-traditional-multithreading-to-virtual-threads).)

## Migration checklist

- [ ] Find every `MDC.put("requestId", ...)`, `ThreadLocal<String>`, and `InheritableThreadLocal` that carries request context.
- [ ] Create one owner class with a `private static final ScopedValue`. Bind it in one place: the first filter, or the message listener.
- [ ] Never bind a raw header. `where(key, null)` gives you `isBound() == true` and a `null` value. Generate an ID instead.
- [ ] Replace `orElse(null)` (it throws on 25) with a real default or `orElseThrow(...)`.
- [ ] Replace `runWhere`/`callWhere` from old tutorials with `where(...).run(...)` and `where(...).call(...)`.
- [ ] Bridge into MDC at the boundary with save-and-restore in `finally`. Bridge again inside each forked subtask, or try `logback-scoped-mdc`.
- [ ] Audit every thread hop. Executors, `Thread.ofVirtual().start`, and `CompletableFuture` didn't inherit in my run, and Spring's `@Async` runs on an executor, so treat it the same way. Capture the value and re-bind it in the task.
- [ ] Open `StructuredTaskScope` *inside* the binding (preview in 25: `--enable-preview`).
- [ ] Log failures inside the scope, or the error line loses its ID.
- [ ] Add a test that runs a failing request followed by one without a header on a single-thread pool, and asserts the second one doesn't log the first one's ID.

## Related posts

- [Virtual Thread Pinning in Java 25: What Still Pins After JEP 491](https://gowthammuthuvel.hashnode.dev/virtual-thread-pinning-java-25)
- [Spring Boot Virtual Threads and HikariCP: Why Your Pool Runs Dry](https://gowthammuthuvel.hashnode.dev/spring-boot-virtual-threads-hikaricp-pool-exhausted)
- [Mastering Concurrency in Java: From Traditional Multithreading to Virtual Threads](https://gowthammuthuvel.hashnode.dev/mastering-concurrency-in-java-from-traditional-multithreading-to-virtual-threads)
