Xử lý lỗi trong Node.js chuyên nghiệp: Hướng dẫn Error Handling từ A-Z

VMas-Dev-KA

Xử lý lỗi trong Node.js chuyên nghiệp: Đừng chỉ dùng console.log(err)

Bạn từng thấy ứng dụng Node.js của mình dừng đột ngột chỉ vì một lỗi nhỏ? Hay nhận được response lỗi đầy stack trace từ production, lộ cả đường dẫn tuyệt đối đến source code?

Nếu bạn vẫn quen dùng console.log(err) mỗi khi có lỗi, rất có thể bạn đang:

  • Làm ứng dụng dễ bị crash.
  • Không thể truy vết lỗi nghiêm trọng.
  • Trả về thông tin lỗi thiếu an toàn cho client.

Bài viết này sẽ giúp bạn xây dựng một hệ thống xử lý lỗi chuyên nghiệp trong Node.js + Express, từ phân loại lỗi, try-catch đúng cách, custom error class, async wrapper, global middleware, và logging tập trung – thứ có thể tái sử dụng cho mọi dự án.


Tại sao console.log(err) là thói quen xấu?

console.log(err) chỉ in lỗi ra terminal. Khi chạy trên server thật (production):

  • Không ai đọc terminal liên tục → bỏ lỡ lỗi quan trọng.
  • Lỗi không được ghi lại → không thể phân tích sau.
  • Ứng dụng vẫn crash nếu không có xử lý khác.

Ngoài ra, nếu bạn trả lỗi trực tiếp về client:

res.status(500).send(err.stack) // ❌ Cực kỳ nguy hiểm

Kẻ tấn công có thể đọc được đường dẫn file, tên biến, thậm chí secret key nếu lộ.

Vậy thay vì ghi log linh tinh, chúng ta cần một chiến lược rõ ràng: phân loại lỗi → bắt lỗi tập trung → log có hệ thống → trả message an toàn. Và tuyệt đối không dùng console.log trong production – hãy dùng logger chuyên dụng (Winston, Pino) sẽ đề cập ở cuối bài.


Phân loại lỗi: Operational Error vs Programmer Error

Theo Node.js Best Practices GitHub, mọi lỗi đều thuộc một trong hai loại:

Loại lỗi Định nghĩa Ví dụ Ai xử lý?
Operational Error Lỗi xảy ra trong quá trình vận hành bình thường, có thể dự đoán và xử lý được – Database connection fail
– Request timeout
– Input validation fail
– File not found
Ứng dụng (bắt và trả message phù hợp)
Programmer Error Lỗi do lập trình viên gây ra, không nên “bắt” để tiếp tục hoạt động – Gọi hàm trên undefined
– Sai cú pháp
– Dùng sai kiểu dữ liệu
Lập trình viên (phải fix code)

👉 Nguyên tắc vàng:

  • Với Operational Error: Bắt, log, trả lỗi thân thiện, ứng dụng vẫn chạy.
  • Với Programmer Error: Để ứng dụng crash, log chi tiết, fix code ngay.

Trong Express, hầu hết lỗi bạn xử lý hàng ngày đều là Operational Error (validation, database, network…).


Try-Catch với Async/Await: Cách làm đúng

Nhiều junior dev quên try-catch trong async/await, dẫn đến Unhandled Promise Rejection và server crash.

❌ Cách sai – không bắt lỗi

app.get('/user/:id', async (req, res) => {
  const user = await User.findById(req.params.id) // Nếu lỗi DB thì crash
  res.json(user)
})

✅ Cách đúng – luôn try-catch

app.get('/user/:id', async (req, res) => {
  try {
    const user = await User.findById(req.params.id)
    if (!user) {
      return res.status(404).json({ message: 'User not found' })
    }
    res.json(user)
  } catch (error) {
    // Ghi lỗi ra logger thay vì console.log
    logger.error('Database error:', error)
    res.status(500).json({ message: 'Internal server error' })
  }
})

Nhưng viết try-catch lặp lại ở mọi controller rất dài và dễ quên. Giải pháp? Async wrapper (sẽ có ở phần Global Middleware).

🔄 Xử lý lỗi với Promise.catch

Với cách viết promise, đừng quên .catch():

