How to implement logging in Express.js (Morgan, Winston)?
Logging in Express.js uses two separate libraries for two separate jobs: Morgan captures every HTTP request and response automatically; Winston logs whatever your application code explicitly reports.
Theory
TL;DR
- Morgan = HTTP request logger (security camera recording every visitor)
- Winston = application logger (notebook recording what happened inside)
- Morgan is middleware - it runs on every request without any code in your route handlers
- Winston is a library you call directly:
logger.info(),logger.error() - Decision rule: Morgan for request/response tracking; Winston for errors, business logic, and everything else
Quick example
const express = require('express');
const morgan = require('morgan');
const winston = require('winston');
const app = express();
// Morgan logs every HTTP request automatically
app.use(morgan('combined'));
// Winston logs only what you tell it to
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [new winston.transports.File({ filename: 'app.log' })]
});
app.get('/users/:id', (req, res) => {
logger.info(`Fetching user ${req.params.id}`); // You decide when to call this
res.json({ id: req.params.id });
});
// Morgan output: GET /users/123 200 5ms
// Winston output: {"level":"info","message":"Fetching user 123"}Morgan logged the HTTP transaction. Winston logged the business event. Neither knew about the other.
Key difference
Morgan is middleware - it intercepts every request via app.use() and writes a log line after the response finishes. You write zero logging code per route. Winston is a library you call explicitly. If you don't write logger.info(...), nothing gets logged. Morgan answers "what HTTP traffic hit my server?"; Winston answers "what did my application code actually do?"
When to use
- Morgan only: simple APIs, dev environments, when HTTP traffic logs are enough
- Winston only: CLI tools, background jobs, services without HTTP
- Both together: any production application - which is most cases
- Morgan + custom token: when you need to include request body, user ID, or custom headers alongside HTTP data
- Winston + multiple transports: when logs need to go to files AND external services like Datadog, CloudWatch, or Sentry
Comparison table
| Aspect | Morgan | Winston |
|---|---|---|
| What it logs | HTTP requests/responses | App events, errors, custom messages |
| How you use it | Middleware (app.use()) | Direct calls (logger.info(), logger.error()) |
| Automatic? | Yes, every request | No, you decide what to log |
| Output format | Predefined (dev, combined, tiny) or custom | Fully configurable (JSON, text, etc.) |
| Performance | Minimal overhead | Depends on transports (file I/O is async by default) |
| When to use | Request/response tracking | Errors, business logic, debugging |
How it works
Morgan hooks into Express's response cycle. For each request, it waits for res.on('finish') before writing the log line. That means it captures the final status code and response size, not just the incoming request. Nothing blocks.
Winston works differently. When you call logger.info(), Winston formats the message and passes it to each configured transport. File transports use fs.createWriteStream() with buffered writes by default. If a transport is slow (remote HTTP endpoint, for example), Winston queues messages internally rather than blocking your route handler.
Common mistakes
Mistake 1: logging sensitive data
// Wrong: passwords and tokens end up in your log files
morgan.token('body', (req) => JSON.stringify(req.body));
app.use(morgan(':method :url :body'));
// Right: filter sensitive fields first
morgan.token('body', (req) => {
const { password, token, ...safe } = req.body || {};
return JSON.stringify(safe);
});
app.use(morgan(':method :url :body'));Anyone who accesses those log files can impersonate users or call external services using the leaked credentials.
Mistake 2: synchronous file writes
// Wrong: blocks the entire event loop on every write
const logger = winston.createLogger({
transports: [new winston.transports.File({ filename: 'app.log', sync: true })]
});
// Right: async is the default - just don't set sync: true
const logger = winston.createLogger({
transports: [new winston.transports.File({ filename: 'app.log' })]
});One slow disk write with sync: true adds 10-100ms latency to every request.
Mistake 3: wrong log levels
// Wrong: everything at "info" level
logger.info('User logged in');
logger.info('Database connection failed'); // This is an error!
logger.info('Cache miss for key xyz'); // This is debug noise
// Right: level reflects actual severity
logger.info('User logged in');
logger.error('Database connection failed');
logger.debug('Cache miss for key xyz');In production you set level: 'error' to cut the noise. If real errors are logged at info, they disappear along with everything else when you filter.
Mistake 4: creating a new logger per request
// Wrong: opens new file handles on every request
app.get('/api/data', (req, res) => {
const logger = winston.createLogger({ /* config */ });
logger.info('Processing request');
res.json({});
});
// Right: create once at module level, reuse everywhere
const logger = winston.createLogger({ /* config */ });
app.get('/api/data', (req, res) => {
logger.info('Processing request');
res.json({});
});At high traffic you hit the OS file descriptor limit and the app crashes with "EMFILE: too many open files".
Mistake 5: not skipping health check noise
// Wrong: monitoring pings /health every 5 seconds, polluting logs
app.use(morgan('combined'));
// Right: skip requests you don't care about
app.use(morgan('combined', {
skip: (req) => req.path === '/health'
}));A health check every 5 seconds generates 17,000 useless log lines per day.
Real-world usage
- Standard Express APIs: Morgan for HTTP, Winston for errors and business events
- Next.js: Morgan in API routes, Winston for server-side logging
- AWS Lambda: Winston with CloudWatch transport
- Docker/Kubernetes: stdout output from Morgan/Winston, the platform routes to ELK or Datadog
- NestJS: teams often add Winston on top of the built-in logger for file persistence
Follow-up questions
Q: Why does Morgan wait for the response to finish before logging?
A: Morgan needs the HTTP status code and response size, which are not available until the response is sent. It subscribes to res.on('finish') and writes the log line only after that event fires.
Q: How do you prevent Morgan from logging health check requests?
A: Use the skip option: app.use(morgan('combined', { skip: (req) => req.path === '/health' })). Without it, monitoring tools that ping that endpoint every few seconds will flood your logs.
Q: What is the difference between Winston's format.json() and format.simple()?
A: json() outputs structured logs (one JSON object per line) that log aggregation services can parse and index. simple() outputs human-readable text. Use simple() in development, json() in production.
Q: How do you ensure logs are written before the process exits?
A: Call logger.close() in your shutdown handler: process.on('SIGTERM', () => { logger.close(); process.exit(0); }). Without this, in-flight buffered messages may never reach the file.
Q: How do you correlate logs across microservices?
A: Generate a request ID (UUID) at the entry point, pass it through HTTP headers to downstream services, and include it in every Winston log message. Tools like Datadog or ELK group all related logs by that ID. This pattern is called distributed tracing.
Q (senior-level): Your API handles 100k requests per second. Morgan is causing 15% CPU overhead. How do you fix it?
A: Several options in order of impact. Switch to morgan('tiny') to reduce format complexity. Sample requests: skip: () => Math.random() > 0.1 logs only 10%. Replace Winston with Pino, which is significantly faster for structured logging. Move logging to a separate process via Redis or Kafka. Or disable Morgan entirely and rely on reverse proxy logs from Nginx or Cloudflare instead. The right answer depends on how much log fidelity you actually need.
Examples
Basic setup: Morgan and Winston side by side
const express = require('express');
const morgan = require('morgan');
const winston = require('winston');
const app = express();
app.use(express.json());
// HTTP request logging (automatic)
app.use(morgan('dev'));
// Application logging (manual)
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: 'logs/app.log' })
]
});
app.get('/users/:id', (req, res) => {
logger.info('Fetching user', { userId: req.params.id });
res.json({ id: req.params.id, name: 'Alice' });
});
app.listen(3000, () => logger.info('Server started', { port: 3000 }));
// Morgan prints: GET /users/42 200 3.456 ms - 28
// Winston logs: {"level":"info","timestamp":"...","message":"Fetching user","userId":"42"}Morgan ran without any code in the route handler. Winston only logged what you told it to.
Production setup: daily log rotation and error handling
const express = require('express');
const morgan = require('morgan');
const winston = require('winston');
require('winston-daily-rotate-file');
const app = express();
app.use(express.json());
// Daily rotation: keeps 14 days, max 20MB per file
const transport = new winston.transports.DailyRotateFile({
filename: 'logs/application-%DATE%.log',
datePattern: 'YYYY-MM-DD',
maxSize: '20m',
maxFiles: '14d'
});
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [transport]
});
// Route Morgan output through Winston - one place to check in production
const morganStream = { write: (msg) => logger.http(msg.trim()) };
app.use(morgan('combined', { stream: morganStream }));
app.post('/api/orders', (req, res) => {
try {
logger.info('Order created', { userId: req.user?.id, orderId: req.body.id });
res.json({ success: true });
} catch (err) {
logger.error('Order creation failed', { error: err.message, stack: err.stack });
res.status(500).json({ error: 'Failed' });
}
});
// Flush buffered logs before shutdown
process.on('SIGTERM', () => { logger.close(); process.exit(0); });Routing Morgan through Winston means all logs, HTTP and application, land in the same file. One grep command covers everything when debugging a production incident.
Request ID tracking across logs
const express = require('express');
const morgan = require('morgan');
const winston = require('winston');
const { v4: uuidv4 } = require('uuid');
const app = express();
const logger = winston.createLogger({
format: winston.format.json(),
transports: [new winston.transports.Console()]
});
// Attach a unique ID to every request
app.use((req, res, next) => {
req.id = uuidv4();
res.setHeader('X-Request-ID', req.id);
next();
});
// Include that ID in Morgan logs
morgan.token('request-id', (req) => req.id);
app.use(morgan(':request-id :method :url :status :response-time ms'));
// Child logger carries requestId automatically in every message
app.use((req, res, next) => {
req.logger = logger.child({ requestId: req.id });
next();
});
app.get('/api/data', (req, res) => {
req.logger.info('Fetching data'); // {"message":"Fetching data","requestId":"abc-123"}
res.json({ ok: true });
});
// Morgan: abc-123 GET /api/data 200 4.5 ms
// Winston: {"message":"Fetching data","requestId":"abc-123"}
// Both share the same ID - search once, find everythingWhen a request fails in production, you search by request ID and see every log line from both Morgan and Winston that belongs to it. No guessing which logs are related.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.