Published

- 8 min read

Production-Ready Logging in Node.js with AsyncLocalStorage

img of Production-Ready Logging in Node.js with AsyncLocalStorage

Introduction: Why Are My Logs So Hard to Read?

If you’ve ever worked on a Node.js application, you’ve probably faced this classic problem: a critical error appears in your production logs, but it’s just a single, lonely line. You’re left wondering: Which user triggered this? What request caused it? What were the parameters?

To solve this, we often resort to “prop drilling”—passing the request object or a context object through every single function in our call stack. It clutters our code and makes business logic harder to read.

But what if there was a better way? A way to link every log, error, and database query back to the original request that started it all, without passing a single extra parameter?

Q: Okay, you’ve got my attention. What’s this magic solution?

A: It’s a powerful, built-in Node.js module called AsyncLocalStorage. It allows you to create a request-scoped “context” that stays with a request throughout its entire lifecycle, across all asynchronous operations.

Think of it like this: when a request (a package) arrives at your server (a cargo company), AsyncLocalStorage puts a unique tracking label on it. As that package moves through different conveyor belts and is handled by different workers (your async functions), anyone can scan the label to know exactly where it came from. Crucially, one package’s label will never get mixed up with another’s.

This “tracking label” can hold anything we want: a unique requestId, user information, IP address, and more. Let’s explore how to build a robust logging system with it.


Part 1: The ‘Why’ - What Problems Are We Solving?

Q: I get the analogy, but what are the concrete, real-world benefits?

A: By using AsyncLocalStorage, we solve several major headaches in application development:

  1. Rich, Contextual Logging: Every single log line automatically includes a requestId. When you’re debugging, you can simply filter your logs by this ID to see the entire story of that request, from start to finish.
  2. Eliminating Prop Drilling: No more adding (req, res) or a context object to every function signature. Your service layer, database layer, and utility functions can remain clean and focused on their specific tasks.
  3. Centralized & Accessible Information: Need to know which user is performing an action deep within your application? Just ask the async store. No need to fetch it from the database again or pass it down ten levels.
  4. Simplified & More Maintainable Code: When your functions don’t need to worry about passing context, they become simpler, easier to test, and more reusable.

Part 2: The ‘How’ - Let’s Build It, Step by Step

Ready to see it in action? We’ll build this system piece by piece, starting with the context store itself.

Q: First things first, how do we create this magical storage?

A: It’s surprisingly simple. We just need to instantiate the AsyncLocalStorage class. Create a file to hold our context instance so it can be imported anywhere in the app.

   // src/lib/async-storage.ts

import { AsyncLocalStorage } from 'node:async_hooks'

// We can define a type for our store to get nice autocompletion.
// We will add more properties to this as we go.
type RequestContext = {
	requestId: string
	method: string
	url: string
	ip?: string
	geo?: object
	ua?: object
	user?: { id: string; role: string; type: string }
}

export const requestContext = new AsyncLocalStorage<RequestContext>()

That’s it. We now have a store ready to hold our data.

Q: How do we get data into the store for each request?

A: We use an Express.js middleware. This middleware will intercept every incoming request, create a new context for it using the run() method, and then pass control to the next middleware in the chain. Everything that happens inside the run() callback will have access to this specific context.

   // src/app.ts
import express from 'express'
import { v7 as uuidv7 } from 'uuid'
import { requestContext } from '@/lib/async-storage'
import logger from '@/logger'

const app = express()

// This middleware MUST be one of the first to run.
app.use((req, res, next) => {
	const requestId = uuidv7()
	const method = req.method
	const url = req.originalUrl || req.url
	const startTime = Date.now()

	// The magic happens here!
	// We create a new context for this request.
	requestContext.run({ requestId, method, url }, () => {
		// We can also hook into the response finish event to log performance.
		res.on('finish', () => {
			const duration = Date.now() - startTime
			logger.info('Request finished', {
				statusCode: res.statusCode,
				duration: `${duration}ms`
			})
		})
		next()
	})
})

// ... other middleware and routes

With this in place, every request is now wrapped in its own unique context containing a requestId.

Q: This is great! What about adding more data, like the user’s IP address or browser info?

A: We can create another middleware that runs after our context has been initialized. This new middleware can use requestContext.getStore() to access the current request’s context and add more information to it.

   // src/app.ts (continued)
import requestIp from 'request-ip'
import useragent from 'express-useragent'
import geoip from 'geoip-lite'

// ... after the first context middleware
app.use(requestIp.mw())
app.use(useragent.express())

