Error Handling and Logging in Node.js with Winston

When building backend applications with Node.js, it’s easy to overlook error handling and logging, especially in the early stages of development. Many developers rely heavily on console.log() for debugging; however, this approach quickly falls short in production environments, where stability, scalability, and maintainability are essential.

Errors are inevitable, whether they come from user input, third-party APIs, database operations, or even the server itself. Without proper error handling, these issues can cause applications to crash unexpectedly, degrade user experience, or worse, expose sensitive information. Similarly, without structured logging, tracking down the root cause of a failure becomes a game of guesswork.

This is where Winston, a popular and flexible logging library for Node.js, comes in. Winston provides a standardized method for capturing, formatting, and storing logs at various levels of severity. Paired with a thoughtful error-handling strategy, it allows developers to build resilient applications that are easier to monitor, debug, and scale.

In this article, we’ll explore how to design a robust error-handling system in Node.js and integrate Winston for structured logging. By the end, you’ll understand not just how to catch errors, but how to learn from them through meaningful logs.

Error Handling in Node.js

Errors are inevitable in any backend system, but understanding how they occur and how Node.js treats them is the first step toward building resilience. Let’s break this down.

1. Types of Errors: Synchronous vs. Asynchronous

Synchronous Errors

These errors occur immediately during execution, often due to invalid input or coding mistakes. For example:

JavaScript
function divide(a, b) {
  if (b === 0) {
    throw new Error("Cannot divide by zero");
  }
  return a / b;
}

try {
  console.log(divide(10, 0));
} catch (err) {
  console.error("Caught:", err.message);
}

Here, the error is thrown and caught synchronously in the try…catch block.

Asynchronous Errors

These happen in callbacks, promises, or async/await code. Node.js won’t automatically catch them with try…catch unless you explicitly handle them.

JavaScript
// Callback-based async error

const fs = require("fs");

fs.readFile("nonexistent.txt", (err, data) => {
  if (err) {
    console.error("Async error:", err.message);
    return;
  }

  console.log(data.toString());
});

// Promise-based async error

Promise.reject(new Error("Something went wrong"))
  .catch(err => console.error("Caught promise error:", err.message));

Key takeaway: Asynchronous errors must be handled where they occur; otherwise, they bubble up and may cause your app to crash.

2. How Node.js Deals with Uncaught Exceptions and Unhandled Rejections

Uncaught Exceptions
If a synchronous error is missed, Node.js emits an uncaughtException event and will terminate the process by default.

JavaScript
process.on("uncaughtException", (err) => {

  console.error("Uncaught Exception:", err.message);

  process.exit(1); // Always exit to avoid inconsistent state

});

throw new Error("Boom!"); // This will trigger uncaughtException

Unhandled Promise Rejections

If a rejected promise has no .catch(), Node.js emits an unhandledRejection event. From Node.js v15+, unhandled rejections also terminate the process by default (before, they only emitted warnings).

JavaScript
process.on("unhandledRejection", (reason, promise) => {
  console.error("Unhandled Rejection at:", promise, "reason:", reason);
  // Decide whether to exit or recover
});

Promise.reject("Oops!");

Important: You should never rely solely on these handlers for normal error handling. They’re last-resort mechanisms for logging and shutting down gracefully.

3. Best Practices for Structured Error Handling

Use try/catch for synchronous and async/await errors

JavaScript
async function fetchData() {

  try {
    const data = await someAsyncOperation();
    return data;
  } catch (err) {
    console.error("Error fetching data:", err.message);
    throw err; // rethrow if it should be handled upstream
  }

}

Always handle promise rejections

  • Use .catch() on promises.
  • Wrap async/await code in try/catch.

Fail fast but fail gracefully

If an unexpected error occurs, log it, clean up resources (e.g., close DB connections), and shut down the app safely.

Return consistent error responses
Especially in APIs, always respond with a predictable structure:

Centralized Error Handling

  • Importance of centralizing error logic
  • Error-handling middleware in Express.js
  • Creating and using custom error classes
JavaScript
{
  "status": "error",
  "message": "Invalid input provided"
}

