← Back to Articles
6/8/2026Guest Transmission

threadlocal springboot guide

ThreadLocal in Spring Boot - Complete Guide

When, Why, How, and Every Pitfall


Table of Contents

  1. What is ThreadLocal and Why Does It Exist?
  2. How ThreadLocal Works Internally
  3. Spring Boot's Own ThreadLocals You Use Every Day
  4. Use Case 1: Request Correlation ID Propagation
  5. Use Case 2: Multi-Tenant Database Routing
  6. Use Case 3: User Context Without Method Parameters
  7. Use Case 4: MDC for Structured Logging
  8. Use Case 5: Read vs Write Replica Routing
  9. The ThreadLocal Memory Leak Problem
  10. ThreadLocal and @Async - The Silent Bug
  11. ThreadLocal and CompletableFuture - The Same Problem
  12. InheritableThreadLocal
  13. TransmittableThreadLocal - The Real Solution
  14. Context Propagation Patterns
  15. Testing Code That Uses ThreadLocal

1. What is ThreadLocal and Why Does It Exist?

The Core Problem

Imagine a web server handling 1000 simultaneous requests. Each request has:

  • A user ID (who made the request)
  • A correlation ID (for tracing the request through logs)
  • A tenant ID (which company's data to show)
  • A locale (which language to respond in)

The naive approach passes these as method parameters:

// UGLY: userId, correlationId, tenantId passed through every single method
public Order placeOrder(Long userId, String correlationId, String tenantId, OrderRequest req) {
    validateUser(userId, correlationId, tenantId);
    reserveInventory(userId, correlationId, tenantId, req.getItems());
    processPayment(userId, correlationId, tenantId, req.getPaymentInfo());
    sendConfirmation(userId, correlationId, tenantId, req.getEmail());
}

Every method in the entire call chain needs these parameters even if it does not directly use them — it must pass them down to methods it calls.

What ThreadLocal Solves

ThreadLocal provides per-thread storage. Each thread has its own independent copy of the variable. No other thread can see or modify it.

In a Spring Boot web application:

  • Each HTTP request is handled by one thread (from Tomcat's thread pool)
  • You store the request context in ThreadLocal at the filter/interceptor level
  • Any code running on that thread can read it without parameter passing
  • When the request ends, you clean up the ThreadLocal
// CLEAN: set once at the filter level, read anywhere without parameter passing
public Order placeOrder(OrderRequest req) {
    validateUser();                           // reads UserContext.get()
    reserveInventory(req.getItems());         // reads UserContext.get()
    processPayment(req.getPaymentInfo());     // reads UserContext.get()
    sendConfirmation(req.getEmail());         // reads UserContext.get()
}

ThreadLocal is Not a Global Variable

This is the most important distinction:

Global variable:  Thread 1 writes value X  -->  Thread 2 reads value X  (shared)
ThreadLocal:      Thread 1 writes value X  -->  Thread 2 reads null     (isolated)
                  Thread 2 writes value Y  -->  Thread 1 still reads X  (isolated)

Each thread's ThreadLocal is completely independent from every other thread's ThreadLocal, even for the same ThreadLocal object.


2. How ThreadLocal Works Internally

Understanding the internals prevents the most common bugs.

The Storage Model

ThreadLocal<String> tl = new ThreadLocal<>();

Each Thread object has a field:  thread.threadLocalMap = new ThreadLocalMap()

thread.threadLocalMap is a Map where:
  Key   = the ThreadLocal object itself (WeakReference)
  Value = the value you stored

Thread-1's map: { tl -> "request-123" }
Thread-2's map: { tl -> "request-456" }
Thread-3's map: { tl -> null          }

When you call tl.get(), Java looks up Thread.currentThread().threadLocalMap.get(tl).
When you call tl.set(value), Java writes Thread.currentThread().threadLocalMap.put(tl, value).

Why WeakReference Matters

The key in ThreadLocalMap is a WeakReference to the ThreadLocal object. This means:

  • If the ThreadLocal object goes out of scope (no strong references), the key can be garbage collected
  • BUT the value is NOT a WeakReference
  • The value can remain in the map forever even after the key is collected
  • This is the source of the memory leak (covered in Section 9)

The ThreadLocal API

public class ThreadLocal<T> {
 
    // Get the value for the current thread. Returns null if never set.
    public T get();
 
    // Set the value for the current thread.
    public void set(T value);
 
    // Remove the value for the current thread.
    // ALWAYS call this in a finally block. Never skip it.
    public void remove();
 
    // Subclass and override this to provide a default value
    protected T initialValue() { return null; }
 
    // Factory method for ThreadLocal with an initial value lambda
    public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier);
}

3. Spring Boot's Own ThreadLocals You Use Every Day

Spring Boot uses ThreadLocal extensively internally. Understanding this demystifies a lot of Spring "magic".

SecurityContextHolder (Spring Security)

// Spring Security stores authentication in a ThreadLocal
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
 
// How it works internally (simplified):
public class ThreadLocalSecurityContextHolderStrategy {
    private static final ThreadLocal<SecurityContext> CONTEXT = new ThreadLocal<>();
 
    public SecurityContext getContext() {
        SecurityContext ctx = CONTEXT.get();
        if (ctx == null) {
            ctx = SecurityContextHolder.createEmptyContext();
            CONTEXT.set(ctx);
        }
        return ctx;
    }
}
 
// This is why @PreAuthorize, @Secured, and @WithMockUser work without parameter passing:
@GetMapping("/profile")
public UserProfile getProfile() {
    // No userId parameter needed - Spring Security reads it from ThreadLocal
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    String username = auth.getName();  // ThreadLocal magic
    return userService.getProfile(username);
}

RequestContextHolder (Spring MVC)

// Spring MVC stores the current HttpServletRequest in ThreadLocal
HttpServletRequest request = ((ServletRequestAttributes)
    RequestContextHolder.getRequestAttributes()).getRequest();
 
// Useful for accessing request details deep in the service layer
// WITHOUT passing HttpServletRequest as a parameter
@Service
public class AuditService {
    public void logAction(String action) {
        HttpServletRequest request = ((ServletRequestAttributes)
            RequestContextHolder.getRequestAttributes()).getRequest();
 
        String ipAddress = request.getRemoteAddr();
        String userAgent = request.getHeader("User-Agent");
 
        auditRepository.save(new AuditLog(action, ipAddress, userAgent));
    }
}

TransactionSynchronizationManager (Spring Data)

// Spring's @Transactional stores the active database connection in ThreadLocal
// This is how one @Transactional method calls another and they share the same connection
 
// Internally (simplified):
public abstract class TransactionSynchronizationManager {
    private static final ThreadLocal<Map<Object, Object>> resources =
        new NamedThreadLocal<>("Transactional resources");
    // resources maps DataSource -> Connection
}
 
// This is why calling a @Transactional method from within another @Transactional
// method reuses the same transaction (PROPAGATION_REQUIRED behavior):
@Transactional
public void outerMethod() {
    // Opens connection, stores in ThreadLocal
    innerMethod();  // Sees the SAME connection from ThreadLocal
}
 
@Transactional
public void innerMethod() {
    // Reads existing connection from ThreadLocal - does NOT open a new one
}

4. Use Case 1: Request Correlation ID Propagation

Every production system needs a correlation ID (also called trace ID or request ID) to link all log lines for a single request across dozens of microservices and log files.

The Filter: Set Once, Available Everywhere

/**
 * Servlet filter that runs for every HTTP request.
 * Extracts or generates a correlation ID and stores it in ThreadLocal.
 * Cleans up in the finally block.
 */
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)  // Run before all other filters
public class CorrelationIdFilter extends OncePerRequestFilter {
 
