unctx vs AsyncLocalStorage vs cls-hooked: Async Context in Node.js (2026)
TL;DR
AsyncLocalStorage is the built-in Node.js API for async context — propagates data through the async call chain without passing arguments, used for request-scoped logging, tracing, and auth context. unctx is the UnJS composable context library — creates named contexts with use/call pattern, works like Vue's inject/provide for server-side code. cls-hooked is the legacy continuation-local storage library — wraps AsyncLocalStorage with a friendlier API, widely used before AsyncLocalStorage was stable. In 2026: AsyncLocalStorage for direct Node.js usage, unctx for framework-level composable contexts, cls-hooked is legacy (migrate to AsyncLocalStorage).
Key Takeaways
- AsyncLocalStorage: built-in (Node.js 16+) — zero deps, stable, the foundation
- unctx: ~5M weekly downloads — UnJS, composable contexts, used by Nuxt/H3
- cls-hooked: ~3M weekly downloads — legacy, wraps AsyncLocalStorage, being replaced
- The problem: passing request context through every function is tedious
- AsyncLocalStorage propagates data through async calls automatically
- unctx provides a higher-level API for creating composable "use" functions
The Problem
// Without async context — manual passing:
async function handleRequest(req, res) {
const user = await getUser(req)
const data = await fetchData(user, req) // Must pass req/user everywhere
const formatted = formatResponse(data, user, req)
await logRequest(req, user, data)
res.json(formatted)
}
// Every function needs req, user, logger, traceId...
// Deep call chains become: fn(a, b, c, req, user, logger, traceId)
// With async context — available anywhere:
async function handleRequest(req, res) {
const user = await getUser(req)
// Context is available to ALL functions called within this scope:
const data = await fetchData() // No req/user needed
const formatted = formatResponse() // Reads from context
res.json(formatted)
}
AsyncLocalStorage (Built-in)
AsyncLocalStorage — Node.js built-in:
Basic usage
import { AsyncLocalStorage } from "node:async_hooks"
// Create a storage instance:
const requestContext = new AsyncLocalStorage<{
requestId: string
userId: string
startTime: number
}>()
// Run code within a context:
requestContext.run(
{ requestId: "abc-123", userId: "user-1", startTime: Date.now() },
async () => {
// Context is available in ALL functions called from here:
await handleRequest()
}
)
// Access context from anywhere in the call chain:
function getRequestId(): string {
const ctx = requestContext.getStore()
return ctx?.requestId ?? "unknown"
}
Express middleware
import { AsyncLocalStorage } from "node:async_hooks"
import express from "express"
import { randomUUID } from "node:crypto"
interface RequestContext {
requestId: string
userId?: string
startTime: number
}
const als = new AsyncLocalStorage<RequestContext>()
const app = express()
// Middleware that sets up context:
app.use((req, res, next) => {
const context: RequestContext = {
requestId: req.headers["x-request-id"] as string ?? randomUUID(),
startTime: Date.now(),
}
als.run(context, () => next())
})
// Auth middleware adds userId:
app.use((req, res, next) => {
const ctx = als.getStore()!
ctx.userId = req.headers.authorization ? "user-123" : undefined
next()
})
// Any function anywhere can access context:
function getLogger() {
const ctx = als.getStore()
return {
info: (msg: string) => console.log(`[${ctx?.requestId}] ${msg}`),
error: (msg: string) => console.error(`[${ctx?.requestId}] ${msg}`),
}
}
// Route handler:
app.get("/api/packages", async (req, res) => {
const log = getLogger()
log.info("Fetching packages")
const packages = await fetchPackages() // getLogger() works inside too!
log.info(`Found ${packages.length} packages`)
const ctx = als.getStore()!
res.json({
data: packages,
meta: { requestId: ctx.requestId, duration: Date.now() - ctx.startTime },
})
})
Request-scoped tracing
import { AsyncLocalStorage } from "node:async_hooks"
const traceContext = new AsyncLocalStorage<{ traceId: string; spans: string[] }>()
function trace(spanName: string) {
const ctx = traceContext.getStore()
if (ctx) {
ctx.spans.push(spanName)
console.log(`[${ctx.traceId}] → ${spanName}`)
}
}
// All nested calls are traced:
app.get("/api/packages/:name", async (req, res) => {
traceContext.run({ traceId: randomUUID(), spans: [] }, async () => {
trace("handler:start")
const pkg = await getPackage(req.params.name) // trace() works inside
trace("handler:end")
res.json(pkg)
})
})
async function getPackage(name: string) {
trace("getPackage:start")
const data = await db.query("SELECT * FROM packages WHERE name = $1", [name])
trace("getPackage:query")
return data
}
unctx
unctx — composable context:
Create composable contexts
import { createContext } from "unctx"
// Create a named context:
const requestCtx = createContext<{
requestId: string
user?: { id: string; name: string }
}>()
// Use pattern — like Vue's inject:
export function useRequestContext() {
const ctx = requestCtx.use()
if (!ctx) throw new Error("No request context available")
return ctx
}
// Call pattern — like Vue's provide:
export function withRequestContext<T>(
context: { requestId: string; user?: { id: string; name: string } },
fn: () => T
): T {
return requestCtx.call(context, fn)
}
Usage in H3/Nitro
import { createContext } from "unctx"
// Define composable context:
const appCtx = createContext<{ config: AppConfig }>()
export const useAppContext = appCtx.use
// In H3 event handler:
export default eventHandler(async (event) => {
// Set context:
return appCtx.call({ config: getConfig() }, async () => {
// Any function can call useAppContext():
const packages = await listPackages()
return packages
})
})
// In a utility function:
async function listPackages() {
const { config } = useAppContext() // Access without arguments!
const limit = config.defaultLimit
return db.query("SELECT * FROM packages LIMIT $1", [limit])
}
How Nuxt uses unctx
// Nuxt's useRuntimeConfig, useState, useFetch all use unctx:
// nuxt/src/app/composables/config.ts (simplified):
import { createContext } from "unctx"
const nuxtAppCtx = createContext<NuxtApp>("nuxt-app")
export function useNuxtApp(): NuxtApp {
const app = nuxtAppCtx.tryUse()
if (!app) throw new Error("useNuxtApp() called outside Nuxt context")
return app
}
export function useRuntimeConfig() {
return useNuxtApp().$config
}
// This is why useRuntimeConfig() works in Nuxt:
// The context is set by Nuxt's request handler
// Your code calls useRuntimeConfig() and it "just works"
Async context with unctx
import { createContext } from "unctx"
const ctx = createContext<{ requestId: string }>()
// Enable async support (uses AsyncLocalStorage internally):
ctx.callAsync({ requestId: "abc" }, async () => {
// Works across async boundaries:
await someAsyncOperation()
const { requestId } = ctx.use()
console.log(requestId) // "abc" — preserved across await
})
cls-hooked (Legacy)
cls-hooked — legacy CLS:
Usage (for reference)
import cls from "cls-hooked"
// Create namespace:
const ns = cls.createNamespace("request")
// Middleware:
app.use((req, res, next) => {
ns.run(() => {
ns.set("requestId", randomUUID())
ns.set("startTime", Date.now())
next()
})
})
// Access from anywhere:
function getRequestId() {
return cls.getNamespace("request")?.get("requestId")
}
Migration to AsyncLocalStorage
// cls-hooked (legacy):
const ns = cls.createNamespace("request")
ns.run(() => { ns.set("key", "value") })
const value = ns.get("key")
// AsyncLocalStorage (modern):
const als = new AsyncLocalStorage()
als.run({ key: "value" }, () => { /* ... */ })
const value = als.getStore()?.key
// cls-hooked is essentially a wrapper around AsyncLocalStorage now.
// Migrate to AsyncLocalStorage directly for zero dependencies.
Feature Comparison
| Feature | AsyncLocalStorage | unctx | cls-hooked |
|---|---|---|---|
| Built-in | ✅ (Node.js 16+) | ❌ | ❌ |
| Zero deps | ✅ | ✅ | ❌ |
| Composable pattern | ❌ (raw API) | ✅ (use/call) | ❌ |
| Named contexts | ❌ | ✅ | ✅ (namespaces) |
| Async propagation | ✅ | ✅ (callAsync) | ✅ |
| TypeScript | ✅ (generics) | ✅ (generics) | ⚠️ |
| Framework integration | Raw | Nuxt, H3 | Express |
| Browser support | ❌ | ✅ (sync only) | ❌ |
| Status | Active (Node.js) | Active (UnJS) | Legacy |
| Weekly downloads | Built-in | ~5M | ~3M |
When to Use Each
Use AsyncLocalStorage if:
- Need request-scoped context in Node.js
- Want zero dependencies (built-in)
- Building Express, Fastify, or Hono middleware
- Need tracing, logging, or auth context propagation
Use unctx if:
- Building a framework with composable "use" functions
- Want the Vue-style
useX()pattern on the server - In the UnJS ecosystem (Nuxt, H3, Nitro)
- Need named, typed contexts with clean API
Use cls-hooked if:
- Existing project already using it (legacy)
- Migrate to AsyncLocalStorage — cls-hooked is no longer needed
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on Node.js 22 AsyncLocalStorage, unctx v2.x, and cls-hooked v4.x.