app.get('/user/:id', (req, res) => {
  User.findById(req.params.id)
    .then(user => res.json(user))
    .catch(err => {
      logger.error(err)
      res.status(500).json({ message: 'Error' })
    })
})

⚠️ Lưu ý: Event emitter (ví dụ stream, EventEmitter) cũng có thể gây crash nếu không bắt lỗi error event. Luôn gắn .on('error', handler).


Tạo Global Error Middleware trong Express

Đây là trái tim của hệ thống xử lý lỗi chuyên nghiệp. Mọi lỗi từ route, middleware đều dồn về một nơi duy nhất.

Bước 1: Custom Error Class (quản lý statusCode)

Tạo file utils/AppError.js:

// utils/AppError.js
class AppError extends Error {
  constructor(message, statusCode) {
    super(message)
    this.statusCode = statusCode
    this.isOperational = true   // đánh dấu là operational error
    Error.captureStackTrace(this, this.constructor)
  }
}

module.exports = AppError

Giờ bạn có thể throw lỗi rất gọn:

const AppError = require('./utils/AppError')

app.get('/user/:id', async (req, res, next) => {
  const user = await User.findById(req.params.id)
  if (!user) {
    return next(new AppError('User not found', 404))
  }
  res.json(user)
})

Bước 2: Async Wrapper – loại bỏ try-catch thủ công

Để không phải viết try-catch trong mọi async route, hãy tạo hàm wrapper:

// utils/catchAsync.js
module.exports = (fn) => {
  return (req, res, next) => {
    fn(req, res, next).catch(next) // lỗi tự động chuyển sang global error handler
  }
}

Sử dụng:

const catchAsync = require('./utils/catchAsync')
const AppError = require('./utils/AppError')

app.get('/user/:id', catchAsync(async (req, res) => {
  const user = await User.findById(req.params.id)
  if (!user) throw new AppError('User not found', 404)
  res.json(user)
}))

Rất gọn, không còn try-catch lộn xộn, và lỗi sẽ được truyền vào next().

Bước 3: Global Error Middleware

// middleware/errorHandler.js
const AppError = require('../utils/AppError')

const handleDuplicateKeyDB = (err) => {
  return new AppError('Dữ liệu bị trùng key', 400)
}

const sendErrorDev = (err, res) => {
  // Môi trường dev: gửi toàn bộ stack trace (hữu ích cho debug)
  res.status(err.statusCode).json({
    status: err.statusCode,
    message: err.message,
    stack: err.stack,
    error: err
  })
}

const sendErrorProd = (err, res) => {
  // Môi trường production: chỉ gửi message an toàn
  if (err.isOperational) {
    res.status(err.statusCode).json({
      status: err.statusCode,
      message: err.message
    })
  } else {
    // Programmer error: không tiết lộ chi tiết, chỉ log nội bộ
    logger.error('ERROR 💥', err)
    res.status(500).json({
      status: 'error',
      message: 'Something went wrong'
    })
  }
}

module.exports = (err, req, res, next) => {
  err.statusCode = err.statusCode || 500
  err.message = err.message || 'Internal Server Error'

  if (process.env.NODE_ENV === 'development') {
    sendErrorDev(err, res)
  } else if (process.env.NODE_ENV === 'production') {
    // KHÔNG clone err bằng spread operator để giữ nguyên prototype chain
    let error = err

    // Xử lý lỗi cụ thể: duplicate key MongoDB
    if (error.code === 11000) error = handleDuplicateKeyDB(error)

    sendErrorProd(error, res)
  }
}

Sau đó, trong app.js hoặc server chính, đăng ký middleware sau tất cả các route:

const errorHandler = require('./middleware/errorHandler')

// ... định nghĩa route

app.use(errorHandler) // phải để cuối cùng

Lúc này, mọi next(err) hoặc lỗi từ catchAsync đều chạy qua global handler. Bạn không cần viết res.status().json() lẻ tẻ nữa.


Lỗi thường gặp: Uncaught Exception và Unhandled Rejection

Dù đã có global middleware, vẫn có những lỗi không qua được Express:

  • Lỗi đồng bộ trong code không thuộc request/response (ví dụ: lỗi trong setInterval, lỗi khi khởi tạo DB).
  • Promise rejection không được bắt (nếu bạn quên không dùng catchAsync hoặc try-catch ở ngoài route).

