Bỏ qua để đến nội dung
Knowledge Hub

Lỗi async và xử lý lỗi hay gặp trong Node.js/TypeScript Trung cấp

Thời gian đọc ước tính: 9 phút

Trang này gom các lỗi async và xử lý lỗi hay lọt qua code review nhất. Điểm chung: code chạy được ở happy path, chỉ nổ khi DB chậm, API ngoài lỗi hoặc tải tăng.

Mọi output trong comment là kết quả chạy thật trên Node 24.17.

Đang tải diagram…

Hầu hết lỗi dưới đây là một biến thể của nhánh “Không”: Promise được tạo ra nhưng không ai chờ, nên lỗi không về đúng chỗ.

Dùng async callback trong forEach

Hay gặp

async function importUsers(ids: number[]) {
let saved = 0;
ids.forEach(async (id) => {
await saveUser(id);
saved++;
});
return saved; // 0 — forEach không chờ callback async
}

Nên viết

async function importUsers(ids: number[]) {
let saved = 0;
for (const id of ids) {
await saveUser(id); // tuần tự, lỗi ném ra đúng try/catch bên ngoài
saved++;
}
return saved; // 3 với ids = [1, 2, 3]
}
// Cần song song? Dùng Promise.all(ids.map(saveUser)) có giới hạn concurrency.

Vì sao

  • forEach bỏ qua giá trị trả về của callback, nên Promise bị vứt đi. Hàm trả 0 trước khi lưu xong user nào.
  • Tệ hơn: nếu saveUser ném lỗi, try/catch bên ngoài không bắt được. Lỗi thành unhandled rejection và process thoát (đã chạy thử: main: done, no error caught rồi crash).

return Promise trong try mà không await

Hay gặp

async function getProfile(id: string) {
try {
return fetchProfileFromDb(id); // trả Promise ra ngoài, chưa settle
} catch {
return DEFAULT_PROFILE; // không bao giờ chạy khi DB lỗi
}
}
// Kết quả: getProfile ném "db down" thay vì trả DEFAULT_PROFILE

Nên viết

async function getProfile(id: string) {
try {
return await fetchProfileFromDb(id); // chờ settle ngay trong try
} catch {
return DEFAULT_PROFILE; // chạy đúng khi DB lỗi
}
}

Vì sao

try/catch chỉ bắt lỗi xảy ra trong lúc nó đang chạy. Không có await, hàm thoát khỏi try ngay khi Promise còn pending; lúc Promise reject thì catch đã qua rồi. Quy tắc: trong try, luôn return await.

await tuần tự những việc độc lập

Hay gặp

const user = await getUser(id); // 100 ms
const orders = await getOrders(id); // 100 ms, không phụ thuộc user
// tổng ~202 ms

Nên viết

const [user, orders] = await Promise.all([getUser(id), getOrders(id)]);
// tổng ~101 ms

Vì sao

Mỗi await dừng hàm tới khi xong. Hai lời gọi không phụ thuộc nhau mà viết tuần tự thì latency cộng dồn, endpoint chậm gấp đôi không vì lý do gì. Chỉ song song hoá khi số lượng nhỏ và cố định; với danh sách dài, xem Promise.all không giới hạn.

Promise.all khi cần kết quả từng phần

Hay gặp

// Gửi thông báo cho 3 kênh; 1 kênh lỗi
await Promise.all([sendPush(u), sendEmail(u), sendSms(u)]);
// Promise.all rejected: smtp timeout
// -> không biết push/sms đã gửi chưa, retry cả 3 -> user nhận push 2 lần

Nên viết

const results = await Promise.allSettled([sendPush(u), sendEmail(u), sendSms(u)]);
// [ 'fulfilled', 'rejected', 'fulfilled' ]
const failed = results
.map((r, i) => ({ r, channel: CHANNELS[i] }))
.filter(({ r }) => r.status === 'rejected');
// chỉ retry kênh lỗi

Vì sao

Promise.all fail-fast: reject ngay khi một Promise reject, bỏ qua kết quả các Promise còn lại (chúng vẫn chạy tiếp, không bị huỷ). Dùng all khi “một hỏng là hỏng hết”; dùng allSettled khi các việc độc lập và cần biết từng cái ra sao.

Fire-and-forget không có .catch

Hay gặp

async function register(dto: RegisterDto) {
const user = await usersRepo.create(dto);
sendWelcomeEmail(user); // không await, không catch
return user;
}
// SMTP lỗi -> Error: SMTP 421 ... Node.js v24.17.0, exit code 1

Nên viết