    private static final String HEADER_NAME = "X-Correlation-Id";
 
    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {
 
        // Use correlation ID from upstream service, or generate a new one
        String correlationId = request.getHeader(HEADER_NAME);
        if (correlationId == null || correlationId.isBlank()) {
            correlationId = UUID.randomUUID().toString();
        }
 
        // Store in ThreadLocal via MDC (Logback's built-in ThreadLocal)
        MDC.put("correlationId", correlationId);
 
        // Store in our own ThreadLocal for non-logging uses
        RequestContext.setCorrelationId(correlationId);
 
        // Echo the correlation ID back in the response header
        response.setHeader(HEADER_NAME, correlationId);
 
        try {
            filterChain.doFilter(request, response);
        } finally {
            // CRITICAL: always clear in finally, even if request handler throws
            MDC.clear();
            RequestContext.clear();
        }
    }
}

The ThreadLocal Holder

/**
 * Holds request-scoped context data for the current thread.
 * Set by CorrelationIdFilter at the start of every request.
 * Read by any code running on the same thread.
 */
public final class RequestContext {
 
    private static final ThreadLocal<Context> CONTEXT = new ThreadLocal<>();
 
    private RequestContext() {}
 
    public static void set(Context ctx) {
        CONTEXT.set(ctx);
    }
 
    public static void setCorrelationId(String id) {
        Context ctx = CONTEXT.get();
        if (ctx == null) {
            ctx = new Context();
            CONTEXT.set(ctx);
        }
        ctx.setCorrelationId(id);
    }
 
    public static String getCorrelationId() {
        Context ctx = CONTEXT.get();
        return ctx != null ? ctx.getCorrelationId() : null;
    }
 
    public static void clear() {
        CONTEXT.remove();  // Always remove(), never set(null)
    }
 
    @Data
    public static class Context {
        private String correlationId;
        private String userId;
        private String tenantId;
        private Instant requestStartTime = Instant.now();
    }
}

Using the Correlation ID Deep in the Stack

@Service
@Slf4j
public class PaymentService {
 
    public PaymentResult processPayment(PaymentRequest request) {
        // No correlationId parameter needed - read from ThreadLocal
        String correlationId = RequestContext.getCorrelationId();
 
        // Automatically appears in every log line because MDC is also set
        log.info("Processing payment. amount={}", request.getAmount());
        // Log output: 2026-05-01 10:23:45 [correlationId=abc-123] INFO PaymentService - Processing payment. amount=599.00
 
        // Pass correlation ID to downstream REST calls
        return paymentGateway.charge(request)
            .header("X-Correlation-Id", correlationId);
    }
}

logback-spring.xml - MDC in Log Pattern

<configuration>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>
                %d{yyyy-MM-dd HH:mm:ss} [correlationId=%X{correlationId}] [thread=%thread] %-5level %logger{36} - %msg%n
            </pattern>
        </encoder>
    </appender>