Đây là những lỗi có thể sập cả server.

Giải pháp cuối cùng: bắt ở process level

// Trong file server chính (ví dụ server.js)
const server = app.listen(port, () => {
  console.log(`Server running on port ${port}`)
})

// unhandled rejection (Promise)
process.on('unhandledRejection', (err) => {
  console.error('UNHANDLED REJECTION! 💥 Shutting down...')
  console.error(err.name, err.message)
  server.close(() => {
    process.exit(1)
  })
})

// uncaught exception (sync)
process.on('uncaughtException', (err) => {
  console.error('UNCAUGHT EXCEPTION! 💥 Shutting down...')
  console.error(err.name, err.message)
  process.exit(1)
})

Quan trọng: Đây chỉ là “lưới an toàn” cuối cùng. Bạn vẫn phải sửa code để không tạo ra unhandled rejection. Việc process.exit(1) giúp server shutdown sạch sẽ, sau đó trình quản lý process (PM2, Docker) tự restart.


Logging chuyên nghiệp thay vì console.log

Trong tất cả ví dụ trên, tôi dùng logger.error() thay vì console.error. Dưới đây cách cài đặt nhanh với Winston:

npm install winston
// utils/logger.js
const winston = require('winston')

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    new winston.transports.File({ filename: 'error.log', level: 'error' }),
    new winston.transports.File({ filename: 'combined.log' })
  ]
})

if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({
    format: winston.format.simple()
  }))
}

module.exports = logger

Sau đó dùng logger.error(err) trong toàn bộ ứng dụng.

So sánh log lỗi dạng console.log không cấu trúc bên terminal và log dạng JSON với Winston có timestamp, level, stack trace – dễ dàng phân tích lỗi trong Node.js production


Best Practices tóm gọn

  1. Không bao giờ để lộ stack trace cho client ở production – dùng global error handler để che giấu.
  2. Luôn log lỗi ra file hoặc dịch vụ tập trung (Sentry, Loggly, Winston + file rotate). Console là không đủ.
  3. Phân biệt operational error (bắt và xử lý được) và programmer error (fix code).
  4. Dùng custom error class để quản lý statusCode và loại lỗi.
  5. Dùng async wrapper để tránh try-catch lặp lại.
  6. Luôn bắt unhandledRejection và uncaughtException để server không bị crash bất ngờ.
  7. Trong unit test, kiểm tra xem lỗi có phải instance của AppError không.

FAQ

1. Có nên dùng thư viện bắt lỗi không?

Có, một số thư viện giúp việc xử lý lỗi gọn hơn:

  • express-async-errors: cho phép throw lỗi trực tiếp trong async route mà không cần wrapper.
  • http-errors: tạo lỗi HTTP nhanh.

Tuy nhiên, tự xây dựng custom error class và async wrapper như bài viết giúp bạn hiểu sâu và linh hoạt điều chỉnh. Với dự án lớn, nên dùng thư viện để giảm code trùng.

2. Tại sao next(err) lại quan trọng trong Express?

next(err) là cách duy nhất để Express biết rằng có lỗi xảy ra trong middleware/route. Nếu bạn chỉ throw lỗi mà không dùng next hoặc không có catchAsync, Express 4 sẽ không xử lý được và lỗi sẽ bị bỏ qua hoặc crash server.

⚠️ Note: Hiện tại (tháng 5/2026) Express 5 vẫn trong beta, chưa khuyến khích dùng production. Với Express 4, bắt buộc phải dùng wrapper hoặc thư viện như express-async-errors.


Tài liệu tham khảo chính thức


Kết luận

Xử lý lỗi không chỉ là để tránh crash – nó là thước đo sự chuyên nghiệp của một backend developer. Hãy bắt đầu ngay hôm nay: xóa bỏ console.log(err) tùy tiện, thay bằng custom error class, async wrapper, global error middleware và logger tập trung. Ứng dụng của bạn sẽ ổn định hơn, an toàn hơn và dễ bảo trì hơn rất nhiều.

Bookmark lại bài viết này để làm checklist cho mọi dự án Node.js sắp tới nhé!

Chia sẻ bài viết này