skip to content

TaskDecorator & Context Propagation

A TaskDecorator wraps the submitted task so the security context, request scope, MDC or tracing context crosses the thread boundary into the pool. Interviewers ask why the trace id and the user disappear inside @Async, and this is the fix.

part ofSpring Frameworkoverview, primer and where to startread it →
on this pageshow

questions

5

What is Spring's TaskDecorator, and why is it needed when running work on a thread pool like ThreadPoolTaskExecutor?

level: juniorimportance: must knowfreq 55%

answer

  1. Runnable decorate(Runnable) — one method
  2. runs on caller at submit, wrapper runs on pool thread
  3. capture in decorate body, restore in finally
  4. setTaskDecorator on ThreadPoolTaskExecutor
  5. pool threads reused -> must clear to avoid leaks

basics

~20 s

TaskDecorator is an interface with one method, decorate(Runnable), that wraps every task before a ThreadPoolTaskExecutor runs it. You use it to carry thread-local context (like logging MDC or the logged-in user) from the calling thread onto the pool thread.

solid answer

~40 s

TaskDecorator (org.springframework.core.task.TaskDecorator, since Spring 4.3) has a single method, Runnable decorate(Runnable). When you register it via ThreadPoolTaskExecutor.setTaskDecorator, Spring calls it once per submitted task, on the submitting thread, and runs the returned wrapper on a pool thread. It's needed because thread-locals — SLF4J MDC, SecurityContextHolder, RequestContextHolder, Micrometer Observation — live on the caller's thread and are NOT visible on a different pool thread. The decorator captures that state at submit time and re-installs it around the task's execution. The correct shape is: read the context inside decorate() (caller thread), then in the returned lambda set it, run the task in a try, and clear/restore it in a finally so reused pool threads don't leak state.

code

java · 24 lines
java
import org.springframework.core.task.TaskDecorator;
import org.slf4j.MDC;
import java.util.Map;

public class MdcTaskDecorator implements TaskDecorator {
    @Override
    public Runnable decorate(Runnable runnable) {
        // 1) capture on the CALLER thread, at submit time
        Map<String, String> captured = MDC.getCopyOfContextMap();
        return () -> {
            // runs on the POOL thread
            Map<String, String> previous = MDC.getCopyOfContextMap();
            if (captured != null) MDC.setContextMap(captured);
            else MDC.clear();
            try {
                runnable.run();
            } finally {
                // 3) restore so the reused pool thread doesn't leak
                if (previous != null) MDC.setContextMap(previous);
                else MDC.clear();
            }
        };
    }
}

go deeper

for a junior

Know it's one method, decorate(Runnable), used to carry MDC/user context onto pool threads.

for a middle

Explain the capture-on-caller / restore-in-finally pattern and why cleanup prevents leakage.

for a senior

Contrast hand-rolled decorators with Micrometer context-propagation and note it's per-executor.

for a principal

Discuss uniform propagation strategy, reused-thread hygiene, and standardizing via ContextPropagatingTaskDecorator across all executors.