</configuration>

Now every log line automatically includes the correlation ID without any code change.


5. Use Case 2: Multi-Tenant Database Routing

SaaS applications serve multiple companies (tenants). Each tenant's data is isolated, often in separate databases or schemas.

/**
 * ThreadLocal holding the current tenant identifier.
 * Set by TenantFilter at the start of each request.
 */
public final class TenantContext {
 
    private static final ThreadLocal<String> CURRENT_TENANT =
        ThreadLocal.withInitial(() -> "default");
 
    private TenantContext() {}
 
    public static void setTenantId(String tenantId) {
        if (tenantId == null || tenantId.isBlank()) {
            throw new IllegalArgumentException("Tenant ID cannot be null or blank");
        }
        CURRENT_TENANT.set(tenantId);
    }
 
    public static String getTenantId() {
        return CURRENT_TENANT.get();
    }
 
    public static void clear() {
        CURRENT_TENANT.remove();
    }
}
 
/**
 * Filter that extracts tenant ID from the request.
 * Could come from: subdomain (acme.myapp.com), JWT claim, header.
 */
@Component
@Order(2)
public class TenantFilter extends OncePerRequestFilter {
 
    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain chain) throws ServletException, IOException {
 
        // Option 1: From subdomain - acme.myapp.com -> tenantId = "acme"
        String tenantId = extractFromSubdomain(request);
 
        // Option 2: From JWT claim (after authentication)
        // String tenantId = ((JwtAuthToken) SecurityContextHolder
        //     .getContext().getAuthentication()).getTenantId();
 
        // Option 3: From header
        // String tenantId = request.getHeader("X-Tenant-Id");
 
        TenantContext.setTenantId(tenantId);
 
        try {
            chain.doFilter(request, response);
        } finally {
            TenantContext.clear();
        }
    }
 
    private String extractFromSubdomain(HttpServletRequest request) {
        String host = request.getServerName();  // "acme.myapp.com"
        String[] parts = host.split("\\.");
        return parts.length >= 3 ? parts[0] : "default";
    }
}
 
/**
 * Routing DataSource that reads TenantContext and connects to the right database.
 */
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
 
    @Override
    protected Object determineCurrentLookupKey() {
        return TenantContext.getTenantId();
    }
}
 
/**
 * Service code is completely unaware of multi-tenancy.
 * The correct database is selected transparently.
 */
@Service
public class UserService {
 
    private final UserRepository userRepository;
 
    public List<User> getAllUsers() {
        // Automatically queries the correct tenant's database
        // TenantContext.getTenantId() is read by TenantRoutingDataSource
        return userRepository.findAll();
    }
}

6. Use Case 3: User Context Without Method Parameters

After authentication, the user's details should be accessible anywhere without passing them through every method.

/**
 * Immutable user context stored in ThreadLocal after authentication.
 * Richer than SecurityContextHolder - includes app-specific fields.
 */
@Value  // Lombok: immutable, all-args constructor, getters
public class UserContext {
 
    private final Long userId;
    private final String email;
    private final String fullName;
    private final Set<String> roles;
    private final String tenantId;
    private final String locale;
 
    public boolean hasRole(String role) {
        return roles.contains(role);
    }
 
    public boolean isAdmin() {
        return hasRole("ROLE_ADMIN");
    }
}
 
/**
 * ThreadLocal holder for UserContext.
 */
public final class UserContextHolder {
 
    private static final ThreadLocal<UserContext> USER_CONTEXT = new ThreadLocal<>();
 
    private UserContextHolder() {}
 
    public static void set(UserContext ctx) {
        USER_CONTEXT.set(Objects.requireNonNull(ctx, "UserContext cannot be null"));
    }
 
    public static UserContext get() {
        UserContext ctx = USER_CONTEXT.get();
        if (ctx == null) {
            throw new IllegalStateException(
                "UserContext not set on current thread. " +
                "Ensure AuthenticationFilter runs before accessing UserContext."
            );
        }
        return ctx;
    }
 
    /**
     * Returns empty Optional instead of throwing - use in non-secured endpoints.
     */
    public static Optional<UserContext> getOptional() {
        return Optional.ofNullable(USER_CONTEXT.get());
    }
 
    public static void clear() {
        USER_CONTEXT.remove();
    }
}
 
/**
 * Spring Security filter that populates UserContext from the JWT token.
 */
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
 
    private final JwtService jwtService;
 
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
            FilterChain chain) throws ServletException, IOException {
 
        String token = extractBearerToken(request);
 
        if (token != null) {
            try {
                JwtClaims claims = jwtService.validateAndParse(token);
 
                UserContext ctx = UserContext.builder()
                    .userId(claims.getUserId())
                    .email(claims.getEmail())
                    .fullName(claims.getFullName())
                    .roles(claims.getRoles())
                    .tenantId(claims.getTenantId())
                    .locale(claims.getLocale())
                    .build();
 
                UserContextHolder.set(ctx);
 
                // Also set Spring Security context (for @PreAuthorize etc.)
                UsernamePasswordAuthenticationToken auth =
                    new UsernamePasswordAuthenticationToken(ctx, null, buildAuthorities(ctx));
                SecurityContextHolder.getContext().setAuthentication(auth);
 
            } catch (JwtException e) {
                response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token");
                return;
            }
        }
 
        try {
            chain.doFilter(request, response);
        } finally {
            UserContextHolder.clear();
            SecurityContextHolder.clearContext();
        }
    }
}
 
