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.
Một Promise đi đâu sau khi tạo ra?
Phần tiêu đề “Một Promise đi đâu sau khi tạo ra?”Đ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ỗ.
Vòng lặp và await
Phần tiêu đề “Vòng lặp và await”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
forEachbỏ qua giá trị trả về của callback, nên Promise bị vứt đi. Hàm trả0trước khi lưu xong user nào.- Tệ hơn: nếu
saveUserném lỗi,try/catchbê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 caughtrồ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_PROFILENê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 msconst orders = await getOrders(id); // 100 ms, không phụ thuộc user// tổng ~202 msNên viết
const [user, orders] = await Promise.all([getUser(id), getOrders(id)]);// tổng ~101 msVì 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ỗiawait 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ầnNê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ỗiVì 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.
Lỗi không ai bắt
Phần tiêu đề “Lỗi không ai bắt”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 1Nê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.
Nuốt lỗi và làm mất dấu vết
Phần tiêu đề “Nuốt lỗi và làm mất dấu vết”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
nullvốn nghĩa là “không tìm thấy”. Gộp “DB sập” vàonullbiế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 === undefinedtry { 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 callbackNê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.
Checklist nhanh
Phần tiêu đề “Checklist nhanh”- Không có
asynccallback trongforEach,mapkhông đi kèmPromise.all. - Trong
try, mọi Promise đềureturn await/await. - Các
awaitliên tiếp không phụ thuộc nhau đã gộpPromise.all(số lượng nhỏ). - Chọn
allhayallSettledcó 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ỉ
throwobjectError, bọc lỗi luôn kèm{ cause }. - Không
new Promise(async ...), không gọi callback trong.thencó.catchphía sau.
Tiếp theo: Số, ngày, chuỗi.