app.use((req, res, next) => {
	// Get the store for the current request.
	const store = requestContext.getStore()

	// If a store exists, we can add more data to it.
	if (store) {
		const clientIp = req.clientIp
		if (clientIp) {
			store.ip = clientIp
			const geo = geoip.lookup(clientIp)
			if (geo) {
				store.geo = geo
			}
		}
		if (req.useragent) {
			const ua = req.useragent
			store.ua = {
				browser: ua.browser,
				version: ua.version,
				os: ua.os,
				platform: ua.platform
			}
		}
	}
	next()
})

Now our context is enriched with IP, location, and user agent data, all without touching our route handlers or business logic!


Part 3: The Payoff - Automatically Contextual Logs

Q: Now that the data is in the store, how does our logger automatically use it?

A: This is the most satisfying part. We’ll configure our logger (in this case, using Winston) with a custom “format” that injects the context data into every log message.

First, let’s look at a reusable logger package. The key part is that it allows us to pass in additionalFormats.

   // A reusable logger package, e.g., @my-awesome-corp/logger
import winston from 'winston'

type LoggerProps = {
	target: string
	additionalFormats?: winston.Logform.Format[]
}

export const getLogger = ({ target, additionalFormats }: LoggerProps) => {
	const logFormat = winston.format.combine(
		winston.format.timestamp(),
		winston.format.errors({ stack: true }),
		...(additionalFormats || []), // Our custom format will go here!
		winston.format.json()
	)

	const logger = winston.createLogger({
		level: 'info',
		format: logFormat,
		defaultMeta: {
			service: target,
			environment: process.env.NODE_ENV
		},
		transports: []
	})

	if (process.env.NODE_ENV !== 'production') {
		logger.add(
			new winston.transports.Console({
				format: winston.format.combine(winston.format.colorize(), winston.format.simple())
			})
		)
	} else {
		logger.add(
			new winston.transports.Console({
				format: winston.format.json()
			})
		)

		logger.add(
			new winston.transports.Http({
				host: 'log-ingestor.example.com',
				port: 443,
				ssl: true,
				headers: {
					Authorization: `Bearer ${process.env.LOG_INGESTOR_API_KEY}`
				}
			})
		)
	}

	return logger
}

Now, in our application’s logger configuration, we create the custom format that reads from AsyncLocalStorage.

   // src/logger.ts
import { getLogger, type Logger, format } from '@my-awesome-corp/logger'
import { requestContext } from '@/lib/async-storage'

// This is the function that connects our logger to the context.
const injectRequestContext = format((logEntry) => {
	const store = requestContext.getStore()
	if (store) {
		// If a context exists, inject its properties into the log entry.
		logEntry.requestId = store.requestId
		logEntry.method = store.method
		logEntry.url = store.url

		if (store.user) {
			logEntry.user = store.user
		}
		if (store.ip) {
			logEntry.ip = store.ip
		}
		// ... and so on for geo, ua, etc.
	}
	return logEntry
})

// We create our logger instance and pass our custom format to it.
const logger: Logger = getLogger({
	target: 'api',
	additionalFormats: [injectRequestContext()]
})

export default logger

Q: Perfect! Can you show me a final example, like adding user info after they log in?

A: Absolutely. Imagine you have an authentication middleware that verifies a JWT. All you need to do is add the user information to the async store.

   // A hypothetical authentication middleware
const authMiddleware = (req, res, next) => {
	const token = req.headers.authorization?.split(' ')[1]
	if (token) {
		const decodedUser = verifyJwt(token) // Your JWT verification logic
		const store = requestContext.getStore()
		if (store) {
			// Add the user to the context!
			store.user = {
				id: decodedUser.id,
				role: decodedUser.role,
				type: 'authenticated'
			}
		}
	}
	next()
}

Now, any log statement made after this middleware runs will automatically contain the user’s ID and role. If you call logger.error("Database connection failed") from deep within a service, your log output will look something like this:

   {
	"level": "error",
	"message": "Database connection failed",
	"timestamp": "2025-09-26T10:30:00.123Z",
	"service": "api",
	"environment": "production",
	"requestId": "018b2b1a-1b1a-7b1a-8b1a-1b1a1b1a1b1a",
	"method": "POST",
	"url": "/v1/orders",
	"ip": "123.45.67.89",
	"user": {
		"id": "user-abc-123",
		"role": "admin"
	}
}

Look at that! A single error message, now packed with all the context you need to debug it instantly.

Conclusion

By leveraging AsyncLocalStorage, we’ve built a powerful, production-ready logging system that is both clean and scalable. We’ve untangled our business logic from context-passing concerns and made our application significantly easier to monitor and debug.

This pattern isn’t just for logging. You can use it to manage database transactions, handle feature flags, or pass any request-specific data through your application without the pain of prop drilling. It’s a modern Node.js feature that, once you start using it, you’ll wonder how you ever lived without.