/**
 * Service that uses UserContext without any method parameters.
 */
@Service
@Slf4j
public class OrderService {
 
    public Order placeOrder(PlaceOrderRequest request) {
        UserContext user = UserContextHolder.get();  // No userId parameter needed
 
        log.info("Order placed by userId={}, tenantId={}",
            user.getUserId(), user.getTenantId());
 
        if (!user.hasRole("ROLE_CUSTOMER")) {
            throw new AccessDeniedException("Only customers can place orders");
        }
 
        return Order.builder()
            .userId(user.getUserId())
            .tenantId(user.getTenantId())
            .items(request.getItems())
            .build();
    }
}

7. Use Case 4: MDC for Structured Logging

MDC (Mapped Diagnostic Context) is Logback and Log4j's built-in ThreadLocal designed specifically for enriching log messages.

/**
 * MDC automatically threads context through all log statements
 * on the current thread without any code changes in service classes.
 *
 * MDC.put(key, value) stores in a ThreadLocal<Map<String, String>>
 * %X{key} in the log pattern reads from that map
 */
@Component
@Order(1)
public class MdcEnrichmentFilter extends OncePerRequestFilter {
 
    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain chain) throws ServletException, IOException {
 
        try {
            // These values appear in EVERY log line for this request
            MDC.put("requestId",  getOrGenerateRequestId(request));
            MDC.put("method",     request.getMethod());
            MDC.put("path",       request.getRequestURI());
            MDC.put("clientIp",   getClientIp(request));
            MDC.put("userAgent",  request.getHeader("User-Agent"));
 
            // Add after authentication completes
            // (UserContextHolder is populated by JwtAuthFilter which runs after this)
 
            chain.doFilter(request, response);
 
            MDC.put("statusCode", String.valueOf(response.getStatus()));
 
        } finally {
            // MDC.clear() removes ALL keys set on this thread.
            // Logback will AUTOMATICALLY propagate MDC to child threads
            // if you use MDCAdapter - but not to @Async threads (covered below).
            MDC.clear();
        }
    }
 
    private String getOrGenerateRequestId(HttpServletRequest request) {
        String id = request.getHeader("X-Request-Id");
        return (id != null && !id.isBlank()) ? id : UUID.randomUUID().toString();
    }
 
    private String getClientIp(HttpServletRequest request) {
        String forwarded = request.getHeader("X-Forwarded-For");
        return forwarded != null ? forwarded.split(",")[0].trim() : request.getRemoteAddr();
    }
}

MDC in Service Enrichment (Add More Context As You Go)

@Service
@Slf4j
public class PaymentService {
 
    public PaymentResult processPayment(String orderId, BigDecimal amount) {
        // Add order-specific context to MDC for this section of code
        MDC.put("orderId", orderId);
        MDC.put("paymentAmount", amount.toPlainString());
 
        try {
            log.info("Initiating payment");
            // Log: requestId=abc-123, orderId=ORD-456, paymentAmount=599.00 - Initiating payment
 
            PaymentResult result = gateway.charge(amount);
 
            MDC.put("transactionId", result.getTransactionId());
            log.info("Payment successful");
            // Log: requestId=abc-123, orderId=ORD-456, transactionId=TXN-789 - Payment successful
 
            return result;
 
        } catch (PaymentException e) {
            MDC.put("errorCode", e.getCode());
            log.error("Payment failed: {}", e.getMessage());
            throw e;
        } finally {
            // Remove only the keys we added in this method
            // Do NOT call MDC.clear() here - would erase requestId etc. from outer scope
            MDC.remove("orderId");
            MDC.remove("paymentAmount");
            MDC.remove("transactionId");
            MDC.remove("errorCode");
        }
    }
}

MDC with Spring AOP - Automatic Enrichment

/**
 * AOP that automatically adds method name and class to MDC for service calls.
 * Every service method gets automatic MDC enrichment without boilerplate.
 */
@Aspect
@Component
public class MdcServiceAspect {
 
    @Around("execution(* com.example..service..*(..))")
    public Object enrichMdcForServiceCall(ProceedingJoinPoint pjp) throws Throwable {
        String methodName = pjp.getSignature().getName();
        String className  = pjp.getTarget().getClass().getSimpleName();
 
        MDC.put("serviceClass",  className);
        MDC.put("serviceMethod", methodName);
 
        try {
            return pjp.proceed();
        } finally {
            MDC.remove("serviceClass");
            MDC.remove("serviceMethod");
        }
    }
}

8. Use Case 5: Read vs Write Replica Routing

/**
 * ThreadLocal-based read/write routing for database replicas.
 * @Transactional(readOnly = true) routes to read replica.
 * @Transactional  (without readOnly) routes to primary.
 */
public final class DatabaseContextHolder {
 
    public enum TargetDatabase { PRIMARY, REPLICA }
 
    private static final ThreadLocal<TargetDatabase> TARGET =
        ThreadLocal.withInitial(() -> TargetDatabase.PRIMARY);
 
