ThreadLocal vs. Scoped Values:The Virtual Thread Migration No One Warned You About
ThreadLocal was the hack that let Java survive the era of application servers. Scoped Values are its replacement for the Loom era — and they behave fundamentally differently in ways that will silently break applications migrating to virtual threads.
When Project Loom landed virtual threads in Java 21, the migration story looked simple: swap your thread pool executor for Executors.newVirtualThreadPerTaskExecutor(), flip spring.threads.virtual.enabled=true, and enjoy 10× more throughput. What the migration guides don’t tell you is what happens to all the ThreadLocal variables quietly embedded in your application, your framework, and your third-party dependencies. The answer, in many cases, is silent breakage, heap pollution, and behaviour that looks correct in testing but fails at production scale.
This article is about the concurrency primitive that sits underneath all of it — and the replacement that JEP 506 finalised in JDK 25. Understanding why ThreadLocal and ScopedValue are not just different spellings of the same idea is essential before you migrate anything to virtual threads.
ThreadLocal was designed for a world of pooled platform threads that lived for the life of the application. Virtual threads are born and die in milliseconds. The two worlds have incompatible assumptions — and your code was probably written for the old one.
1. A Brief History: Why ThreadLocal Existed
ThreadLocal landed in Java 1.2, in 1998. The problem it solved was real and pressing: application servers ran dozens of threads, each processing one request for its entire lifetime. You needed a way to pass context — a database connection, a user principal, a transaction — down a deep call stack without threading it through every method signature. ThreadLocal gave each thread its own isolated copy of a value, readable anywhere in that thread’s call stack, without method parameters.
For two decades, this worked acceptably well. A typical thread pool had 200 threads. Each thread lived for hours. The overhead of giving each one a small map of thread-local data was negligible. Memory leaks were possible if you forgot to call remove(), but in practice the threads outlived most mistakes. The pattern became idiomatic across frameworks: Spring Security stores the SecurityContext in a ThreadLocal. MDC logging stores trace IDs in a ThreadLocal. Hibernate’s session management historically touched ThreadLocal. It is everywhere.
2. The Three Fundamental Flaws
The OpenJDK team articulated the core problems with ThreadLocal clearly in the JEP series. They are worth understanding precisely, not just as a list:
| Flaw | What it means in practice | How bad with virtual threads |
|---|---|---|
| Unconstrained mutability | Any code anywhere in the call stack that can call .get() can also call .set() — at any time, in any order. There is no caller/callee contract. | Bad — mutations are invisible and unpredictable across millions of short-lived threads |
| Unbounded lifetime | The value lives until the thread ends or you explicitly call remove(). Forget remove() in a finally block and you leak. | Critical — with pooled platform threads leaks are slow; with virtual threads they accumulate instantly |
| Expensive inheritance | Child threads created from a parent inherit all of its ThreadLocal state by copying it. Each child allocates its own storage for every parent-written variable, even if it never uses them. | Critical — millions of child virtual threads each copying parent thread-local maps destroys the heap |
JEP 444 itself warns directly: virtual threads support
ThreadLocalfor backward compatibility, but because virtual threads can be very numerous, you should use thread locals only after careful consideration, and you should not use them to pool costly resources. The JDK team removed many internal uses ofThreadLocalfromjava.basespecifically to reduce memory footprint at virtual-thread scale.
3. The Silent Breaks: What Actually Goes Wrong
Here is where the theory becomes production risk. These are not hypothetical edge cases — they are the breakage patterns that engineers have actually hit in 2024–2025 migrations.
Break #1 — ThreadLocal caching no longer caches
A common and long-standing pattern: use ThreadLocal to cache an expensive-to-create, non-thread-safe object so it’s created once per thread and reused across many requests. SimpleDateFormat is the canonical example. With a pool of 200 platform threads, this object is instantiated 200 times and reused for the life of the application. With virtual threads, virtual threads are never pooled and never reused by unrelated tasks. Every new virtual thread gets a fresh ThreadLocal state. So your “cached” object is now created once per request — and you’re making a million requests.
✗ ThreadLocal “cache” — creates one instance per virtual thread, not per pool thread
// Looks like a cache. Is NOT a cache with virtual threads.
private static final ThreadLocal<SimpleDateFormat> FORMATTER =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
// With a platform thread pool of 200: ~200 instances total.
// With virtual threads: one instance per request = millions of instances.
void formatDate(Date d) {
System.out.println(FORMATTER.get().format(d));
// Missing FORMATTER.remove() in finally = memory leak on top
}
Break #2 — Context not inherited by StructuredTaskScope.fork()
This is the subtlest and most dangerous failure mode. InheritableThreadLocal works by copying the parent’s thread-local map when a child thread is spawned via new Thread(). But when using Structured Concurrency‘s StructuredTaskScope.fork() — which is the right way to spawn child work in the Loom model — the inheritance does not happen. The child virtual thread prints null. Your security context, your MDC trace ID, your tenant identifier: all silently absent.
✗ ThreadLocal — child thread from scope.fork() does NOT inherit the value
// Compile and run: JDK 21+ with --enable-preview (JDK 21-24) or JDK 25+
public static final ThreadLocal<String> REQUEST_ID = new ThreadLocal<>();
public static void main(String[] args) throws InterruptedException {
REQUEST_ID.set("req-abc-123");
try (var scope = StructuredTaskScope.open()) {
System.out.println("Parent: " + REQUEST_ID.get()); // "req-abc-123"
scope.fork(() -> {
// SILENT BUG: prints null — context not inherited
System.out.println("Child: " + REQUEST_ID.get());
return null;
});
scope.join();
}
REQUEST_ID.remove();
}
✓ ScopedValue — automatically inherited by all child threads in the scope
// JDK 25 (final) — no --enable-preview flag needed
public static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
public static void main(String[] args) throws InterruptedException {
ScopedValue.where(REQUEST_ID, "req-abc-123").run(() -> {
try (var scope = StructuredTaskScope.open()) {
System.out.println("Parent: " + REQUEST_ID.get()); // "req-abc-123"
scope.fork(() -> {
// CORRECT: prints "req-abc-123" — inherited automatically
System.out.println("Child: " + REQUEST_ID.get());
return null;
});
scope.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
Break #3 — Heap explosion from unbounded inheritance copies
The third failure is a performance cliff rather than a logical bug. When a parent thread spawns child threads, each child allocates its own copy of every ThreadLocal written by the parent. With platform threads you might spawn a handful of children. With virtual threads handling a million concurrent requests, each spawning structured sub-tasks, the copies multiply into heap exhaustion. ScopedValue avoids this entirely: because values are immutable and the JVM knows they will never be modified by children, child threads can share the parent’s binding without copying it.
Memory Model: ThreadLocal vs. ScopedValue — Child Thread Inheritance
Heap allocations per 1,000 child threads spawned from a parent with 5 thread-local variables set. Lower is better.

4. ScopedValue: The Full Design
JEP 506 finalised ScopedValue in JDK 25 after five rounds of preview and incubation beginning with JDK 20. The final API is clean. A ScopedValue is declared as a static final field — same as ThreadLocal. The difference is in how you bind a value to it and how long that binding lasts.
You bind a value using ScopedValue.where(KEY, value).run() or .call(). Inside the lambda, any code that calls KEY.get() receives the bound value. When the lambda returns, the binding is gone — automatically, with no remove() required. The lifetime of shared data is directly visible in the syntactic structure of the code. You can nest bindings: a callee can rebind a scoped value to a different value for its own scope without affecting the caller’s binding.
| Property | ThreadLocal | ScopedValue |
|---|---|---|
| Mutability | Mutable — .set() callable anywhere, anytime | Immutable — bound once per scope, cannot be changed mid-scope |
| Lifetime | Lives until .remove() or thread death. Easy to leak. | Automatically bounded to the .run() / .call() lambda. GC’d on exit. |
| Child thread inheritance | Copies the full map per child. Expensive at scale. Broken for scope.fork(). | Shared (read-only) binding — zero copy cost. Works correctly with scope.fork(). |
| Rebinding in callees | Possible via .set() — but visible globally to all callers above in stack | Scoped rebinding — callee’s new value is invisible to callers |
| Two-way data flow | Supported — callee can .set() to communicate back to caller | Not supported — values flow down only, never up. By design. |
| Memory footprint | O(threads × variables) — scales with thread count | O(nesting depth) — scales with call depth, not thread count |
| Performance at scale | Degrades badly at millions of virtual threads | Designed for millions of virtual threads — constant-time lookup |
| JDK status (2026) | Still available, not deprecated. Actively maintained. | Finalized in JDK 25 (JEP 506). Production-ready. |
ThreadLocal vs. ScopedValue — Design Quality Comparison
Scored across six design dimensions relevant to virtual thread applications. Higher is better.

5. The Side-by-Side: Framework Context Pattern
The canonical use case for both APIs is framework context propagation — passing a request-scoped object (user principal, tenant ID, security context) into deep call stacks without method parameter threading. Here is the complete before-and-after for that pattern, from a simplified framework class:
| Concern | ✗ ThreadLocal — traditional pattern | ✓ ScopedValue — Loom-era pattern |
|---|---|---|
| Field declaration | private static final ThreadLocal<RequestCtx> CTX = new ThreadLocal<>();Mutable. Lives until remove() or thread death. | public static final ScopedValue<RequestCtx> CTX = ScopedValue.newInstance();Immutable. Auto-released when scope exits. |
| Binding & lifetime | CTX.set(ctx); try { App.handle(req, res); } finally { CTX.remove(); // Must NOT forget this }Manual cleanup required. Missed remove() = leak on every request. | ScopedValue .where(CTX, ctx) .run(() -> App.handle(req, res));Binding auto-released when lambda returns. No finally needed. |
| Reading the value | static RequestCtx getCtx() { return CTX.get(); }Works, but any callee can silently read or write at any point. | static RequestCtx getCtx() { return CTX.get(); }Read-only for all callees. No mutation path exists. |
| Mutation by callee | static void setCtx(RequestCtx c) { CTX.set(c); // Dangerous — globally // visible to all callers }.set() is public. Any code in the call stack can overwrite the value silently. | // No .set() method on ScopedValue. // Compile-time impossible to mutate. // Rebind is scoped — invisible to caller.Mutation is a compile error. Callees can rebind locally without affecting callers. |
6. When Should You NOT Migrate to ScopedValue?
The OpenJDK team is explicit about this: it is not a goal to require migration away from thread-local variables, or to deprecate the existing ThreadLocal API. There are legitimate uses of ThreadLocal that ScopedValue genuinely cannot replace. Knowing the difference is as important as knowing when to migrate.
| Use case | Right tool | Why |
|---|---|---|
| Request context propagation (user, principal, tenant, trace ID) | ScopedValue ✓ | One-way, immutable, lifetime matches request — perfect fit |
| Security context (Spring Security-style) | ScopedValue ✓ | Context is set at request entry, read by callees — never needs upward mutation |
| MDC / structured logging trace IDs | ScopedValue ✓ | Same trace ID for request duration, child threads need it — ideal ScopedValue shape |
| Caching expensive non-thread-safe objects (SimpleDateFormat, etc.) | Platform thread pool + ThreadLocal | Caching only works when threads are reused. Use a fixed pool; virtual threads are never reused. |
| Two-way data flow (callee writes back to caller via thread context) | ThreadLocal (or refactor) | ScopedValue is one-way by design. If callees must push state up, ThreadLocal or explicit return values. |
| Unstructured global mutable state across arbitrary thread lifetimes | ThreadLocal (reluctantly) | ScopedValue requires structured lifetimes. Unstructured mutation patterns cannot migrate cleanly. |
| Transaction management (read + mutate in same thread scope) | ThreadLocal or explicit context passing | Transaction state is inherently mutable mid-scope. ScopedValue cannot model this pattern. |
7. Migration Risk Assessment: What to Audit in Your Codebase
Before you enable virtual threads in your application, here is a systematic audit approach. The key insight is that not every ThreadLocal is a bug — but every ThreadLocal is a decision point that deserves review in the context of Loom.
| Pattern to find | Risk | Recommended action |
|---|---|---|
ThreadLocal.withInitial() for expensive objects | High — creates instance per virtual thread, not per pool thread. Memory explosion. | Replace caching with a fixed platform thread pool + ThreadLocal, or a concurrent cache (Caffeine). |
ThreadLocal without remove() in finally | High — guaranteed leak per virtual thread. With millions of threads, heap exhaustion. | Add remove() in finally immediately, or migrate to ScopedValue. |
InheritableThreadLocal used with structured concurrency | High — inheritance is broken for scope.fork(). Context will be silently null in child. | Migrate to ScopedValue — the only correct solution for structured concurrency context. |
ThreadLocal for one-way context (request ID, tenant, principal) | Medium — works but wastes memory and risks leaks. Structurally wrong for Loom. | Ideal migration candidate to ScopedValue. Cleaner, safer, more performant. |
ThreadLocal.set() called deep inside callees | Medium — two-way data flow. ScopedValue cannot model this. | Refactor to explicit return values or a mutable context object passed as a parameter. |
Framework-owned ThreadLocal (Spring Security, Hibernate, MDC) | Medium — depends entirely on framework version and Loom support maturity. | Verify your framework version supports virtual threads. Spring Boot 3.2+ handles this. Older versions may not. |
ThreadLocal in third-party libraries (HikariCP, etc.) | Medium — HikariCP’s ConcurrentBag uses ThreadLocal for connection affinity, which breaks with virtual threads. | Use HikariCP 5.1.0+, which has virtual thread compatibility fixes. Audit other libraries similarly. |
Diagnostic tip from JEP 444: Run your application with -Djdk.traceVirtualThreadLocals=true and you will get a stack trace every time a virtual thread mutates a thread-local variable. This is the fastest way to surface hidden ThreadLocal usage in third-party code that you may not control directly.
JDK 25 note: ScopedValue is finalized and requires no flags in JDK 25+. On JDK 21–24, it was a preview API requiring --enable-preview at compile and runtime. The final API surface changed between preview rounds — most notably, callWhere/runWhere were removed in JDK 24 (JEP 487) in favour of the fully fluent .where().run() / .where().call() chain. Code written for JDK 21–23 preview will need minor updates to compile cleanly on JDK 25.
8. A Complete Working Example: JDK 25 Final API
This snippet uses the finalized JDK 25 API — no preview flags. It demonstrates context propagation, child-thread inheritance through scope.fork(), and scoped rebinding where a callee overrides the context for its own scope without affecting the outer binding.
✓ JDK 25 — compile with: javac ScopedDemo.java && java ScopedDemo
import java.util.concurrent.StructuredTaskScope;
public class ScopedDemo {
// Declared static final — just like ThreadLocal, but immutable
static final ScopedValue<String> TENANT = ScopedValue.newInstance();
static final ScopedValue<String> USER_ID = ScopedValue.newInstance();
public static void main(String[] args) throws Exception {
// Bind TENANT and USER_ID for the duration of this lambda
ScopedValue.where(TENANT, "acme-corp")
.where(USER_ID, "user-42")
.run(() -> {
System.out.println("Request scope — Tenant: " + TENANT.get());
System.out.println("Request scope — UserId: " + USER_ID.get());
// Fork child virtual threads — ScopedValues are inherited automatically
try (var scope = StructuredTaskScope.open()) {
scope.fork(() -> {
// Correctly prints "acme-corp" — inherited, zero-copy
System.out.println("Child A — Tenant: " + TENANT.get());
return null;
});
// Callee can rebind for its own scope without affecting the outer binding
scope.fork(() -> {
ScopedValue.where(TENANT, "beta-org").run(() -> {
System.out.println("Child B (rebound) — Tenant: " + TENANT.get()); // "beta-org"
System.out.println("Child B — UserId still: " + USER_ID.get()); // "user-42"
});
return null;
});
scope.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// Outer binding is completely unaffected by Child B's rebind
System.out.println("After fork — Tenant still: " + TENANT.get()); // "acme-corp"
});
// Bindings are automatically released here — no remove() needed
System.out.println("Outside scope — isBound: " + TENANT.isBound()); // false
}
}
Expected output: Each fork correctly inherits TENANT=acme-corp and USER_ID=user-42. Child B’s rebind to beta-org is scoped to its own lambda and does not affect the parent. After the outer .run() exits, TENANT.isBound() returns false — the binding is gone automatically.
9. What We Have Learned
This article examined one of the most under-discussed migration risks in the Project Loom story. We started with the history of ThreadLocal — a 1998 primitive designed for pooled, long-lived platform threads — and traced why its three fundamental design flaws (unconstrained mutability, unbounded lifetime, expensive inheritance) go from manageable to dangerous when you move to virtual threads. We looked at the three concrete failure modes that engineers have hit in production migrations: ThreadLocal caching that silently stops caching because virtual threads are never reused; InheritableThreadLocal context that silently becomes null in child threads spawned via StructuredTaskScope.fork(); and heap explosion from the O(threads × variables) copy cost of inheritance at virtual-thread scale.
We introduced ScopedValue — finalized in JDK 25 via JEP 506 after five preview and incubation rounds in JDKs 20–24 — and explained why it solves all three problems: it is immutable (no unconstrained mutation), its lifetime is structurally bounded to its enclosing scope (no remove(), no leaks), and its child-thread bindings are shared read-only (zero copy cost).
We provided a full side-by-side comparison table, a working before-and-after code example for framework context propagation, and a complete working JDK 25 snippet you can compile and run today. We also covered when not to migrate — caching, two-way data flow, and transaction management — because ScopedValue‘s one-way immutable model is intentionally the wrong tool for those cases.
Finally, we gave you a concrete audit table of the patterns to search for in your codebase before you enable virtual threads, and the -Djdk.traceVirtualThreadLocals=true flag to surface hidden ThreadLocal usage automatically at runtime.

