Skip to main content

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

js
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

AspectMorganWinston
What it logsHTTP requests/responsesApp events, errors, custom messages
How you use itMiddleware (app.use())Direct calls (logger.info(), logger.error())
Automatic?Yes, every requestNo, you decide what to log
Output formatPredefined (dev, combined, tiny) or customFully configurable (JSON, text, etc.)
PerformanceMinimal overheadDepends on transports (file I/O is async by default)
When to useRequest/response trackingErrors, 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

js
// 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

js
// 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

js
// 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

js
// 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

js
// 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

js
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

js
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

js
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 everything

When 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?