Why Pino for Node.js Logging
Pino has earned its reputation as the gold standard for high-performance logging in the Node.js ecosystem. Unlike traditional logging libraries that block the event loop during log writes, Pino uses a revolutionary approach where log events are serialized to JSON in the application's execution context but written to output streams asynchronously. This design philosophy translates to measurable performance benefits--Pino consistently outperforms Winston by a factor of five or more in logging operations per second while maintaining lower CPU utilization and memory consumption.
Beyond raw speed, Pino's structured JSON output provides significant operational advantages. Each log entry includes consistent metadata--timestamp, process ID, hostname, and log level--as JSON properties that can be indexed, filtered, and analyzed by log aggregation systems. This machine-readable format eliminates the need for regex-based log parsing, reducing both processing overhead and the risk of parsing errors.
The performance advantage stems from Pino's minimal dependency tree--less than twenty dependencies compared to Winston's hundreds--and its careful avoidance of unnecessary object creation and string formatting. For high-throughput applications processing thousands of requests per second, the cumulative impact of logging overhead can meaningfully affect response times and infrastructure costs. The combination of speed and structured output makes Pino the preferred choice for demanding Node.js applications that require both performance and observability.
Implementing robust logging practices also supports broader SEO optimization efforts by ensuring applications remain fast and reliable--critical factors that search engines consider when ranking websites.
The AsyncLocalStorage Imperative
Node.js's AsyncLocalStorage API, available since version 13.10.0, provides a thread-local storage pattern adapted for Node.js's asynchronous architecture. Before AsyncLocalStorage, maintaining request context across asynchronous operations required explicit context passing through function parameters--a practice that cluttered application code and was easy to forget in nested callbacks. AsyncLocalStorage solves this by providing a storage mechanism that persists context across the entire lifecycle of an asynchronous operation, including Promise chains, async/await patterns, and callback-based APIs.
const { AsyncLocalStorage } = require('async_hooks')
const asyncLocalStorage = new AsyncLocalStorage()
// Store context for an operation
asyncLocalStorage.run({ requestId: 'req-123', userId: 'user-456' }, () => {
// Any async operation within this block can access the context
const context = asyncLocalStorage.getStore()
console.log(context.requestId) // 'req-123'
// Context persists through Promise chains
return fetchData().then(() => {
const nestedContext = asyncLocalStorage.getStore()
console.log(nestedContext.requestId) // Still 'req-123'
})
})
The combination of Pino and AsyncLocalStorage creates a logging pattern where every log entry automatically includes contextual information about the request or operation that generated it. When a user reports an error, developers can filter all logs by the user's request ID and see every database query, external API call, and internal operation that occurred during that request--critical capability for debugging distributed systems and microservices architectures. This observability becomes especially valuable when building AI automation solutions where tracing decision paths and debugging complex workflows is essential.
Installation and Basic Setup
Getting started with Pino requires only the core package, though additional packages enhance the development experience and provide integration capabilities for specific frameworks.
# Core Pino package for production logging
npm install pino
# Development tools for readable output
npm install --save-dev pino-pretty
# Express integration for HTTP request logging
npm install pino-http
Creating a basic logger demonstrates Pino's simplicity while revealing its power:
const pino = require('pino')
const logger = pino()
logger.info('Application starting')
logger.error({ err: error }, 'Database connection failed')
logger.warn({ users: count }, 'High user load detected')
The default configuration produces JSON output that includes the message, log level, timestamp, process ID, and hostname:
{"level":30,"time":1701734400000,"pid":12345,"hostname":"app-server-1","msg":"Application starting"}
{"level":50,"time":1701734400123,"pid":12345,"hostname":"app-server-1","err":{"type":"Error","message":"Database connection failed","stack":"..."},"msg":"Database connection failed"}
{"level":40,"time":1701734400245,"pid":12345,"hostname":"app-server-1","users":1523,"msg":"High user load detected"}
This metadata provides essential context for production debugging without requiring explicit configuration, and the output is immediately compatible with log aggregation systems like Elasticsearch, Loki, and SigNoz. These logging patterns form the foundation of a reliable web development infrastructure that supports complex enterprise applications.
Log Levels and Strategic Usage
Pino implements a hierarchical log level system where each level has an associated numeric severity value. The numeric hierarchy matters because Pino filters logs based on threshold levels--if the logger's level is set to 'info', then 'trace' and 'debug' messages are completely skipped, incurring no serialization or output overhead. This zero-cost abstraction means debug logging can remain in production code without performance impact when the log level is set appropriately.
Choosing the right log level requires understanding the operational impact of each message type. Error-level logs should represent conditions that require human attention--failed database connections, authentication failures, or uncaught exceptions. Warning-level logs indicate conditions that might become errors if unaddressed--deprecated API usage, approaching rate limits, or elevated latency responses. Info-level logs capture significant lifecycle events that help operators understand application behavior--server starts, user authentications, and job completions.
| Level | Value | Purpose | Example Use Cases |
|---|---|---|---|
| fatal | 60 | Application crash imminent | Database connection pool exhausted - process will terminate |
| error | 50 | Errors requiring investigation | API failures, validation errors, uncaught exceptions |
| warn | 40 | Potential issues | Deprecated API usage, approaching rate limits |
| info | 30 | Significant application events | User authentication, service starts, key operations |
| debug | 20 | Detailed debugging information | Function entry/exit, variable states |
| trace | 10 | Very detailed execution flow | Loop iterations, deep debugging |
Structured Logging for Observability
Structured logging transforms log messages from text strings into data objects that can be efficiently indexed, filtered, and analyzed. Instead of embedding all information in a human-readable message, structured logs include relevant data as first-class properties that observability platforms can query directly.
// Poor practice: embedding data in message string
logger.info(`User ${userId} completed order ${orderId}`)
// Better practice: structured data alongside message
logger.info({ userId, orderId }, 'Order completed')
// Best practice: comprehensive structured context
logger.info({
userId,
orderId,
amount,
currency: 'USD',
paymentMethod: 'credit_card',
processingTime: 145
}, 'Order completed successfully')
The structured approach produces JSON output that enables powerful query patterns:
{"level":30,"time":1701734400000,"userId":"user-123","orderId":"ord-456","amount":99.99,"currency":"USD","processingTime":145,"msg":"Order completed successfully"}
This structure enables operators to find all orders above a certain amount, all users experiencing slow checkout times, or all requests that triggered a specific error--without requiring complex regex parsing or manual log review. Learn more about structured logging patterns in our comprehensive Node.js development guide.
Child Loggers for Context Management
Pino's child logger feature creates logger instances that inherit parent configuration while adding persistent contextual properties. Child loggers are essential for maintaining clean, consistent logging across complex applications without repeating contextual information in every log call.
// Create base logger
const baseLogger = pino({
level: 'info',
base: {
service: 'order-processor',
version: '1.0.0'
}
})
// Create child logger for order processing
const orderLogger = baseLogger.child({
component: 'orders'
})
// Create child logger for payments
const paymentLogger = baseLogger.child({
component: 'payments'
})
orderLogger.info({ orderId: 'ORD-123' }, 'Processing order')
paymentLogger.info({ paymentId: 'PAY-456' }, 'Processing payment')
The JSON output demonstrates how child loggers combine base and child-specific context:
{"level":30,"time":1701734400000,"service":"order-processor","version":"1.0.0","component":"orders","orderId":"ORD-123","msg":"Processing order"}
{"level":30,"time":1701734400050,"service":"order-processor","version":"1.0.0","component":"payments","paymentId":"PAY-456","msg":"Processing payment"}
Child loggers inherit the parent's level, formatters, and serializers while adding their own properties. This creates a powerful pattern when combined with AsyncLocalStorage for request-scoped logging.
Inherited Configuration
Child loggers inherit log level, formatters, and serializers from parent
Persistent Context
Contextual properties are automatically included in all child logger output
Cleaner Code
No need to repeat common properties in every log call
Debugging Clarity
Logs clearly indicate which component or service generated them
Request-Scoped Logging with AsyncLocalStorage
Implementing request-scoped logging requires coordinating AsyncLocalStorage with your HTTP framework's request lifecycle. The pattern involves creating middleware that establishes a unique request identifier, stores it in AsyncLocalStorage, and attaches a child logger to each request.
const { AsyncLocalStorage } = require('async_hooks')
const { v4: uuidv4 } = require('uuid')
const pino = require('pino')
const express = require('express')
const asyncLocalStorage = new AsyncLocalStorage()
const logger = pino()
// Middleware to establish request context
function requestContext(req, res, next) {
const requestId = req.headers['x-request-id'] || uuidv4()
const requestContext = {
requestId,
startTime: Date.now(),
method: req.method,
path: req.path,
userId: req.user?.id
}
asyncLocalStorage.run(requestContext, () => {
// Attach request-scoped logger to request object
req.log = logger.child({
requestId,
method: req.method,
path: req.path
})
req.log.info({ requestId }, 'Incoming request')
next()
})
}
// Utility to get current context anywhere in the application
function getContext() {
return asyncLocalStorage.getStore()
}
Using Request-Scoped Loggers in Route Handlers
app.get('/users/:id', async (req, res) => {
// req.log includes requestId, method, path automatically
req.log.debug({ userId: req.params.id }, 'Fetching user')
try {
const user = await getUserById(req.params.id)
req.log.info({ userId: user.id }, 'User retrieved successfully')
res.json(user)
} catch (error) {
req.log.error({ err: error, userId: req.params.id }, 'Failed to retrieve user')
res.status(500).json({ error: 'Internal server error' })
}
})
The elegance of this pattern lies in its simplicity--route handlers simply use req.log without needing to know about AsyncLocalStorage or request IDs. Every log entry from that request automatically includes the correlation ID, making it trivial to trace the entire request lifecycle through logs. Implementing these patterns as part of your comprehensive web development strategy ensures maintainable codebases with excellent debuggability.
Express Integration with pino-http
The pino-http package provides Express-specific integration that automatically creates child loggers for each request with standard HTTP request properties:
const express = require('express')
const pinoHttp = require('pino-http')
const app = express()
// Use pino-http middleware
app.use(pinoHttp({
logger: logger,
// Customize log level based on response status
customLogLevel: (req, res) => {
if (res.statusCode >= 500) return 'error'
if (res.statusCode >= 400) return 'warn'
return 'info'
},
// Custom message format
customSuccessMessage: (req, res) =>
`${req.method} ${req.url} completed in ${res.responseTime}ms`,
// Define properties to redact for security
redact: ['req.headers.authorization', 'req.body.password']
}))
app.get('/api/users', (req, res) => {
req.log.info('Users endpoint called')
res.json(users)
})
The pino-http middleware automatically adds request ID, HTTP method, URL, status code, response time, and other relevant properties to every log entry. Example output:
{"level":30,"time":1701734400000,"req":{"id":"req-abc123","method":"GET","url":"/api/users","headers":{"host":"localhost:3000"}},"res":{"statusCode":200,"responseTime":45},"responseTime":45,"msg":"GET /api/users completed in 45ms"}
This standardized format simplifies log analysis and enables immediate identification of slow requests, error rates, and traffic patterns. The customLogLevel option is particularly valuable for ensuring that HTTP errors are logged at appropriate severity levels for alerting systems.
Production Best Practices
Deploying Pino in production requires attention to configuration, security, and operational considerations that ensure logging supports rather than impedes application reliability.
Sensitive Data Redaction
Logging sensitive data is a security risk that can lead to compliance violations and data breaches. Pino provides redaction options to prevent sensitive fields from appearing in logs:
const logger = pino({
redact: [
'req.headers.authorization',
'req.headers.cookie',
'req.body.password',
'user.ssn',
'creditCard',
'email',
'phone'
]
})
Runtime Log Level Adjustment
app.post('/admin/log-level', (req, res) => {
const { level } = req.body
const validLevels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal']
if (!validLevels.includes(level)) {
return res.status(400).json({ error: 'Invalid log level' })
}
logger.level = level
logger.info({ newLevel: level }, 'Log level changed')
res.json({ message: `Log level set to ${level}` })
})
Performance Optimization
For high-throughput applications, consider these optimization strategies:
- Use the lowest log level that provides necessary information
- Pre-serialize static properties in the base configuration
- Pre-create child loggers instead of calling
.child()in hot paths - Configure messageKey for log parsing compatibility with your aggregation platform
- Consider using the
timestamp: pino.stdTimeFunctions.epochTimeoption for production
Implementing these logging practices as part of your comprehensive Node.js development strategy ensures your applications remain observable under production load. Combined with proper SEO monitoring and observability practices, your applications will deliver both performance and discoverability.
Common Questions
How does AsyncLocalStorage handle Promise chains?
AsyncLocalStorage automatically propagates context through all Promise resolutions and rejections, making it safe to use with async/await.
Can I use pino-pretty in production?
No. pino-pretty is for development only--it produces human-readable (non-JSON) output and adds significant overhead.
What's the performance impact of structured logging?
Pino's structured logging has minimal overhead. The zero-cost abstraction means disabled log levels don't impact performance at all.
Putting It All Together
The combination of Pino's high-performance JSON logging with AsyncLocalStorage's request context management creates a logging architecture that is both powerful and maintainable. Every log entry carries sufficient context to trace operations across complex systems, while the structured JSON format enables efficient storage and analysis.
const { AsyncLocalStorage } = require('async_hooks')
const { v4: uuidv4 } = require('uuid')
const pino = require('pino')
const pinoHttp = require('pino-http')
const asyncLocalStorage = new AsyncLocalStorage()
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
base: {
service: 'my-api',
version: process.env.APP_VERSION || 'development',
environment: process.env.NODE_ENV
},
redact: ['req.headers.authorization', 'req.body.password']
})
const httpLogger = pinoHttp({
logger,
customLogLevel: (req, res) => {
if (res.statusCode >= 500) return 'error'
if (res.statusCode >= 400) return 'warn'
return 'info'
}
})
function requestContext(req, res, next) {
const requestId = req.headers['x-request-id'] || uuidv4()
const store = asyncLocalStorage.run({
requestId,
startTime: Date.now()
}, () => {
req.log = logger.child({ requestId, method: req.method, path: req.path })
req.log.info({ requestId }, 'Request started')
res.on('finish', () => {
const duration = Date.now() - store.startTime
req.log.info({ requestId, statusCode: res.statusCode, duration }, 'Request completed')
})
next()
})
}
// Usage in route handlers
app.get('/api/resource', requestContext, async (req, res) => {
req.log.info('Processing resource request')
try {
const result = await fetchResource(req.params.id)
req.log.info({ resourceId: req.params.id }, 'Resource retrieved')
res.json(result)
} catch (error) {
req.log.error({ err: error }, 'Failed to retrieve resource')
res.status(500).json({ error: 'Internal server error' })
})
This pattern provides complete observability into every request's lifecycle while maintaining the performance characteristics that make Pino the preferred choice for demanding Node.js applications. The logs integrate seamlessly with observability platforms like Datadog, New Relic, and cloud-native solutions such as AWS CloudWatch Logs and Google Cloud Logging. For organizations implementing AI-powered automation workflows, these logging patterns become essential for monitoring AI decision processes and ensuring audit compliance.
Sources
- LogRocket: Logging with Pino and AsyncLocalStorage in Node.js - Comprehensive guide covering fundamental patterns of combining Pino with AsyncLocalStorage for request-scoped logging
- SigNoz: Pino Logger Complete Node.js Guide - In-depth coverage of Pino installation, configuration, and performance benchmarks
- Dash0: Contextual Logging Done Right in Node.js with AsyncLocalStorage - Professional patterns for contextual logging and OpenTelemetry integration
- Last9: Pino.js The Ultimate Guide to High-Performance Node.js Logging - Focus on performance optimization, Express integration, and production best practices