Skip to main content

unctx vs AsyncLocalStorage vs cls-hooked: Async Context in Node.js (2026)

·PkgPulse Team

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

FeatureAsyncLocalStorageunctxcls-hooked
Built-in✅ (Node.js 16+)
Zero deps
Composable pattern❌ (raw API)✅ (use/call)
Named contexts✅ (namespaces)
Async propagation✅ (callAsync)
TypeScript✅ (generics)✅ (generics)⚠️
Framework integrationRawNuxt, H3Express
Browser support✅ (sync only)
StatusActive (Node.js)Active (UnJS)Legacy
Weekly downloadsBuilt-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.

Compare async context and server tooling on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.