Promises: Giải quyết trạng thái, nối chuỗi và xử lý lỗi
Xây dựng mô hình tư duy chuẩn xác về trạng thái Promise, resolution, chaining, khôi phục lỗi, adoption, combinators và các API hiện đại.
Bản đồ học tập phát triển phần mềm bởi Tran Trong Thuc · Về dự án Atlas · Cập nhật lần cuối: 5 thg 11, 2026
Tóm tắt nhanh (TL;DR)
Promise là đối tượng đại diện cho một kết quả trong tương lai, không phải chính tác vụ đang chạy. Mỗi mắt xích trong chuỗi handler tạo một Promise mới. Trong một tình huống giả định, nếu dịch vụ thanh toán chỉ ghi log lỗi ở .catch() mà không ném lại lỗi, bước xử lý tiếp theo có thể hiểu nhầm giao dịch đã thành công. Để tránh lỗi này, cần nắm rõ trạng thái, cơ chế nhận kết quả và cách lỗi lan truyền qua chuỗi Promise.
💡 Quy tắc bỏ túi:
.then()và.catch()tạo Promise mới; giá trị trả về hoặc lỗi do handler ném ra có thể quyết định kết quả của chuỗi..finally()cũng trả về Promise mới, nhưng thông thường giữ nguyên giá trị thành công hoặc lý do thất bại trước đó. Chỉ khi bước dọn dẹp ném lỗi hoặc trả về Promise bị từ chối thì kết quả mới thay đổi. Khi gỡ lỗi, hãy theo dõi từng Promise trong chuỗi.
- Promise là chiếc vé hẹn lấy kết quả, không phải tác vụ đang chạy: Reject hay bỏ rơi Promise không hề tự động hủy bỏ socket mạng, câu truy vấn database hay worker thread đang thực thi dưới nền.
- Resolved không đồng nghĩa với Fulfilled: Một Promise được xem là "resolved" khi số phận của nó đã được niêm phong. Nó hoàn toàn có thể ở trạng thái pending nhưng đã resolved nếu đang nhận nuôi (adopt) một Promise con chưa settled.
- Mỗi mắt xích sinh ra một Promise downstream độc lập: Trả về giá trị thường sẽ làm downstream fulfilled; ném lỗi (
throw) sẽ làm downstream rejected; trả về một Promise/thenable sẽ kích hoạt cơ chế nhận nuôi (adoption). - Lựa chọn đúng combinator theo nghiệp vụ:
Promise.all()dừng ngay khi có lỗi (fail-fast);Promise.allSettled()quan sát trọn vẹn 100% kết quả;Promise.any()lấy thành công đầu tiên;Promise.race()lấy kết quả sớm nhất bất kể thành bại. - Cạm bẫy chết người (Lỗi bị nuốt trọn - Swallowed Error): Handler
.catch()là một trạm khôi phục. Nếu chỉ ghi log mà không chủ độngthrow err, downstream Promise sẽ được giải quyết ở trạng thái fulfilled với giá trịundefined, âm thầm cho phép các nghiệp vụ nguy hiểm phía sau chạy tiếp như thể không hề có sự cố.
Ba trạng thái của Promise
Theo đặc tả ECMAScript, một Promise luôn nằm ở một trong ba trạng thái duy nhất:
pending: Trạng thái ban đầu, kết quả chưa được định đoạt.fulfilled: Tác vụ hoàn thành thành công, mang theo một giá trị (value).rejected: Tác vụ thất bại, mang theo một lý do từ chối (reason).
Trạng thái Promise
Resolution
const p1 = new Promise((resolve) => {
resolve(42);
resolve(100); // Lệnh này hoàn toàn bị bỏ qua, p1 vĩnh viễn là 42
});"Resolved" khác với "Fulfilled" như thế nào?
Đây là điểm gây nhầm lẫn phổ biến nhất ngay cả với các lập trình viên nhiều năm kinh nghiệm:
const inner = new Promise((resolve) => {
setTimeout(() => resolve('Xong!'), 1000);
});
const outer = new Promise((resolve) => {
resolve(inner); // outer đã resolved, nhưng vẫn pending trong 1 giây!
});
console.log(outer); // Promise { <pending> }Cơ chế nối chuỗi (Chaining) và tạo Promise Downstream
Khi gọi .then(), .catch(), hay .finally(), JavaScript không biến đổi Promise hiện tại mà luôn cấp phát một đối tượng Promise mới trong bộ nhớ heap:
P0 --then(fn1)--> P1 --catch(fn2)--> P2- p0Promise nguồn
- then(handler)Tạo p1
- return valuep1 fulfilled
- throw errorp1 rejected
- return Promisep1 adopt Promise đó
Bảng đối chiếu kết quả của Handler
| Hành động trong Handler | Trạng thái Downstream Promise | Giá trị Downstream |
|---|---|---|
Trả về một giá trị thông thường (return 42) | fulfilled | 42 |
Ném ra một lỗi (throw new Error()) | rejected | Đối tượng Error |
Trả về một Promise khác (return fetch()) | Nhận nuôi (Adopt) | Phụ thuộc vào kết quả của fetch() |
Không trả về gì (undefined) | fulfilled | undefined |
Tự kiểm tra: chuỗi này settle thành gì?
Dự đoán giá trị fulfillment của p1 trước khi mở đáp án.
const p0 = Promise.resolve({ id: 7 });
const p1 = p0.then((user) => {
loadProfile(user.id); // cố ý bỏ return trong bài tập này
return user.id;
});Giả sử loadProfile() trả về một Promise fulfill với đối tượng hồ sơ người dùng.
Xem giải thích chi tiết
- Handler
return user.id(7) nênp1fulfill với giá trị7. loadProfile(user.id)vẫn được kích hoạt, nhưng Promise của nó không được adopt vì không đượcreturn.- Đoạn mã phía sau
await p1sẽ tiếp tục chạy với giá trị7trong khi hồ sơ người dùng có thể vẫn chưa tải xong—lỗi thứ tự này tạo cảm giác mã "đã bất đồng bộ" nhưng thực chất mắt xích xử lý đã bị đứt gãy hoàn toàn.
Phòng thí nghiệm Promise (Interactive Lab)
Sử dụng môi trường mô phỏng dưới đây để từng bước quan sát cách các Promise được khởi tạo, resolve, và lan truyền kết quả:
Phòng thực hành phân giải Promise
const p0 = Promise.resolve(10);
const p1 = p0.then((value) => value * 2);Trạng thái các Promise
P0
Source promise
- Trạng thái:
- Thành công (Fulfilled)
- Phân giải:
- Hoàn thành với giá trị
- Giá trị:
- 10
Handler đang thực thi
Không có
Nhật ký kết quả
Chưa có kết quả
Giải thích bước này
Start with the source promise state. Chain methods create new promises; they do not mutate this source promise into the downstream result.
Lan truyền lỗi và Khôi phục (Recovery)
Lỗi trong chuỗi Promise hoạt động như một thác nước: nếu mắt xích hiện tại không có handler xử lý lỗi (onRejected), lỗi sẽ nhảy cóc qua tất cả các .then() trung gian cho đến khi gặp .catch() đầu tiên.
- throw Error
- then(onFulfilled)Bị bỏ qua
- catch(onRejected)Xử lý rejection
- return fallbackDownstream fulfilled
- Quên rethrow?Có thể vô tình nuốt lỗi
fetchUser(id)
.then((user) => fetchSettings(user))
.then((settings) => applyTheme(settings))
.catch((err) => {
// Khôi phục bằng giá trị mặc định:
return defaultSettings;
})
.then((settings) => {
// Bước này VẪN ĐƯỢC CHẠY nếu .catch() phía trên trả về defaultSettings!
console.log('Áp dụng cài đặt:', settings);
});Tự kiểm tra: .catch() có chặn handler phía sau không?
fetchCart()
.catch((error) => {
report(error);
return emptyCart();
})
.then((cart) => renderCheckout(cart));Nếu fetchCart() reject và emptyCart() trả về { items: [] }, renderCheckout có chạy không?
Xem giải thích chi tiết
- Có. Handler
.catch()khôi phục bằng cách trả về cart bình thường, nên Promise do.catch()trả về fulfill. .then()tiếp theo vì thế vẫn chạy với cart đã khôi phục.- Trường hợp nguy hiểm là
.catch()chỉ log rồireturnngầmundefined, khiến bước “thành công” phía sau vẫn chạy.
.finally() giữ nguyên kết quả nếu dọn dẹp thành công
.finally(onFinally) phù hợp để dọn dẹp tài nguyên sau cả thành công lẫn thất bại. Hàm onFinally không nhận giá trị kết quả hoặc lý do lỗi. Khác với handler của .then() và .catch(), giá trị thông thường được trả về từ .finally() không thay thế kết quả trước đó.
const success = await Promise.resolve(2).finally(() => 77);
// success === 2: giá trị 77 bị bỏ qua
try {
await Promise.reject(new Error("original")).finally(() => 77);
} catch (error) {
console.log(error.message); // "original"
}
await Promise.resolve("ok").finally(() => {
throw new Error("cleanup failed");
}); // reject với "cleanup failed"Nếu bước dọn dẹp trả về một Promise chưa hoàn tất, Promise downstream chờ bước dọn dẹp settle. Khi cleanup bị reject hoặc ném lỗi, lỗi cleanup thay thế kết quả ban đầu. Không nên dùng .finally() để sửa dữ liệu thành công hoặc phục hồi lỗi; hãy dùng .then() hoặc .catch() cho mục đích đó.
Bốn Combinators chuẩn theo mục đích thiết kế
| Phương án | Điều kiện thành công | Hành vi khi lỗi |
|---|---|---|
| Promise.all | Mọi input fulfilled | Reject khi gặp rejection đầu tiên |
| Promise.allSettled | Chờ mọi input settle | Trả record trạng thái thay vì fail-fast |
| Promise.race | Input settle đầu tiên quyết định | Có thể fulfilled hoặc rejected đầu tiên |
| Promise.any | Fulfillment đầu tiên thắng | Chỉ reject khi tất cả đều reject |
| API | Nhu cầu sử dụng | Hành vi khi có lỗi (Failure) |
|---|---|---|
Promise.all() | Cần tất cả input cùng thành công | Fail-fast: Reject ngay khi bất kỳ input nào reject |
Promise.allSettled() | Cần kết quả của mọi input (dù thành công hay thất bại) | Luôn fulfill với mảng { status, value / reason } |
Promise.any() | Chỉ cần ít nhất một input thành công đầu tiên | Chỉ reject khi tất cả input đều thất bại (AggregateError) |
Promise.race() | Cần phản hồi sớm nhất (thành công hoặc thất bại) | Settle ngay theo kết quả của Promise về đích đầu tiên |
Executor chạy ngay, handler chạy trong microtask
Hàm executor truyền vào new Promise(executor) chạy đồng bộ lúc tạo Promise. Handler của .then() không chạy ngay trong cùng câu lệnh, mà được xếp lịch sau phần mã đồng bộ.
console.log("A");
const promise = new Promise((resolve) => {
console.log("B");
resolve(42);
});
promise.then((value) => console.log(value));
console.log("C");
// Kết quả: A, B, C, 42Đừng nhầm việc tạo một Promise với việc đợi kết quả. Tác vụ mạng hoặc tác dụng phụ có thể đã bắt đầu ngay khi gọi hàm trả về Promise.
Nhận nuôi Promise và đối tượng thenable
Nếu handler trả về một Promise hoặc thenable (đối tượng có phương thức then), Promise downstream tiếp nhận kết quả cuối cùng của đối tượng đó. Một Promise đã resolved vẫn có thể pending khi đang chờ Promise khác.
const inner = Promise.resolve("done");
console.log(Promise.resolve(inner) === inner); // true: cùng constructor
const outer = new Promise((resolve) => resolve(inner));
console.log(outer === inner); // false: outer là đối tượng khác
console.log(await outer); // "done"Không dùng Promise.resolve(inner) như ví dụ bắt buộc tạo một Promise ngoài mới. Với inner là Promise của cùng constructor, Promise.resolve(inner) trả về chính inner.
Nhánh song song khác với chuỗi phụ thuộc
Gắn hai .then() vào cùng một Promise sẽ tạo hai nhánh, không phải hai bước tuần tự:
const userPromise = fetchUser();
const profile = userPromise.then((user) => fetchProfile(user.id));
const permissions = userPromise.then((user) => fetchPermissions(user.id));
const [profileData, access] = await Promise.all([profile, permissions]);Nếu phải kiểm tra quyền trước khi lấy hồ sơ, hãy viết phụ thuộc đó thành chuỗi rõ ràng thay vì coi thứ tự đăng ký handler là ràng buộc nghiệp vụ.
Input rỗng và các API mới
Promise.all([]) và Promise.allSettled([]) hoàn thành với []; Promise.any([]) bị reject bằng AggregateError; Promise.race([]) vẫn pending. Điều này quan trọng khi danh sách công việc được tạo động.
Promise.withResolvers() trả về { promise, resolve, reject } để điều khiển việc hoàn thành Promise từ một nơi khác. Promise.try(fn) gọi fn đồng bộ và chuyển giá trị hoặc lỗi do callback tạo ra thành kết quả Promise. Cần kiểm tra khả năng hỗ trợ theo môi trường chạy; không giả định mọi runtime đều có hai API này.
async/await, cancellation và quyền sở hữu tác vụ
await giúp viết luồng xử lý dễ đọc, nhưng không tự hủy tác vụ hoặc giới hạn số lượng yêu cầu đang chạy. Promise.all() cũng không hủy các Promise còn lại khi một Promise bị reject. Khi API hỗ trợ hủy, hãy truyền AbortSignal vào tác vụ thực sự:
const controller = new AbortController();
const request = fetch("/api/report", { signal: controller.signal });
// Khi người dùng bỏ thao tác: controller.abort();
const response = await request;Hủy ở client không đồng nghĩa với việc mọi side effect đã xảy ra trên server được rollback.
Sự cố thực tế cần tránh
Kịch bản thực tế: "Lỗi bị nuốt trọn" (Swallowed Error) dẫn đến giao hàng miễn phí
Một hệ thống thương mại điện tử xử lý thanh toán đơn hàng bằng chuỗi Promise như sau:
function processOrderPayment(orderId: string) {
return chargeCustomer(orderId)
.catch((err) => {
// Lập trình viên chỉ ghi log lỗi trừ thẻ nhưng quên ném lại lỗi (rethrow):
logger.error('Failed to charge card', { orderId, err });
})
.then(() => {
// Bước này VẪN CHẠY vì .catch() phía trên ngầm trả về undefined (fulfilled)!
return markOrderAsPaidAndDispatch(orderId);
});
}- Hậu quả: Khi thẻ khách hàng không đủ số dư,
chargeCustomerbị reject. Tuy nhiên.catch()chỉ ghi log rồi kết thúc hàm mà không ném lỗi tiếp (return undefined). Điều này biến downstream Promise thành fulfilled! Hệ thống lập tức gọimarkOrderAsPaidAndDispatch(orderId), xuất kho và gửi hàng cho khách dù chưa thu được đồng nào! - Nguyên nhân cốt lõi: Lập trình viên quên quy tắc cơ bản:
.catch()là một trạm khôi phục (recovery). Nếu không ném lỗi tiếp, downstream sẽ mặc định hiểu là sự cố đã được khắc phục hoàn toàn. - Cách khắc phục chuẩn: Luôn rethrow lỗi nếu bạn chỉ muốn ghi log hoặc quan sát sự cố:
return chargeCustomer(orderId) .catch((err) => { logger.error('Failed to charge card', { orderId, err }); throw err; // Tiếp tục đẩy lỗi xuống downstream để chặn đơn hàng }) .then(() => markOrderAsPaidAndDispatch(orderId));
Kịch bản thực tế: Quên return khiến toast báo “đã lưu” quá sớm
loadUser()
.then((user) => {
saveUser(user); // quên return
})
.then(() => {
showToast('Saved');
});- Hậu quả: Toast có thể hiện khi
saveUservẫn đang chạy—hoặc sau khi nó reject—nên sản phẩm báo thành công trước khi biết dữ liệu đã bền vững. - Nguyên nhân cốt lõi: Handler đầu trả về
undefinedngay, nên Promise downstream fulfill mà không adopt Promise củasaveUser. - Cách khắc phục chuẩn:
returnđúng công việc bất đồng bộ cần nối chuỗi:
loadUser()
.then((user) => saveUser(user))
.then(() => {
showToast('Saved');
});Bài tập kiểm tra tư duy
Hãy dự đoán thứ tự in ra màn hình của đoạn mã sau trước khi mở đáp án:
console.log('1');
Promise.resolve().then(() => {
console.log('2');
return Promise.resolve('3');
}).then((val) => {
console.log(val);
});
Promise.resolve().then(() => {
console.log('4');
});
console.log('5');Xem giải thích chi tiết
Thứ tự in ra: 1 -> 5 -> 2 -> 4 -> 3
Giải thích từng bước:
- Mã đồng bộ chạy trước: In
1, sau đó in5. - Hai microtask đầu tiên được xếp vào hàng đợi:
- Microtask A (in
2). - Microtask B (in
4).
- Microtask A (in
- Chạy Microtask A: in
2. Hàm này trả về mộtPromise.resolve('3'). Theo cơ chế Promise Adoption, engine cần thêm các microtask nội bộ để làm phẳng (flatten) Promise này. - Chạy Microtask B: in
4. - Sau khi Promise con được giải quyết xong, callback downstream tiếp theo mới được đẩy vào microtask queue và in ra
3.
Checklist rà soát mã nguồn cho Kỹ sư
Trước khi đưa mã nguồn sử dụng Promise vào production, hãy đối chiếu với checklist sau:
- Xử lý lỗi trọn vẹn: Mọi nhánh
.catch()đã chủ độngthrowlại lỗi nếu không có dữ liệu khôi phục hợp lệ chưa? - Tránh lồng nhau vô nghĩa (Promise Hell): Đã phẳng hóa chuỗi bằng cách
returnPromise thay vì viết lồng.then()bên trong.then()chưa? - Lựa chọn combinator chính xác: Đã sử dụng
Promise.allSettled()cho các tác vụ hàng loạt (batch operations) độc lập chưa? - Quản lý tài nguyên với
finally(): Các tác vụ dọn dẹp (tắt loading spinner, đóng kết nối DB) đã được đặt trong.finally()để luôn được thực thi chưa? - Không bọc lại Promise thừa thãi: Tránh viết
new Promise((res, rej) => existingPromise.then(res, rej)).
Tài liệu quy chuẩn
-
MDN — Promise.prototype.finally() — giữ nguyên trạng thái ban đầu nếu cleanup thành công, và tác động của cleanup khi thất bại.
-
MDN — Promise.withResolvers() — tách thời điểm tạo và hoàn thành Promise.
-
MDN — Promise.try() — chuyển kết quả hoặc lỗi callback thành Promise.
-
MDN — Promise.all() — combinator và input rỗng.
-
ECMAScript Language Specification (ECMA-262) — Promise Objects — Đặc tả quy chuẩn ngôn ngữ về vòng đời, trạng thái và thuật toán Promise Resolution.
-
MDN Web Docs — Using Promises — Hướng dẫn thực hành chuẩn mực từ Mozilla Developer Network.