Introduction
Logging is essential for any production application. In Node.js, a good logger helps you monitor app health, debug issues, audit events, and feed data to centralized systems (such as ELK, Graylog, Datadog, etc.). While many libraries (e.g., Winston, Bunyan, Pino) exist, sometimes you need a custom logger tailored to your specific needs — one that is lightweight, structured, and production-ready.
This article explains how to implement a custom logger in Node.js for production. You’ll learn log levels, JSON structured logs, file rotation strategies, asynchronous writing, and integration tips for centralized logging. The examples are practical and ready to adapt.
Why Build a Custom Logger?
Before you start, ask why you need a custom logger:
Lightweight & focused: Only include the features you need.
Consistent JSON output: Useful for log aggregation and search.
Custom transports: Send logs to files, HTTP endpoints, or message queues.
Special formatting or metadata: Add request IDs, user IDs, or environment tags.
That said, if you need high performance and battle-tested features, consider existing libraries (Pino, Winston). But a custom logger is great when you want control and simplicity.
Key Requirements for Production Logging
For a production-ready logger, ensure the following:
Log levels (error, warn, info, debug) with configurable minimum level.
Structured output — JSON logs with timestamp, level, message, and metadata.
Asynchronous, non-blocking writes to avoid slowing your app.
Log rotation (daily rotation or size-based) and retention policy.
Integration-friendly: support for stdout (for containers) and file or HTTP transports.
Correlation IDs for tracing requests across services.
Safe shutdown — flush buffers on process exit.
Basic Custom Logger (Simple, Sync to Console)
Start small to understand the shape of a logger. This basic example prints structured logs to the console.
// simple-logger.js
const levels = { error: 0, warn: 1, info: 2, debug: 3 };
const defaultLevel = process.env.LOG_LEVEL || 'info';
function formatLog(level, message, meta) {
return JSON.stringify({
timestamp: new Date().toISOString(),
level,
message,
...meta
});
}
module.exports = {
log(level, message, meta = {}) {
if (levels[level] <= levels[defaultLevel]) {
console.log(formatLog(level, message, meta));
}
},
error(msg, meta) { this.log('error', msg, meta); },
warn(msg, meta) { this.log('warn', msg, meta); },
info(msg, meta) { this.log('info', msg, meta); },
debug(msg, meta) { this.log('debug', msg, meta); }
};
Limitations: console output is fine for local development and containers (stdout), but you need file rotation, non-blocking IO, and transports for production.
Asynchronous File Transport (Non-blocking)
Writing to files synchronously can block the event loop. Use streams and async writes instead.
// file-logger.js
const fs = require('fs');
const path = require('path');
class FileTransport {
constructor(filename) {
this.filePath = path.resolve(filename);
this.stream = fs.createWriteStream(this.filePath, { flags: 'a' });
}
write(line) {
return new Promise((resolve, reject) => {
this.stream.write(line + '\n', (err) => {
if (err) return reject(err);
resolve();
});
});
}
async close() {
return new Promise((resolve) => this.stream.end(resolve));
}
}
module.exports = FileTransport;
Use the transport in your logger to offload writes.
A Minimal Production-ready Logger Class
This logger supports multiple transports (console, file), JSON logs, async writes, log level filtering, and graceful shutdown.
// logger.js
const FileTransport = require('./file-logger');
const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 };
class Logger {
constructor(options = {}) {
this.level = options.level || process.env.LOG_LEVEL || 'info';
this.transports = options.transports || [console];
this.queue = [];
this.isFlushing = false;
// On process exit flush logs
process.on('beforeExit', () => this.flushSync());
process.on('SIGINT', async () => { await this.flush(); process.exit(0); });
}
log(level, message, meta = {}) {
if (LEVELS[level] > LEVELS[this.level]) return;
const entry = {
timestamp: new Date().toISOString(),
level,
message,
...meta
};
const line = JSON.stringify(entry);
this.transports.forEach((t) => {
if (t === console) console.log(line);
else t.write(line).catch(err => console.error('Log write failed', err));
});
}
error(msg, meta) { this.log('error', msg, meta); }
warn(msg, meta) { this.log('warn', msg, meta); }
info(msg, meta) { this.log('info', msg, meta); }
debug(msg, meta) { this.log('debug', msg, meta); }
async flush() {
if (this.isFlushing) return;
this.isFlushing = true;
const closes = this.transports
.filter(t => t !== console && typeof t.close === 'function')
.map(t => t.close());
await Promise.all(closes);
this.isFlushing = false;
}
// Synchronous flush for quick shutdown hooks
flushSync() {
this.transports
.filter(t => t !== console && t.stream)
.forEach(t => t.stream.end());
}
}
module.exports = Logger;

Join the conversation! Your thoughts help the community grow.