async function register(dto: RegisterDto) {
const user = await usersRepo.create(dto);
sendWelcomeEmail(user).catch((err) =>
logger.error({ err, userId: user.id }, 'welcome email failed'),
);
return user;
}
// Tốt hơn nữa: đẩy vào queue/outbox để có retry.

Vì sao

Từ Node 15, mặc định --unhandled-rejections=throw: Promise reject mà không có handler sẽ thành uncaught exception và process thoát. Một email lỗi kéo sập cả API, cắt ngang mọi request đang xử lý. Mọi Promise không được await phải có .catch.

async executor trong new Promise

Hay gặp

const config = new Promise(async (resolve) => {
const raw = await readFile(path, 'utf8');
resolve(JSON.parse(raw)); // JSON hỏng -> ném trong async executor
});
// Process crash vì unhandled rejection; nếu có handler toàn cục
// thì `config` pending mãi mãi, request treo.

Nên viết

async function loadConfig(path: string) {
const raw = await readFile(path, 'utf8');
return JSON.parse(raw); // lỗi -> Promise trả về reject đúng cách
}

Vì sao

Lỗi ném trong executor đồng bộ thì new Promise tự chuyển thành reject. Nhưng executor async trả về một Promise khác mà không ai nghe, nên lỗi đi vào Promise đó. Khi đã có await, viết hàm async thường là đủ, không cần new Promise.

catch rồi trả null

Hay gặp

async function findUser(id: string) {
try {
return await db.user.findUnique({ where: { id } });
} catch {
return null;
}
}
// DB mất kết nối -> null -> controller trả 404 "User không tồn tại"

Nên viết

async function findUser(id: string) {
return db.user.findUnique({ where: { id } }); // null = không có; lỗi = lỗi
}
// Chỉ catch lỗi bạn xử lý được, ví dụ Prisma P2002 (trùng unique) -> 409.

Vì sao

  • null vốn nghĩa là “không tìm thấy”. Gộp “DB sập” vào null biến sự cố 500 thành 404 hàng loạt.
  • Alert dựa trên tỉ lệ 5xx sẽ im lặng, client có thể còn xoá cache vì tưởng user đã bị xoá.

throw string và bọc lỗi làm mất nguyên nhân

Hay gặp

if (!order) throw 'order not found'; // e.stack === undefined
try {
await paymentClient.charge(order);
} catch (e) {
throw new Error('Thanh toán thất bại'); // lỗi gốc ECONNRESET biến mất
}

Nên viết

if (!order) throw new NotFoundException(`Order ${id} không tồn tại`);
try {
await paymentClient.charge(order);
} catch (e) {
throw new Error(`Thanh toán order ${order.id} thất bại`, { cause: e });
}
// console.log in ra: Error: Thanh toán ... { [cause]: Error: read ECONNRESET ... }

Vì sao

Ném string thì không có stack, instanceof Error sai, exception filter của NestJS xử lý như lỗi lạ. Bọc lỗi mà không truyền cause thì log chỉ còn câu chung chung. Option cause có từ ES2022; console.log/util.inspect của Node in cả chuỗi [cause].

Trộn callback và Promise

Hay gặp

function getUser(id: number, cb: (err: Error | null, user?: User) => void) {
findUser(id)
.then((user) => cb(null, user))
.catch((err) => cb(err)); // bắt luôn lỗi ném từ trong cb
}
// Callback ném lỗi -> .catch gọi cb lần 2:
// bad cb call 1 user=An
// bad cb call 2 err=bug trong callback

Nên viết

import { callbackify } from 'node:util';
const getUser = callbackify(findUser); // cb gọi đúng 1 lần
// Lỗi ném trong cb thành uncaughtException, không quay lại cb.
// Tốt nhất: chuyển hẳn caller sang async/await.

Vì sao

.catch đặt sau .then(cb) bắt cả lỗi do chính callback ném ra. Callback bị gọi hai lần: response gửi hai lần (ERR_HTTP_HEADERS_SENT), hoặc transaction commit rồi rollback. util.callbackify gọi callback ngoài chuỗi Promise nên không có vòng lặp này.

  • Không có async callback trong forEach, map không đi kèm Promise.all.
  • Trong try, mọi Promise đều return await / await.
  • Các await liên tiếp không phụ thuộc nhau đã gộp Promise.all (số lượng nhỏ).
  • Chọn all hay allSettled có chủ đích.
  • Mọi Promise không await đều có .catch (hoặc đẩy qua queue).
  • Không catch để trả null/[] che lỗi hạ tầng.
  • Chỉ throw object Error, bọc lỗi luôn kèm { cause }.
  • Không new Promise(async ...), không gọi callback trong .then có .catch phía sau.

Tiếp theo: Số, ngày, chuỗi.