    public static void usePrimary() { TARGET.set(TargetDatabase.PRIMARY); }
    public static void useReplica()  { TARGET.set(TargetDatabase.REPLICA); }
    public static TargetDatabase get() { return TARGET.get(); }
    public static void clear() { TARGET.remove(); }
}
 
/**
 * Routes every JDBC call to either primary or replica based on ThreadLocal.
 */
public class ReplicaRoutingDataSource extends AbstractRoutingDataSource {
 
    @Override
    protected Object determineCurrentLookupKey() {
        return DatabaseContextHolder.get();
    }
}
 
/**
 * AOP that reads @Transactional readOnly attribute and sets DatabaseContextHolder.
 * This is exactly how real "read replica routing" libraries work in Spring.
 */
@Aspect
@Component
@Order(1)  // Before @Transactional opens the connection
public class ReplicaRoutingAspect {
 
    @Around("@annotation(transactional)")
    public Object routeToReplica(
            ProceedingJoinPoint joinPoint,
            Transactional transactional) throws Throwable {
 
        if (transactional.readOnly()) {
            DatabaseContextHolder.useReplica();
        } else {
            DatabaseContextHolder.usePrimary();
        }
 
        try {
            return joinPoint.proceed();
        } finally {
            DatabaseContextHolder.clear();
        }
    }
}
 
// Usage: fully transparent to the service layer
@Service
public class ProductService {
 
    @Transactional(readOnly = true)  // -> Routes to replica automatically
    public List<Product> searchProducts(String query) {
        return productRepository.findByNameContaining(query);
    }
 
    @Transactional  // -> Routes to primary automatically
    public Product createProduct(CreateProductRequest request) {
        return productRepository.save(new Product(request));
    }
}

9. The ThreadLocal Memory Leak Problem

This is the most critical thing to understand about ThreadLocal in production applications.

Why Thread Pools Make ThreadLocal Dangerous

In a traditional Java application, a thread runs a task and dies. ThreadLocal is cleaned up when the thread dies.

In a Spring Boot web application:

  • Tomcat has a pool of reusable threads (default: 200 threads)
  • Thread-1 handles Request-A, sets ThreadLocal = "user-123", finishes
  • Thread-1 is returned to the pool
  • Thread-1 handles Request-B — ThreadLocal still contains "user-123" from Request-A

This causes two problems:

  1. Security bug: Request-B reads Request-A's user context
  2. Memory leak: Objects held by ThreadLocal are never garbage collected

The Memory Leak Mechanics

// LEAK: ThreadLocal holds a reference to an expensive object
private static final ThreadLocal<LargeObjectGraph> CONTEXT = new ThreadLocal<>();
 
@GetMapping("/data")
public Data getData() {
    // Set: holds a 10 MB object graph
    CONTEXT.set(loadExpensiveContext());
 
    try {
        return processData();
    }
    // BUG: no finally block with CONTEXT.remove()
    // The 10 MB object is NOW stuck in this thread's ThreadLocalMap forever
    // The thread lives as long as Tomcat runs (years in production)
}
 
// Even if the ThreadLocal variable is garbage collected (CONTEXT = null),
// the VALUE (the 10 MB object) is still in the thread's ThreadLocalMap
// because it is a strong reference (not WeakReference like the key)

The Safe Pattern

// SAFE: Always clear in finally
@GetMapping("/data")
public Data getData() {
    CONTEXT.set(loadExpensiveContext());
    try {
        return processData();
    } finally {
        CONTEXT.remove();  // ALWAYS, no exceptions
    }
}

Production Symptom of ThreadLocal Leaks

java.lang.OutOfMemoryError: Java heap space
  at com.example.service.SomeService.processRequest(SomeService.java:45)

Heap dump analysis shows:
  200 Thread objects (Tomcat thread pool)
  Each Thread has threadLocalMap with 50+ MB of retained objects
  Total: 200 threads * 50 MB = 10 GB of leaked memory

10. ThreadLocal and @Async - The Silent Bug

This is the most common ThreadLocal bug in Spring Boot applications.

Why @Async Breaks ThreadLocal

// Thread-1 (Tomcat request thread): handles HTTP request
//   RequestContext.setCorrelationId("req-123")   <- ThreadLocal set on Thread-1
//   service.doWork()                              <- still Thread-1, reads "req-123"
//   asyncService.asyncWork()                      <- @Async -> submits to thread pool
 
// Thread-7 (from @Async thread pool): picks up the async task
//   RequestContext.getCorrelationId()  <- Thread-7 has its OWN ThreadLocal = null
//   log.info("correlationId={}", RequestContext.getCorrelationId())
//   Output: "correlationId=null"  <- lost!

The Bug in Code

@Service
@Slf4j
public class EmailService {
 
    // @Async: runs on a DIFFERENT thread from the HTTP request thread
    @Async
    public void sendWelcomeEmail(String userId) {
        // WRONG: RequestContext.getCorrelationId() returns null here
        // because this runs on a different thread that never had the ThreadLocal set
        log.info("Sending welcome email. correlationId={}",
            RequestContext.getCorrelationId());  // Prints: correlationId=null
        // Loses traceability across the async boundary!
    }
}
 