Differentiate between operational errors and programmer errors

  • Operational errors: predictable and recoverable (invalid user input, network timeouts).
  • Programmer errors: bugs in the code (undefined variable, logic error). These often require fixing, not recovery.

Limitations of Using console.log()

Before introducing a dedicated logging tool like Winston, it’s important to understand why relying solely on console.log() is insufficient — especially in production-grade Node.js applications.

While console.log() is quick and convenient for debugging during development, it falls short when an application grows in complexity or scale. Its limitations include:

  1. No Log Levels:
    Every message printed using console.log() has the same priority. There’s no way to differentiate between informational messages, warnings, or critical errors.
  2. No Persistence:
    Logs exist only in the running console. Once the server restarts or the terminal closes, all historical logs are lost — making it impossible to trace issues retrospectively.
  3. Lack of Structure:
    Logs generated this way are plain text without consistent formatting, timestamps, or context. This makes it difficult to parse them programmatically or integrate with monitoring tools.
  4. No Scalability:
    In distributed systems or multi-instance environments, console.log() offers no way to aggregate logs or send them to external storage or analysis tools.

Because of these constraints, developers quickly outgrow console.log() as their logging solution. This is where Winston comes in — a flexible, structured logging library designed for reliability, scalability, and maintainability in production environments.

What is Winston?

Logging is more than printing messages to the console. In real-world apps, logs must be structured, categorized, and stored for tracing issues, monitoring, and analysis. Winston is a widely used Node.js logging library that supports log levels, multiple destinations, and structured formats—far beyond what console.log can offer.

Overview of Winston as a Logging Library

Winston provides a unified way to manage logs with:

  • Levels – categorize messages (error, warn, info, debug).
  • Transports – send logs to the console, files, or external services.
  • Formats – structure logs with JSON, timestamps, or custom layouts.

In short, Winston turns simple messages into well-organized, production-ready logs.

Core Features of Winston

Log Levels

Winston defines a set of severity levels (inspired by syslog):

  • error: Application errors that need immediate attention.
  • warn: Potentially harmful situations.
  • info: General operational information.
  • http: Requests/response logs.
  • debug: Detailed information for debugging.

Example: Basic Logging with Winston

Here’s a simple example demonstrating how Winston handles different log levels.
In this setup, all logs are displayed in the console — and when deployed to Galaxy, these same logs appear automatically in the platform’s Activity Logs view.

JavaScript
const winston = require("winston");

const logger = winston.createLogger({
  level: "info",
  transports: [new winston.transports.Console()],
});

logger.info("Server started successfully");
logger.error("Database connection failed");

Example Output (as shown in Galaxy or any Node.js console):

JavaScript
{"level":"info","message":"Server started successfully","timestamp":"2025-10-08T10:15:34.000Z"}

{"level":"error","message":"Database connection failed","timestamp":"2025-10-08T10:15:35.000Z"}

Transports

A transport determines where logs go. Winston allows multiple transports at the same time.

Common transports include:

  • Console: useful during development.
  • File: log to files for persistence.
  • HTTP/External Services: send logs to remote log servers.

Example:

Here, all logs print to the console, but only error logs are persisted to error.log.

JavaScript
const logger = winston.createLogger({

  transports: [
    new winston.transports.Console(),
    new winston.transports.File({ filename: "error.log", level: "error" }),
  ],

});

Formats
Logs can be formatted to improve readability or make them machine-friendly (e.g., JSON for log aggregation tools).

Example with timestamp + JSON:

JavaScript
const { combine, timestamp, json } = winston.format;

const logger = winston.createLogger({
  format: combine(timestamp(), json()),
  transports: [new winston.transports.Console()],
});

logger.info("User logged in", { userId: 123 });

Output:

Why Developers Prefer Winston over console.log

While console.log is fine for quick debugging, it falls short in production. Winston is preferred because it provides:

  1. Granularity: Different log levels instead of one-size-fits-all logging.
  2. Persistence: Logs can be stored in files or databases, not just printed to the terminal.
  3. Structure: JSON and timestamped logs make parsing and analysis easier.
  4. Scalability: Multiple transports let you send logs to different places simultaneously (console + file + monitoring tool).
  5. Maintainability: Easier debugging and auditing because logs are consistent, searchable, and can be integrated with tools like ELK, Splunk, or Datadog.

