Async/Await và Xử Lý Lỗi Trong Node.js
1. Tổng quan
async/await giúp bạn viết code bất đồng bộ trong Node.js trông giống code đồng bộ, dễ đọc hơn hẳn so với callback hay chuỗi .then(). Nhưng nếu không hiểu rõ cơ chế bên dưới, bạn rất dễ viết sai cách bắt lỗi, dẫn đến UnhandledPromiseRejection làm crash cả ứng dụng.
Bài này đi từ cách async/await hoạt động, các pattern xử lý lỗi đúng chuẩn, đến những lỗi thường gặp khi kết hợp với Promise.all, loop, và middleware Express. Đọc xong, bạn sẽ biết cách viết hàm async an toàn và debug lỗi bất đồng bộ nhanh hơn.
2. Async/Await Hoạt Động Như Thế Nào
Một hàm khai báo async luôn trả về một Promise, dù bên trong bạn return giá trị gì. await chỉ “tạm dừng” tại điểm gọi, nhường quyền điều khiển cho event loop, không block thread.
async function getUser(id) {
const response = await fetch(`https://api.example.com/users/${id}`);
const user = await response.json();
return user;
}
getUser(1).then(user => console.log(user.name));
Dòng await fetch(...) chờ Promise từ fetch resolve mới chạy tiếp. Nếu fetch reject (ví dụ mất mạng), lỗi sẽ được throw ngay tại điểm await đó — và nếu bạn không bắt, nó sẽ reject cả Promise mà getUser trả về.
Lưu ý:
async functionluôn trả Promise, kể cả khi bạn khôngawaitgì bên trong. Gọi nó mà quênawaithoặc.then()là nguyên nhân phổ biến nhất của bug “code chạy không theo thứ tự”.
3. Bắt Lỗi Với try/catch
Cách chuẩn nhất để xử lý lỗi trong hàm async là bọc try/catch quanh các điểm await.
async function getOrder(orderId) {
try {
const order = await orderRepository.findById(orderId);
if (!order) {
throw new Error(`Order ${orderId} not found`);
}
return order;
} catch (err) {
console.error('Failed to get order:', err.message);
throw err; // re-throw để caller biết và xử lý tiếp
}
}
trybao quanh toàn bộ logic có khả năng throw hoặc reject.catch (err)nhận cả lỗi dothrowthủ công và lỗi do Promise reject.throw errở cuối để không “nuốt” lỗi — caller vẫn nhận được Promise bị reject.
Lưu ý: Nếu bạn
catchlỗi nhưng khôngthrowlại và khôngreturngiá trị hợp lệ, hàm async sẽ resolve vớiundefined. Caller tưởng mọi thứ ổn nhưng dữ liệu lại thiếu — bug rất khó phát hiện.
3.1. Custom Error Class
Để phân loại lỗi rõ ràng (lỗi nghiệp vụ, lỗi hệ thống…), nên tạo class error riêng.
class PaymentError extends Error {
constructor(message, code) {
super(message);
this.name = 'PaymentError';
this.code = code;
}
}
async function chargeCard(order) {
const result = await paymentGateway.charge(order.total);
if (!result.success) {
throw new PaymentError('Card declined', 'CARD_DECLINED');
}
return result;
}
Khi bắt lỗi, bạn có thể kiểm tra err instanceof PaymentError để xử lý khác nhau theo loại lỗi, thay vì so sánh chuỗi message dễ sai.
4. Lỗi Trong Promise.all Và Promise.allSettled
Promise.all chạy song song nhiều Promise, nhưng chỉ cần một reject là toàn bộ reject ngay, các Promise còn lại vẫn chạy “ngầm” nhưng kết quả bị bỏ qua.
async function notifyCustomers(customers) {
const results = await Promise.all(
customers.map(c => emailService.send(c.email))
);
return results;
}
Nếu emailService.send của khách hàng thứ 2 fail, toàn bộ hàm reject ngay — bạn không biết khách hàng 1, 3, 4 đã gửi thành công hay chưa.
Khi muốn biết kết quả của tất cả Promise, dù có fail, dùng Promise.allSettled:
async function notifyCustomersSafely(customers) {
const results = await Promise.allSettled(
customers.map(c => emailService.send(c.email))
);
const failed = results.filter(r => r.status === 'rejected');
if (failed.length > 0) {
console.warn(`${failed.length} email(s) failed to send`);
}
return results;
}
Promise.allSettled không bao giờ reject. Mỗi phần tử trong results có dạng { status: 'fulfilled', value } hoặc { status: 'rejected', reason }. Bạn tự quyết định xử lý tiếp thế nào.
[
{ status: 'fulfilled', value: true },
{ status: 'rejected', reason: Error: SMTP timeout },
{ status: 'fulfilled', value: true }
]
5. Lỗi Thường Gặp Khi Dùng Await Trong Loop
Dùng await trong forEach là lỗi rất phổ biến — callback của forEach không chờ async.
// SAI: forEach không await được
async function processOrders(orders) {
orders.forEach(async order => {
await orderService.process(order);
});
console.log('Done'); // chạy ngay, không chờ các order xử lý xong
}
forEach gọi callback async rồi bỏ qua Promise trả về, không chờ resolve. console.log('Done') chạy trước khi order nào được xử lý xong — nếu process throw lỗi, lỗi đó cũng không được bắt ở đâu cả, gây UnhandledPromiseRejection.
Sửa bằng for...of nếu cần xử lý tuần tự:
async function processOrdersSequentially(orders) {
for (const order of orders) {
await orderService.process(order);
}
console.log('Done');
}
Hoặc dùng Promise.all với map nếu muốn xử lý song song:
async function processOrdersParallel(orders) {
await Promise.all(orders.map(order => orderService.process(order)));
console.log('Done');
}
6. Xử Lý Lỗi Trong Express Middleware
Express mặc định không bắt lỗi từ hàm async trong route handler — nếu bạn throw bên trong, request sẽ treo hoặc crash process ở Node phiên bản cũ (Express 4 không tự catch Promise rejection).
// RỦI RO: lỗi async không được Express bắt tự động
app.get('/orders/:id', async (req, res) => {
const order = await orderService.getOrder(req.params.id);
res.json(order);
});
Cách an toàn là viết một wrapper chuyển lỗi sang next():
function asyncHandler(fn) {
return (req, res, next) => {
fn(req, res, next).catch(next);
};
}
app.get('/orders/:id', asyncHandler(async (req, res) => {
const order = await orderService.getOrder(req.params.id);
if (!order) {
const err = new Error('Order not found');
err.statusCode = 404;
throw err;
}
res.json(order);
}));
app.use((err, req, res, next) => {
res.status(err.statusCode || 500).json({ message: err.message });
});
asyncHandler bọc route handler, nếu Promise trả về bị reject, .catch(next) chuyển lỗi vào error-handling middleware của Express thay vì để nó rơi tự do.
Lưu ý: Express 5 (đang ở bản thử nghiệm tại thời điểm viết bài) đã tự catch rejection từ async handler, nhưng với Express 4 đang dùng phổ biến, bạn vẫn cần wrapper này.
7. Timeout Và AbortController
Một lỗi dễ bị bỏ qua: await một Promise không bao giờ resolve sẽ khiến hàm treo vô hạn. Dùng AbortController để giới hạn thời gian chờ.
async function fetchWithTimeout(url, timeoutMs = 3000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
return await response.json();
} catch (err) {
if (err.name === 'AbortError') {
throw new Error(`Request to ${url} timed out after ${timeoutMs}ms`);
}
throw err;
} finally {
clearTimeout(timer);
}
}
setTimeout gọi controller.abort() sau timeoutMs, khiến fetch reject với lỗi tên AbortError. finally luôn dọn timer để tránh memory leak dù thành công hay thất bại.
8. Viết Test Cho Hàm Async
Dùng Jest để test cả trường hợp thành công và lỗi.
<!-- package.json (phần dependencies, không phải Maven) -->
npm install --save-dev jest
// orderService.js
async function getOrder(orderId, repository) {
const order = await repository.findById(orderId);
if (!order) {
throw new Error(`Order ${orderId} not found`);
}
return order;
}
module.exports = { getOrder };
// orderService.test.js
const { getOrder } = require('./orderService');
describe('getOrder', () => {
it('trả về order khi tìm thấy', async () => {
const mockRepo = { findById: jest.fn().mockResolvedValue({ id: 1, total: 100 }) };
const order = await getOrder(1, mockRepo);
expect(order).toEqual({ id: 1, total: 100 });
});
it('throw lỗi khi không tìm thấy order', async () => {
const mockRepo = { findById: jest.fn().mockResolvedValue(null) };
await expect(getOrder(99, mockRepo)).rejects.toThrow('Order 99 not found');
});
});
mockResolvedValue giả lập Promise resolve với giá trị cho trước. rejects.toThrow kiểm tra Promise bị reject đúng với message mong đợi — đây là cách chuẩn để test lỗi bất đồng bộ, thay vì bọc try/catch thủ công trong test.
PASS ./orderService.test.js
getOrder
✓ trả về order khi tìm thấy (3 ms)
✓ throw lỗi khi không tìm thấy order (2 ms)
9. Những Lỗi Thường Gặp Cần Tránh
- Quên
await: gọi hàm async nhưng khôngawaitkhiến code chạy không đúng thứ tự, lỗi bị “nuốt” âm thầm. - Mixed
.then()vàawait: dễ gây nhầm lẫn, chỉ chọn một phong cách trong cùng một hàm. try/catchquá rộng: bọc cả logic không liên quan đến async khiến khó biết lỗi xuất phát từ đâu.- Không re-throw trong catch: khiến caller nhận giá trị
undefinedthay vì lỗi, gây bug âm thầm ở tầng trên. - Dùng
forEachvới async callback: không chờ được, nên dùngfor...ofhoặcPromise.all.
Lưu ý: Nếu logic của bạn không có bất kỳ I/O hay tác vụ bất đồng bộ nào, không cần đánh dấu hàm là
async— chỉ làm tăng overhead tạo Promise không cần thiết.
10. Kết luận
async/await giúp code bất đồng bộ trong Node.js dễ đọc, nhưng xử lý lỗi đúng cách mới là phần quan trọng để ứng dụng chạy ổn định. Hãy luôn bọc try/catch quanh điểm await, re-throw lỗi khi cần, dùng Promise.allSettled khi muốn biết kết quả toàn bộ các task song song, và đừng quên wrapper cho Express route handler. Viết test cho cả trường hợp thành công và lỗi giúp bạn tự tin hơn khi refactor logic bất đồng bộ về sau.