## The problem A **thread-local** is a variable whose value is scoped to one thread. Many Spring/Java facilities store per-request state in thread-locals: - **SLF4J MDC** (Mapped Diagnostic Context) — key/value pairs (e.g. `requestId`) that appear in every log line. - **SecurityContextHolder** — the authenticated user (default strategy `MODE_THREADLOCAL`). - **RequestContextHolder** — the current `HttpServletRequest`/request-scoped beans. - **Micrometer Observation / trace context** — the current span for distributed tracing. When you hand work to a **thread pool** (`ThreadPoolTaskExecutor`, the executor behind `@Async`), the task runs on a *different* thread than the one that submitted it. That worker thread has its own, empty (or stale, reused) thread-locals. So logs lose the `requestId`, `SecurityContextHolder.getContext()` returns an empty context, tracing spans break, etc. ## What TaskDecorator is `org.springframework.core.task.TaskDecorator` is a functional interface introduced in **Spring Framework 4.3**: ```java @FunctionalInterface public interface TaskDecorator { Runnable decorate(Runnable runnable); } ``` You register it with `ThreadPoolTaskExecutor.setTaskDecorator(...)` (also available on `SimpleAsyncTaskExecutor` and `ConcurrentTaskExecutor`). Spring invokes `decorate` for **every** task submitted to that executor. ## The critical timing detail `decorate(runnable)` is called **on the submitting (caller) thread, at submit time** — not on the worker. The `Runnable` it returns is what actually runs later on the pool thread. Therefore: 1. **Capture** the context by reading thread-locals in the body of `decorate` (caller thread). 2. In the returned lambda (worker thread) **install** the captured context, run the delegate, and in a **`finally`** block **restore/clear** it. Step 3 matters because pool threads are **reused**. If you set MDC but never clear it, the next unrelated task on that same thread inherits a stale `requestId` — a real, hard-to-debug correctness/privacy bug. ## Gotchas - Capturing inside the *returned* lambda instead of in `decorate` reads the worker thread's (wrong/empty) context. - Forgetting the `finally` cleanup leaks context between pooled tasks. - A `TaskDecorator` only affects the executor it is registered on; each executor needs its own. - It wraps `Runnable`; `Callable` submissions are adapted, so the decorator still applies. ## When to use Any time async/pooled work must keep request-scoped diagnostic or security context: correlation IDs in logs, the authenticated principal, tracing spans. In modern stacks prefer Micrometer **context-propagation** (Spring 6.1's `ContextPropagatingTaskDecorator`) so all registered thread-locals propagate uniformly instead of hand-writing one decorator per concern.

  • On which thread does decorate() execute, and why does that matter?
    On the submitting/caller thread at submit time. That's exactly why you read (capture) the thread-locals there — they hold the caller's request context. The returned Runnable then runs on the pool thread and re-installs that captured context.
  • What breaks if you omit the finally cleanup?
    Pool threads are reused, so the context you set stays on the thread and bleeds into the next, unrelated task — e.g. wrong requestId in logs or a stale principal — until it's overwritten. Always restore or clear in finally.

saying these in an interview costs you the question

  • Thinking decorate() runs on the pool thread (it runs on the caller at submit time).
  • Capturing context inside the returned Runnable instead of in decorate's body.
  • Believing thread-locals are automatically shared across threads in a pool.
  • Omitting finally/clear, causing context to leak between reused pool tasks.

context

open as a page

How do you make SecurityContextHolder's authenticated user available inside @Async / thread-pool work, and why isn't it automatic?

level: middleimportance: must knowfreq 60%

basics

~20 s

SecurityContextHolder uses a thread-local (MODE_THREADLOCAL) that isn't shared with pool threads, so the async thread sees no user. Fix it with Spring Security's DelegatingSecurityContextAsyncTaskExecutor, or a TaskDecorator that copies the SecurityContext and clears it afterward.

open as a page

Walk through implementing an MDC-propagating TaskDecorator correctly. What are the common bugs?

level: middleimportance: should knowfreq 45%

basics

~20 s

In decorate(), call MDC.getCopyOfContextMap() on the caller thread to snapshot the log context. In the returned Runnable, MDC.setContextMap(copy), run the task, and in a finally MDC.clear() (or restore the previous map). Common bugs: capturing on the pool thread, and skipping cleanup.

open as a page

How does Micrometer's context-propagation library (ContextSnapshot / ThreadLocalAccessor) unify context propagation, and how does Spring integrate it?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Micrometer's context-propagation library defines ThreadLocalAccessor SPI implementations for each thread-local (MDC, tracing, security). ContextSnapshot.captureAll() grabs all registered ones at once; wrapping a Runnable restores and then clears them on the pool thread. Spring 6.1's ContextPropagatingTaskDecorator plugs this into any executor.

open as a page

What are the risks of propagating RequestContextHolder / request-scoped beans across the async thread boundary, and how would you do it safely?

level: principalimportance: should knowfreq 30%

basics

~20 s

The HttpServletRequest and request-scoped beans live only for the request; once it completes the request/response may be recycled and its attributes cleared. Copying RequestContextHolder to a pool thread risks touching a dead request. Safer: extract the plain values you need before submitting, and pass them explicitly.

open as a page