In short, Winston turns logs into actionable insights rather than noisy console output.

Setting Up Logging with Winston

To use Winston, you first install it:

Bash
npm install winston

Defining Log Levels

Winston lets you log messages at different severity levels. The default levels are: error, warn, info, http, verbose, debug, and silly.

JavaScript
const winston = require("winston");

const logger = winston.createLogger({
  level: "info", // minimum level to log
  transports: [new winston.transports.Console()],
});

logger.info("Server started");
logger.warn("High memory usage");
logger.error("Database connection failed");

Using Different Transports

A transport determines where logs are sent. You can use multiple transports at the same time.

JavaScript
const logger = winston.createLogger({
  transports: [
    new winston.transports.Console(),
    new winston.transports.File({ filename: "app.log" }),
    new winston.transports.File({ filename: "errors.log", level: "error" }),
  ],
});
  • Console – great for development.
  • File – persist logs for later analysis.
  • Error-only file – capture just critical failures.

Formatting Logs

Winston supports formats to make logs more readable or structured.

JavaScript
const { combine, timestamp, printf, json } = winston.format;

const logger = winston.createLogger({
  format: combine(
    timestamp(),
    printf(({ level, message, timestamp }) => {
      return `[${timestamp}] ${level}: ${message}`;
    })
  ),
  transports: [new winston.transports.Console()],
});

logger.info("User logged in");

Or, for structured JSON logs:

JavaScript
const logger = winston.createLogger({
  format: combine(timestamp(), json()),
  transports: [new winston.transports.File({ filename: "structured.log" })],
});

Combining Error Handling with Winston

Once you’ve set up Winston, the next step is to use it inside your error-handling workflow. This ensures that errors aren’t just caught — they’re also recorded with useful details.

Logging Errors Inside Middleware

In Express.js, a centralized error-handling middleware is the best place to capture and log problems.

JavaScript
const express = require("express");
const app = express();
const winston = require("winston");

const logger = winston.createLogger({
  transports: [new winston.transports.Console()],
});

// Example middleware
app.use((err, req, res, next) => {
  logger.error(err.message, { stack: err.stack });
  res.status(500).json({ status: "error", message: "Something went wrong" });
});

Handling Expected vs. Unexpected Errors

Not all errors are equal:

  • Expected (operational) errors: Things like invalid input or a missing resource. These should be logged at warn or info level, since they’re part of normal app behavior.
  • Unexpected (programmer/system) errors: Bugs, undefined variables, or failed DB connections. These are critical and should be logged at error level, often followed by shutting down the app.
JavaScript
if (err.isOperational) {
  logger.warn("Handled error:", { message: err.message });
} else {
  logger.error("Unexpected error:", { stack: err.stack });
  process.exit(1); // fail fast
}

Recording Stack Traces and Metadata

For debugging, logs should contain more than just messages — they should include stack traces, request details, and other metadata.

JavaScript
app.use((err, req, res, next) => {
  logger.error("Error occurred", {
    message: err.message,
    stack: err.stack,
    method: req.method,
    url: req.originalUrl,
    user: req.user?.id,
  });
  res.status(500).json({ status: "error", message: "Internal server error" });
});

This way, every log entry tells a story: what failed, where it happened, and who was affected.

Advanced Winston Usage: when to use it

Once your logging setup covers the basics, you’ll often encounter scenarios that require more control or scalability.
For example:

  • When your app runs continuously and you don’t want log files to grow indefinitely.
  • When you need to store logs for auditing or compliance (e.g., 30 days of records).
  • When you’re integrating with monitoring tools like Datadog or Loggly to analyze trends across multiple servers.

These are cases where Winston’s advanced capabilities — such as daily rotating file logs, external log management integrations, and request-level metadata — become essential for maintaining performance and traceability in production environments.

Once the basics are in place, Winston can be extended for production-grade needs.

Daily Rotating File Logs