// Caller (on HTTP request thread):
userService.createUser(request);   // correlationId is "req-abc-123" here
emailService.sendWelcomeEmail(userId);  // correlationId is null in this async method!
@Service
@Slf4j
public class EmailService {
 
    // Pass the correlation ID as a parameter (explicit context passing)
    @Async
    public void sendWelcomeEmail(String userId, String correlationId) {
        // Set the ThreadLocal on this new thread
        MDC.put("correlationId", correlationId);
        try {
            log.info("Sending welcome email");  // Now has correlationId
            // ... email sending logic
        } finally {
            MDC.clear();  // Clean up the new thread's MDC
        }
    }
}
 
// Caller:
String correlationId = RequestContext.getCorrelationId();  // Capture before @Async call
emailService.sendWelcomeEmail(userId, correlationId);  // Pass explicitly

Solution 2: TaskDecorator - Automatic Context Propagation

Spring Boot's @Async uses a thread pool. You can wrap every task with a TaskDecorator that copies context from the submitting thread to the worker thread.

/**
 * TaskDecorator that copies MDC and RequestContext from the submitting thread
 * to the worker thread before executing the async task.
 *
 * Set once on the @Async thread pool. All @Async methods get context automatically.
 */
@Component
public class ContextCopyingTaskDecorator implements TaskDecorator {
 
    @Override
    public Runnable decorate(Runnable runnable) {
        // Capture context from the CURRENT thread (the submitting thread)
        // BEFORE the Runnable is submitted to the thread pool
        Map<String, String> mdcContext = MDC.getCopyOfContextMap();
        RequestContext.Context reqContext = RequestContext.get();
 
        // Return a new Runnable that sets context on the WORKER thread before running
        return () -> {
            // Set context on the worker thread
            if (mdcContext != null) {
                MDC.setContextMap(mdcContext);
            }
            if (reqContext != null) {
                RequestContext.set(reqContext);
            }
 
            try {
                runnable.run();
            } finally {
                // Clean up the worker thread's context
                MDC.clear();
                RequestContext.clear();
            }
        };
    }
}
 
/**
 * Configure the @Async thread pool to use the ContextCopyingTaskDecorator.
 */
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
 
    private final ContextCopyingTaskDecorator taskDecorator;
 
    public AsyncConfig(ContextCopyingTaskDecorator taskDecorator) {
        this.taskDecorator = taskDecorator;
    }
 
    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(50);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("async-");
        executor.setTaskDecorator(taskDecorator);  // Attach the decorator
        executor.initialize();
        return executor;
    }
}
 
// Now @Async methods automatically have the same MDC/context as the calling thread:
@Async
public void sendWelcomeEmail(String userId) {
    log.info("Sending welcome email");
    // Now logs: correlationId=req-abc-123  (automatically propagated!)
}

11. ThreadLocal and CompletableFuture - The Same Problem

CompletableFuture runs callbacks on threads from the common ForkJoinPool (or your custom executor). Those threads do not have your ThreadLocal values.

// WRONG: ThreadLocal lost across CompletableFuture boundary
public CompletableFuture<Order> placeOrderAsync(OrderRequest request) {
    String correlationId = RequestContext.getCorrelationId(); // "req-123"
 
    return CompletableFuture
        .supplyAsync(() -> inventoryService.reserve(request.getItems()))
        // ^ Runs on ForkJoinPool thread: correlationId ThreadLocal = null
 
        .thenApply(reservation -> {
            // Still on ForkJoinPool thread or completing thread
            log.info("correlationId={}", RequestContext.getCorrelationId());
            // Logs: correlationId=null
            return paymentService.charge(request.getPayment());
        });
}
 
// CORRECT: Capture and restore context manually in each stage
public CompletableFuture<Order> placeOrderAsync(OrderRequest request) {
    // Capture context from the current thread BEFORE submitting to async
    Map<String, String> mdcSnapshot = MDC.getCopyOfContextMap();
    String correlationId = RequestContext.getCorrelationId();
 
    return CompletableFuture
        .supplyAsync(() -> {
            // Restore context on the worker thread
            MDC.setContextMap(mdcSnapshot != null ? mdcSnapshot : Map.of());
            RequestContext.setCorrelationId(correlationId);
            try {
                return inventoryService.reserve(request.getItems());
            } finally {
                MDC.clear();
                RequestContext.clear();
            }
        })
        .thenApplyAsync(reservation -> {
            // Must restore again - different thread might be used for each stage
            MDC.setContextMap(mdcSnapshot != null ? mdcSnapshot : Map.of());
            RequestContext.setCorrelationId(correlationId);
            try {
                log.info("correlationId={}", RequestContext.getCorrelationId());
                // Now logs: correlationId=req-123
                return paymentService.charge(request.getPayment());
            } finally {
                MDC.clear();
                RequestContext.clear();
            }
        });
}

Creating a Context-Aware Executor (Cleaner Approach)

/**
 * Wraps an ExecutorService to automatically propagate ThreadLocal context
 * from the submitting thread to the worker thread.
 *
 * Use this as the executor for CompletableFuture.supplyAsync(), thenApplyAsync(), etc.
 */
public class ContextAwareExecutor implements Executor {
 
    private final Executor delegate;
 
    public ContextAwareExecutor(Executor delegate) {
        this.delegate = delegate;
    }
 
