Skip to main content

Guide

unctx vs AsyncLocalStorage vs cls-hooked 2026

Compare unctx, AsyncLocalStorage, and cls-hooked for async context propagation in Node.js. Request-scoped data, context passing without prop drilling, and.

·PkgPulse Team·
0

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

Community Adoption in 2026

AsyncLocalStorage is a Node.js built-in (available since Node.js 12.17, stable since Node.js 16) and has no npm download count. It is the underlying mechanism that most modern async context libraries — including unctx and the latest cls-hooked — use internally. APM tools (Datadog, New Relic, OpenTelemetry), request tracing libraries, and database query loggers all use AsyncLocalStorage to propagate request context across async call chains without requiring explicit parameter threading.

unctx reaches approximately 5 million weekly downloads, driven entirely by its role as the context primitive for the UnJS ecosystem. Every Nuxt 3 application uses unctx — useRuntimeConfig(), useNuxtApp(), useState(), and useFetch() are all built on unctx composables. The package's design encodes the pattern that Nuxt made popular: context that is set by the framework during request handling and accessed from composables anywhere in the call tree without explicit parameter passing.

cls-hooked at approximately 3 million weekly downloads represents legacy adoption in older Express and Sequelize-based applications. The package was the standard solution for request-scoped context before AsyncLocalStorage existed (it used async_hooks in its original form). In 2026, cls-hooked is effectively deprecated — it wraps AsyncLocalStorage internally in newer versions and provides no additional value over using AsyncLocalStorage directly. Existing cls-hooked usages should be migrated when touched, but there is no urgency to migrate for working applications.

Request Context Patterns in Web Frameworks

Async context propagation is a critical infrastructure concern for any web framework that needs to track per-request state — user identity, locale, database connections, logging correlation IDs — across asynchronous operations without manually threading context through function parameters.

unctx is the mechanism behind Nuxt's composables system. When you call useNuxtApp() or useRuntimeConfig() inside a Vue composable, unctx is finding the current Nuxt application instance from async context. This is what allows Nuxt's defineEventHandler to provide useStorage(), useRuntimeConfig(), and other utilities as zero-argument functions that magically know which request they belong to. unctx's callAsync wraps a function call in an async context snapshot, and every use* call within that scope resolves to the correct instance.

Node.js AsyncLocalStorage is now stable and recommended for new projects that need framework-agnostic async context. The pattern is straightforward: create an AsyncLocalStorage instance, call .run(store, callback) at request entry, and call .getStore() anywhere in the async call tree to retrieve the per-request data. Modern frameworks like Hono and Fastify expose this pattern directly — Hono's c.set() / c.get() and Fastify's request.requestContext are both thin wrappers over AsyncLocalStorage.

cls-hooked predates AsyncLocalStorage and used the deprecated async_hooks API to simulate the same behavior. It was the only option before Node.js 12.17.0 made AsyncLocalStorage available. In 2026, cls-hooked should be considered legacy — any new code should use AsyncLocalStorage directly or via a framework abstraction. The maintenance burden of polyfilling async context tracking on top of lower-level hooks is not justified now that the standard API is stable and performant.

A practical recommendation: for Next.js applications using the App Router, server-only combined with Next.js's built-in headers() and cookies() provides request context through React's own async component model. For Nuxt applications, unctx is already handling context transparently. For custom Hono or Fastify servers, AsyncLocalStorage is the correct primitive. Reach for cls-hooked only when maintaining legacy Node.js 10/12 compatibility, which in 2026 is rarely a real constraint.

When debugging async context issues, the most common symptom is getStore() returning undefined in a location where you expected the context to be populated. This typically indicates that the async call chain was broken — a setTimeout, setInterval, or external library callback created a new async context root that does not inherit the parent context. The solution is to re-enter the context using run() inside any callback that creates a new async root. Node.js's AsyncResource class provides bind() and runInAsyncScope() for wrapping callbacks that need to inherit the current async context, which works alongside all three libraries since they are built on top of the same async_hooks infrastructure.

Performance Characteristics of Async Context Propagation

AsyncLocalStorage has matured significantly in Node.js 18 and 20 in terms of performance. Earlier versions (Node.js 12–14) had measurable overhead — up to 20% throughput reduction in tight async loops — due to the cost of async context tracking hooks. Node.js 18+ reduced this overhead to under 2% in typical HTTP request handling scenarios, making AsyncLocalStorage viable for production use in high-throughput APIs. The V8 team invested in optimizing async context propagation in 2023–2024, and the results are visible in Node.js 20 and 22 benchmarks compared to earlier LTS versions.

unctx's callAsync (which wraps AsyncLocalStorage internally) adds a small wrapper overhead on top of the native AsyncLocalStorage API. In microbenchmarks, this overhead is negligible for real application code — the cost of a typical database query or HTTP call dwarfs it. unctx's primary contribution is ergonomic, not performance-oriented: the use() and call() pattern is more readable than raw AsyncLocalStorage.run() / AsyncLocalStorage.getStore() in framework code, and the named context pattern supports multiple independent contexts in the same application without namespace collisions.

cls-hooked in its current form (wrapping AsyncLocalStorage) has no inherent performance disadvantage compared to AsyncLocalStorage directly, since it delegates to the same underlying mechanism. The historical overhead of cls-hooked came from its original async_hooks-based implementation. Teams running cls-hooked on Node.js 16+ are already using the AsyncLocalStorage bridge internally, so the performance profile matches AsyncLocalStorage. The only overhead is the namespace lookup abstraction, which is a Map lookup — effectively free compared to the cost of async context itself. For teams migrating from cls-hooked, the migration is low-risk: AsyncLocalStorage's API is the subset of cls-hooked that most applications actually use, and the migration is largely mechanical string replacement.

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 →

See also: pm2 vs node:cluster vs tsx watch and h3 vs polka vs koa 2026, better-sqlite3 vs libsql vs sql.js.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.