Instead of writing endlessly to one file, use winston-daily-rotate-file to generate a new log file every day ideal in scenarios where:

  1. High volume logging – Your app generates lots of log entries, which could make a single log file unwieldy. Rotating files daily keeps logs manageable.
  2. Compliance & auditing – Certain industries (finance, healthcare, etc.) require historical logs to be retained in a structured manner.
  3. Debugging production issues – Rotating logs allow you to isolate logs by date, making it easier to track issues over time.
  4. Backup & archival – Daily log files can be automatically archived or deleted after a set period, ensuring storage efficiency.
Bash
npm install winston-daily-rotate-file
JavaScript
const DailyRotateFile = require("winston-daily-rotate-file");

const logger = winston.createLogger({
  transports: [
    new DailyRotateFile({
      filename: "logs/app-%DATE%.log",
      datePattern: "YYYY-MM-DD",
      maxFiles: "14d", // keep 2 weeks of logs
    }),
  ],
});

This keeps logs organized and prevents massive single files.

External Log Management Integrations

For large systems, logs often need to be centralized. Winston supports sending logs to tools like:

  • Elastic Stack (ELK) for powerful search/analysis.
  • Papertrail or Loggly for hosted log management.
  • Datadog / New Relic for monitoring and alerts.

Example (HTTP transport to an external log service):

JavaScript
new winston.transports.Http({
  host: "log-server.example.com",
  path: "/logs",
  ssl: true,
});

Attaching Request IDs, User Info, or Session Data

Adding context makes logs more useful for debugging. Middleware can attach IDs or user info to each log.

JavaScript
app.use((req, res, next) => {
  req.requestId = Date.now(); // simple request ID
  next();
});

app.use((err, req, res, next) => {
  logger.error("Request failed", {
    requestId: req.requestId,
    user: req.user?.id,
    url: req.originalUrl,
  });
  res.status(500).json({ status: "error" });
});

This allows you to trace errors back to specific requests or users.

With rotating files, external integrations, and contextual data, Winston becomes a scalable logging solution suitable for production environments.

Best Practices

Avoid Sensitive Data in Logs

Never log secrets like passwords, API keys, or tokens. If necessary, mask or redact sensitive fields before writing logs.

For example, you could use a utility like maskSecretLeaks to automatically mask or redact sensitive fields before writing logs:

Environment-Specific Log Levels

  • Development: verbose logs (debug, info) to help with troubleshooting.
  • Production: minimal logs (warn, error) to reduce noise and protect performance.
JavaScript
const logger = winston.createLogger({
  level: process.env.NODE_ENV === "production" ? "warn" : "debug",
});

Use Monitoring Tools Alongside Winston

Winston handles structured logging, but combining it with tools like PM2, Sentry, or New Relic enables real-time monitoring, alerting, and deeper insights.

Following these practices ensures your logging strategy stays secure, efficient, and production-ready.

Real-World Mini Project

Let’s put Winston into action with a small Express.js app.

1. Setup an Express App

JavaScript
import express from "express";
import winston from "winston";

const app = express();
app.use(express.json());

// Winston logger
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: "app.log" }),
  ],
});

2. Routes with Logging

JavaScript
app.get("/", (req, res) => {
  logger.info("Root route accessed");
  res.send("Hello, Winston Logging!");
});

app.get("/error", (req, res, next) => {
  next(new Error("Something went wrong!"));
});

3. Centralized Error Handler

JavaScript
app.use((err, req, res, next) => {
  logger.error(`Error: ${err.message}`, { stack: err.stack });
  res.status(500).json({ error: "Internal Server Error" });
});

4. Start the Server

JavaScript
app.listen(3000, () => {
  logger.info("Server running on http://localhost:3000");
});

With this setup:

  • Successful requests (like hitting /) are logged at info level.
  • Failed requests (like hitting /error) are caught by the centralized error handler and logged at error level.
  • Logs are written to both console and file (app.log).

Conclusion

Error handling and logging are not just “nice-to-haves” — they are the backbone of building resilient, production-ready Node.js applications. With a thoughtful strategy for catching errors and a structured logging tool like Winston, developers can:

  • Detect and fix issues faster through meaningful, categorized logs.
  • Maintain application stability by centralizing error handling.
  • Gain visibility into how their systems behave in real-world scenarios.

The key takeaway is simple: don’t wait until your app is in production to care about errors and logs. Start early, treat logs as first-class citizens, and let Winston help you turn raw data into actionable insights.