    @Override
    public void execute(Runnable command) {
        // Capture context on the SUBMITTING thread
        Map<String, String> mdcContext = MDC.getCopyOfContextMap();
        RequestContext.Context reqContext = RequestContext.get();
 
        delegate.execute(() -> {
            // Restore context on the WORKER thread
            if (mdcContext != null) MDC.setContextMap(mdcContext);
            if (reqContext != null) RequestContext.set(reqContext);
 
            try {
                command.run();
            } finally {
                MDC.clear();
                RequestContext.clear();
            }
        });
    }
}
 
// Configuration
@Bean("contextAwareExecutor")
public Executor contextAwareExecutor() {
    ThreadPoolExecutor pool = new ThreadPoolExecutor(
        10, 50, 60L, TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(200),
        new ThreadFactoryBuilder().setNameFormat("ctx-async-%d").build()
    );
    return new ContextAwareExecutor(pool);
}
 
// Usage: context automatically propagated
@Autowired
@Qualifier("contextAwareExecutor")
private Executor contextAwareExecutor;
 
public CompletableFuture<Order> placeOrderAsync(OrderRequest request) {
    return CompletableFuture
        .supplyAsync(
            () -> inventoryService.reserve(request.getItems()),
            contextAwareExecutor  // Context propagated automatically
        )
        .thenApplyAsync(
            reservation -> paymentService.charge(request.getPayment()),
            contextAwareExecutor  // Context propagated automatically
        );
    // log.info in any of these stages will have the correct correlationId
}

12. InheritableThreadLocal

InheritableThreadLocal automatically copies the parent thread's value to child threads AT THE MOMENT the child thread is created.

private static final InheritableThreadLocal<String> CONTEXT =
    new InheritableThreadLocal<>();
 
// Parent thread (HTTP request thread):
CONTEXT.set("parent-value");
 
// Child thread created from the parent thread:
Thread child = new Thread(() -> {
    System.out.println(CONTEXT.get());  // Prints: "parent-value"
    // The child inherited the parent's value at thread creation time
});
child.start();
 
// IMPORTANT: This is a snapshot at creation time, not a live reference.
CONTEXT.set("updated-value");  // Change in parent
// Child still sees "parent-value", not "updated-value"

Why InheritableThreadLocal Does NOT Work with Thread Pools

// Thread pool: threads are created ONCE and reused for many tasks
 
// Step 1: Thread-7 is created from the main thread (no CONTEXT value set yet)
//         Thread-7.inheritedThreadLocals = { CONTEXT -> null }
 
// Step 2: HTTP request comes in. RequestThread sets CONTEXT = "req-123"
// Step 3: @Async submits task to Thread-7
//         Thread-7 does NOT inherit "req-123" because Thread-7 already exists.
//         Inheritance only happens at thread CREATION time, not task submission time.
//         Thread-7.threadLocalMap = { CONTEXT -> null }  <- still null!

Conclusion: InheritableThreadLocal only helps when you create new threads manually (not thread pools). Use TransmittableThreadLocal for thread pools.


13. TransmittableThreadLocal - The Real Solution

TTL by Alibaba is a library that solves ThreadLocal propagation for thread pools.

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>transmittable-thread-local</artifactId>
    <version>2.14.4</version>
</dependency>

How TTL Works

TTL works at task submission time, not thread creation time:

Thread-1 (HTTP request thread):
  TTL.set("req-123")          <- Store in TTL (which has special thread pool awareness)
  executor.submit(task)       <- TTL captures current values and attaches them to the task

Thread-7 (worker thread):
  task.run()                  <- TTL restores "req-123" on Thread-7 BEFORE running
  TTL.get() -> "req-123"     <- Correct!
  task finishes
  TTL restores Thread-7's original value (removes "req-123" from Thread-7)

TTL in Spring Boot

import com.alibaba.ttl.TransmittableThreadLocal;
import com.alibaba.ttl.threadpool.TtlExecutors;
 
/**
 * Replace ThreadLocal with TransmittableThreadLocal.
 * API is identical.
 */
public final class RequestContext {
 
    // Just change ThreadLocal -> TransmittableThreadLocal
    private static final TransmittableThreadLocal<Context> CONTEXT =
        new TransmittableThreadLocal<>();
 
    // Rest of the API stays the same
    public static void set(Context ctx) { CONTEXT.set(ctx); }
    public static Context get() { return CONTEXT.get(); }
    public static void clear() { CONTEXT.remove(); }
}
 
/**
 * Wrap your ExecutorService with TtlExecutors.getTtlExecutorService().
 * This makes every submitted task automatically carry TTL context.
 */
@Bean("asyncExecutor")
public Executor asyncExecutor() {
    ThreadPoolExecutor pool = new ThreadPoolExecutor(
        10, 50, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(200)
    );
    // Wrap with TTL: every task submitted to this pool gets context automatically
    return TtlExecutors.getTtlExecutorService(pool);
}
 
// Now @Async and CompletableFuture with this executor propagate context automatically:
@Async("asyncExecutor")
public void sendWelcomeEmail(String userId) {
    // RequestContext.get() now works correctly here!
    String correlationId = RequestContext.get().getCorrelationId();
    log.info("Sending email. correlationId={}", correlationId);  // Correct!
}

14. Context Propagation Patterns

Pattern Summary Table

