ThreadLocal provides thread-local variables. Because each thread has its own isolated copy, ThreadLocal is commonly used to store thread-specific information like logged-in user details or database connection config. But there’s a catch most people miss: ThreadLocal only works in synchronous threads. It doesn’t carry over to async threads or thread pools. This post explores how to propagate ThreadLocal context across async boundaries.
2. The Problem
After a release, a production bug surfaced: “Failed to retrieve user info.” To understand why, some context on the system architecture helps. When a user logs in, the backend fetches their profile using a user key and stores it in a ThreadLocal static object for later use. Here’s a simplified version:
/** * Interceptor */ @Component publicclassHandlerAccessInterceptorimplementsHandlerInterceptor{ @Override publicbooleanpreHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o)throws Exception { // Add CORS headers httpServletResponse.setHeader("Access-Control-Allow-Origin", "*"); httpServletResponse.setHeader("Access-Control-Allow-Headers", "Content-Type,Content-Length, Authorization, Accept,X-Requested-With"); httpServletResponse.setHeader("Access-Control-Allow-Methods", "PUT,POST,GET,DELETE,OPTIONS"); // Fetch and store user info if (httpServletRequest.getCookies() != null) { for (Cookie cookie : httpServletRequest.getCookies()) { if ("vkey".equals(cookie.getName())) { UserContextUtil.setUser(cookie.getValue()); } } } returntrue; } @Override publicvoidpostHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView)throws Exception {
} // Clean up user info @Override publicvoidafterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e)throws Exception { UserContextUtil.remove(); } }
/** * User context utility */ @Component publicclassUserContextUtil{ privatestatic ThreadLocal<SyUser> threadLocal = new ThreadLocal<>(); privatestaticfinal String TOKEN_BEARER = "Bearer ";
/** * Get current thread's user info * * @return Current thread's user info */ publicsynchronizedstatic SyUser getUser(){ SyUser syUser = threadLocal.get(); return syUser; }
/** * Set user info directly */ publicsynchronizedstaticbooleansetSyUser(SyUser syUser){
if (syUser == null) { returnfalse; }
threadLocal.set(syUser);
returntrue; }
/** * Parse login token from cookies and set current user * * @param cookies */ publicsynchronizedstaticbooleansetUser(Cookie[] cookies){ if (cookies == null) { returnfalse; } for (Cookie cookie : cookies) { String name = cookie.getName().toLowerCase(); if ("vkey".equals(name)) { String value = cookie.getValue(); if (StrUtil.isEmpty(value)) { returnfalse; } LoginUser loginUser = new LoginUser(value); if (loginUser.getId() == null) { returnfalse; } SyUser syUser = new SyUser(); syUser.setId(loginUser.getId().intValue()); syUser.setUserName(loginUser.getMobile()); syUser.setTrueName(loginUser.getName()); return setSyUser(syUser); } } returnfalse; }
/** * Parse login token from header and set current user * * @param headerValue Header vkey value */ publicsynchronizedstaticbooleansetUser(String headerValue){ if (StrUtil.isEmpty(headerValue)) { returnfalse; } if (headerValue.startsWith(TOKEN_BEARER)) { headerValue = headerValue.substring(TOKEN_BEARER.length()); } LoginUser loginUser = new LoginUser(headerValue); if (loginUser.getId() == null) { returnfalse; } SyUser syUser = new SyUser(); syUser.setId(loginUser.getId().intValue()); syUser.setUserName(loginUser.getMobile()); syUser.setTrueName(loginUser.getName()); syUser.setPhone(loginUser.getMobile()); return setSyUser(syUser); } /** * Get logged-in user's name */ publicsynchronizedstatic String getUserName(){ SyUser syUser = threadLocal.get(); if (syUser == null) { returnnull; } return syUser.getTrueName(); }
/** * Remove user info from current thread */ publicsynchronizedstaticvoidremove(){ threadLocal.remove(); } }
@RestController @RequestMapping("/web/file") @Slf4j publicclassManageFileController{ @Resource private ManageFileService manageFileService; /** * [Load file] * The data preparation phase for file download can be very long. * The frontend operator can't see progress or do anything else. * So we decouple data preparation from file download to improve UX. * * @param loadFileReqDTO Load file request * @return Whether the call succeeded */ @PostMapping("/loadFile") public RespDTO<String> loadFile(@Valid @RequestBody LoadFileReqDTO loadFileReqDTO){
// Initialize file load info AsyncLoadFile asyncLoadFile = manageFileService.initLoadFileInfo(loadFileReqDTO);
/** * [Load file - Approach 1] * The data preparation phase for file download can be very long. * The frontend operator can't see progress or do anything else. * So we decouple data preparation from file download to improve UX. * * @param loadFileReqDTO Load file request * @return Whether the call succeeded */ @PostMapping("/loadFile") public RespDTO<String> loadFile(@Valid @RequestBody LoadFileReqDTO loadFileReqDTO){
// Initialize file load info AsyncLoadFile asyncLoadFile = manageFileService.initLoadFileInfo(loadFileReqDTO); // #loadFile is async; pass user info to the child thread explicitly SyUser syUser = UserContextUtil.getUser();
/** * [Load file] * The data preparation phase for file download can be very long. * The frontend operator can't see progress or do anything else. * So we decouple data preparation from file download to improve UX. * * @param loadFileReqDTO Load file request * @param asyncLoadFile Initialized file load info * @param syUser User info */ @Async(TASK_EXECUTOR) publicvoidloadFile(LoadFileReqDTO loadFileReqDTO, AsyncLoadFile asyncLoadFile, SyUser syUser){ UserContextUtil.setSyUser(syUser); log.info("Async file load started; UserName: {}", UserContextUtil.getRealName()); UserContextUtil.remove(); log.info("Async file load finished; UserName: {}", UserContextUtil.getRealName()); }
Approach 1 is a brute-force workaround. It fixes the symptom, not the cause, and the next developer who isn’t aware of the pattern will fall into the same trap. Here’s approach 2:
privatestatic ThreadLocal<SyUser> threadLocal = new InheritableThreadLocal<>();
/** * [Load file - async thread] * The data preparation phase for file download can be very long. * The frontend operator can't see progress or do anything else. * So we decouple data preparation from file download to improve UX. * * @param loadFileReqDTO Load file request * @param asyncLoadFile Initialized file load info * @param syUser User info */ @Async publicvoidloadFile(LoadFileReqDTO loadFileReqDTO, AsyncLoadFile asyncLoadFile, SyUser syUser){ UserContextUtil.setSyUser(syUser); log.info("Async file load started; UserName: {}", UserContextUtil.getRealName()); UserContextUtil.remove(); log.info("Async file load finished; UserName: {}", UserContextUtil.getRealName()); }
/** * [Load file - async thread pool] * The data preparation phase for file download can be very long. * The frontend operator can't see progress or do anything else. * So we decouple data preparation from file download to improve UX. * * @param loadFileReqDTO Load file request * @param asyncLoadFile Initialized file load info * @param syUser User info */ @Async(TASK_EXECUTOR) publicvoidloadFile(LoadFileReqDTO loadFileReqDTO, AsyncLoadFile asyncLoadFile, SyUser syUser){ UserContextUtil.setSyUser(syUser); log.info("Async file load started; UserName: {}", UserContextUtil.getRealName()); UserContextUtil.remove(); log.info("Async file load finished; UserName: {}", UserContextUtil.getRealName()); }
Results for both async thread and async thread pool:
1 2 3 4
Output... 2020-12-1418:36:41.560 INFO [-,8658c694de3e9b7a,ccc7a782204c91b0,false] 19876 --- [common-async-executor-1] c.v.c.o.service.file.ManageFileService : Async file load started; UserName: Rongsheng Zou
2020-12-1418:41:17.735 INFO [-,67b4725bcaacfc64,36ee2acec232be32,false] 21600 --- [common-async-executor-1] c.v.c.o.service.file.ManageFileService : Async file load started; UserName: Rongsheng Zou
4. Summary
ThreadLocal‘s thread-isolation property is convenient, but in high-concurrency systems, relying on raw static thread-local objects without understanding their limitations is a recipe for subtle bugs. Like any tool — whether it’s Redis or MQ — deeply understanding the characteristics of the utilities you rely on daily is a prerequisite for solid system design.