@esinx/async-context
A TypeScript library for async context management, using React-like context API powered by Node.js AsyncLocalStorage.
Installation
npm install @esinx/async-context
# or
yarn add @esinx/async-context
# or
pnpm add @esinx/async-context
What is Async Context?
Async context allows you to maintain contextual information throughout an async call chain without explicitly passing it through every function parameter. This is particularly useful for:
- Request tracing: Maintain request IDs across async operations
- User context: Keep user information available throughout request handling
- Logging: Automatically include contextual information in logs
- Database transactions: Maintain transaction context across async operations
- Feature flags: Access feature flag state throughout your application
The Problem
In traditional async JavaScript/TypeScript code, passing context through multiple layers can be cumbersome:
// Without async context - tedious parameter passing
async function handleRequest(userId: string, requestId: string) {
const user = await fetchUser(userId, requestId)
const data = await processData(user, requestId)
await saveResult(data, userId, requestId)
}
async function fetchUser(userId: string, requestId: string) {
console.log(`[${requestId}] Fetching user ${userId}`)
// ... fetch logic
}
async function processData(user: User, requestId: string) {
console.log(`[${requestId}] Processing data for ${user.id}`)
// ... processing logic
}
The Solution
With async context, you can maintain this information implicitly:
// With async context - clean and maintainable
const userContext = createContext<User | null>(null)
const requestContext = createContext<string>('')
async function handleRequest(userId: string, requestId: string) {
await requestContext.run(requestId, async () => {
const user = await fetchUser(userId)
await userContext.run(user, async () => {
const data = await processData()
await saveResult(data)
})
})
}
async function fetchUser(userId: string) {
const requestId = use(requestContext)
console.log(`[${requestId}] Fetching user ${userId}`)
// ... fetch logic
}
async function processData() {
const user = use(userContext)
const requestId = use(requestContext)
console.log(`[${requestId}] Processing data for ${user.id}`)
// ... processing logic
}
Installation
npm install async-context
# or
yarn add async-context
# or
pnpm add async-context
Quick Start
import { createContext, use } from 'async-context'
// Create a context with a default value
const userContext = createContext<string>('anonymous')
// Run code with a specific context value
await userContext.run('john-doe', async () => {
// All async operations in this scope have access to the context
const currentUser = use(userContext) // 'john-doe'
await someAsyncOperation()
// Still available after async operations
const stillCurrentUser = use(userContext) // 'john-doe'
})
// Outside the run scope, back to default
const defaultUser = use(userContext) // 'anonymous'
API Reference
createContext<T>(initialValue: T): ContextHandle<T>
Creates a new context handle with an initial/default value.
const stringContext = createContext<string>('default')
const numberContext = createContext<number>(0)
const userContext = createContext<User | null>(null)
ContextHandle<T>
The object returned by createContext with the following methods:
handle.run<Result>(value: T, fn: () => Promise<Result>): Promise<Result>
Runs an async function with a specific context value.
const result = await handle.run('new-value', async () => {
// Context is 'new-value' within this scope
return await someAsyncOperation()
})
handle.use(): T
Gets the current context value (same as the standalone use function).
const currentValue = handle.use()
use<T>(handle: ContextHandle<T>): T
Gets the current value from a context handle.
const currentUser = use(userContext)
runWithContext<T>(handle: ContextHandle<T>, value: T)
Creates a reusable function that runs operations with a specific context value.
const runAsAdmin = runWithContext(userContext, adminUser)
const result = await runAsAdmin(async () => {
// This runs with adminUser as the context
return await performAdminOperation()
})
Common Patterns
Request Tracing
import { createContext, use } from 'async-context'
const requestIdContext = createContext<string>('')
// Express middleware
app.use((req, res, next) => {
const requestId = req.headers['x-request-id'] || generateId()
requestIdContext.run(requestId, () => {
next()
})
})
// In any handler or service
async function processOrder() {
const requestId = use(requestIdContext)
logger.info(`Processing order`, { requestId })
// ... processing logic
}
User Context
interface User {
id: string
role: string
permissions: string[]
}
const userContext = createContext<User | null>(null)
// Authentication middleware
async function authenticate(token: string) {
const user = await verifyToken(token)
return userContext.run(user, async () => {
// All subsequent operations have access to user context
return await handleRequest()
})
}
// In any service
async function createPost(data: PostData) {
const currentUser = use(userContext)
if (!currentUser) {
throw new Error('Not authenticated')
}
if (!currentUser.permissions.includes('create:post')) {
throw new Error('Insufficient permissions')
}
// ... create post logic
}
Database Transactions
const transactionContext = createContext<Transaction | null>(null)
async function withTransaction<T>(fn: () => Promise<T>): Promise<T> {
const transaction = await db.beginTransaction()
try {
const result = await transactionContext.run(transaction, fn)
await transaction.commit()
return result
} catch (error) {
await transaction.rollback()
throw error
}
}
// Usage
await withTransaction(async () => {
await createUser(userData)
await createProfile(profileData)
// Both operations use the same transaction automatically
})
Nested Contexts
const tenantContext = createContext<string>('default')
const userContext = createContext<User | null>(null)
await tenantContext.run('tenant-123', async () => {
await userContext.run(user, async () => {
// Both contexts are available
const tenant = use(tenantContext) // 'tenant-123'
const currentUser = use(userContext) // user object
await performOperation()
})
})
Advanced Usage
Context Composition
interface AppContext {
user: User | null
requestId: string
tenant: string
}
const appContext = createContext<AppContext>({
user: null,
requestId: '',
tenant: 'default',
})
// Helper function to create context
function createAppContext(user: User, requestId: string, tenant: string) {
return { user, requestId, tenant }
}
await appContext.run(createAppContext(user, requestId, tenant), async () => {
const { user, requestId, tenant } = use(appContext)
// ... application logic
})
Context Providers (React-like pattern)
class ContextProvider {
constructor(
private userContext = createContext<User | null>(null),
private requestContext = createContext<string>('')
) {}
async provideContext<T>(
user: User,
requestId: string,
fn: () => Promise<T>
): Promise<T> {
return this.requestContext.run(requestId, async () => {
return this.userContext.run(user, fn)
})
}
getCurrentUser() {
return use(this.userContext)
}
getCurrentRequestId() {
return use(this.requestContext)
}
}
Best Practices
- Use meaningful default values: Choose defaults that make sense for your application
- Keep context immutable: Don't modify context objects; create new ones instead
- Avoid context for frequently changing data: Context is best for relatively stable information
- Use TypeScript: Take advantage of type safety for your context values
- Document your contexts: Make it clear what each context represents and when it's available
- Test with context: Include context scenarios in your tests
Error Handling
Context is automatically cleaned up when errors occur:
try {
await userContext.run(user, async () => {
// Context is available here
throw new Error('Something went wrong')
})
} catch (error) {
// Context is back to default/initial value
const currentUser = use(userContext) // back to initial value
}
Performance Considerations
- AsyncLocalStorage has minimal overhead for most applications
- Context lookup is very fast (O(1))
- Nested contexts have minimal additional cost
- Consider using context composition for complex state rather than many individual contexts
Requirements
- Node.js 16+ (AsyncLocalStorage support)
- TypeScript 4.5+ (for best type support)
License
MIT