ScenarioProblemSolution
HTTP request threadNone - ThreadLocal works directlyStandard ThreadLocal
@Async methodDifferent thread, no contextTaskDecorator OR TTL
CompletableFuture.supplyAsyncDifferent thread, no contextContextAwareExecutor OR TTL
New Thread() manuallyThread inherits at creationInheritableThreadLocal
Thread pool (ForkJoinPool, etc.)No inheritance on reuseTTL + TtlExecutors wrapper
Spring Batch job stepsRuns on batch thread poolTaskDecorator on batch executor
Kafka consumer threadSeparate consumer threadSet context in @KafkaListener prologue
Scheduled task (@Scheduled)No request context at allAvoid ThreadLocal in scheduled tasks

Context Propagation in Kafka Consumers

@Component
@Slf4j
public class OrderEventConsumer {
 
    @KafkaListener(topics = "order.events", groupId = "notification-service")
    public void handleOrderCreated(
            OrderCreatedEvent event,
            @Header(KafkaHeaders.RECEIVED_TOPIC) String topic) {
 
        // Kafka consumer runs on its own thread pool (no HTTP request context)
        // We must set context from the Kafka message headers
 
        String correlationId = event.getCorrelationId();
        if (correlationId == null) correlationId = UUID.randomUUID().toString();
 
        MDC.put("correlationId", correlationId);
        MDC.put("kafkaTopic",    topic);
        MDC.put("orderId",       event.getOrderId().toString());
 
        try {
            log.info("Processing order created event");
            notificationService.sendOrderConfirmation(event);
        } finally {
            MDC.clear();
        }
    }
}

15. Testing Code That Uses ThreadLocal

@SpringBootTest
class UserServiceTest {
 
    @Autowired
    private UserService userService;
 
    /**
     * Test 1: Setup ThreadLocal before the test, clean up after.
     */
    @Test
    void getUserProfile_shouldReturnProfileForCurrentUser() {
        // Arrange: Set up the ThreadLocal context (mimics what the filter does)
        UserContext userCtx = UserContext.builder()
            .userId(42L)
            .email("test@example.com")
            .roles(Set.of("ROLE_CUSTOMER"))
            .tenantId("acme")
            .build();
        UserContextHolder.set(userCtx);
 
        try {
            // Act
            UserProfile profile = userService.getCurrentUserProfile();
 
            // Assert
            assertThat(profile.getEmail()).isEqualTo("test@example.com");
            assertThat(profile.getTenantId()).isEqualTo("acme");
        } finally {
            // Clean up ThreadLocal even if the test fails
            UserContextHolder.clear();
        }
    }
 
    /**
     * Test 2: Verify that ThreadLocal is cleaned up after request processing.
     * Tests that the filter properly clears on exception.
     */
    @Test
    void correlationFilter_shouldClearThreadLocalEvenOnException() throws Exception {
        MockHttpServletRequest request = new MockHttpServletRequest();
        MockHttpServletResponse response = new MockHttpServletResponse();
 
        FilterChain chainThatThrows = (req, res) -> {
            throw new RuntimeException("Simulated error");
        };
 
        CorrelationIdFilter filter = new CorrelationIdFilter();
 
        assertThatThrownBy(() ->
            filter.doFilterInternal(request, response, chainThatThrows)
        ).isInstanceOf(RuntimeException.class);
 
        // ThreadLocal must be cleared even after exception
        assertThat(RequestContext.getCorrelationId()).isNull();
        assertThat(MDC.get("correlationId")).isNull();
    }
 
    /**
     * Test 3: @Async method should not inherit the calling thread's ThreadLocal.
     * (Without TaskDecorator - testing the bug exists without the fix)
     */
    @Test
    void asyncMethod_withoutDecorator_shouldNotHaveThreadLocalContext()
            throws InterruptedException {
 
        RequestContext.setCorrelationId("test-correlation-id");
        CountDownLatch latch = new CountDownLatch(1);
        AtomicReference<String> capturedId = new AtomicReference<>();
 
        // Run on a plain (non-decorated) thread
        new Thread(() -> {
            capturedId.set(RequestContext.getCorrelationId());
            latch.countDown();
        }).start();
 
        latch.await(2, TimeUnit.SECONDS);
        RequestContext.clear();
 
        // New thread should NOT see the parent thread's context
        assertThat(capturedId.get()).isNull();
    }
}

Quick Reference: ThreadLocal Rules

Rule 1: ALWAYS call remove() in a finally block. Never skip cleanup.
        ThreadLocal + thread pools = memory leak and data contamination.

Rule 2: ThreadLocal is per-thread. @Async and CompletableFuture use different threads.
        Use TaskDecorator, ContextAwareExecutor, or TTL to propagate.

Rule 3: Use remove() not set(null).
        remove() physically removes the entry. set(null) leaves it in the map.

Rule 4: Never use ThreadLocal in @Scheduled tasks.
        There is no HTTP request - the context you expect is not set.

Rule 5: InheritableThreadLocal only inherits at thread CREATION time.
        It does NOT work with pre-created thread pools.

Rule 6: Use MDC for logging context. It is ThreadLocal with log pattern support built in.
        MDC.put("key", "value") -> appears in %X{key} in log patterns automatically.

See completablefuture-springboot-guide.md for the CompletableFuture deep dive.