Mở đầu — vì sao bài này quan trọng
Nếu bạn đã đi qua những bài đầu của khóa học, bạn biết cách viết await fetch() để gọi một API, biết dùng async function và biết bọc lỗi trong khối try/catch. Đó là kiến thức nền — và nó đủ để làm những bài tập nhỏ. Nhưng khi bạn bước vào một sản phẩm thật, nơi có hàng nghìn người dùng đang gọi đồng thời, nơi mạng 4G ở vùng ngoại ô Cần Thơ chập chờn, nơi một API bên thứ ba thỉnh thoảng treo 30 giây không phản hồi — thì await fetch() đơn lẻ không còn đủ nữa.
Bài này không lặp lại async/await cơ bản. Chúng ta sẽ đi vào những mẫu thiết kế (patterns) nâng cao mà bạn thực sự cần khi viết code chạy trong môi trường sản xuất năm 2026: chạy song song có kiểm soát, hủy tác vụ (cancellation), thiết lập timeout, thử lại (retry) thông minh, giới hạn số lượng request đồng thời (concurrency limiting), và xử lý lỗi một phần (partial failure). Đây chính là ranh giới phân biệt một lập trình viên "biết JavaScript" với một lập trình viên "viết được hệ thống đáng tin cậy".
Phần thưởng rất cụ thể: code của bạn sẽ nhanh hơn (vì biết chạy song song), bền hơn (vì biết retry và timeout), và thân thiện với người dùng hơn (vì biết hủy những request không còn cần thiết). Hãy bắt đầu.
Khái niệm cốt lõi
1. Tuần tự vs Song song — sai lầm phổ biến nhất
Hãy nhìn đoạn code sau, nó trông rất "sạch" nhưng lại chậm một cách lãng phí:
const user = await getUser(id);
const orders = await getOrders(id);
const reviews = await getReviews(id);
Ba lời gọi này không phụ thuộc vào nhau, nhưng vì mỗi await đứng một dòng nên chúng chạy tuần tự: phải đợi getUser xong mới bắt đầu getOrders. Nếu mỗi request mất 300ms, tổng cộng là 900ms.
Cách đúng là khởi động cả ba cùng lúc rồi mới chờ:
const [user, orders, reviews] = await Promise.all([
getUser(id),
getOrders(id),
getReviews(id),
]);
Bây giờ cả ba chạy song song, tổng thời gian chỉ còn khoảng 300ms — bằng request chậm nhất. Quy tắc vàng: chỉ dùng await tuần tự khi bước sau thật sự cần kết quả của bước trước.
2. Promise.all vs Promise.allSettled — khi một việc hỏng
Promise.all có một nhược điểm chí mạng: nếu một promise bị reject, toàn bộ sẽ reject ngay lập tức, và bạn mất hết kết quả của những promise đã thành công. Trong nhiều tình huống thực tế, bạn muốn "lấy được bao nhiêu hay bấy nhiêu".
Đó là lúc dùng Promise.allSettled:
const results = await Promise.allSettled([
getUser(id),
getOrders(id),
getReviews(id),
]);
for (const r of results) {
if (r.status === "fulfilled") {
console.log("OK:", r.value);
} else {
console.warn("Lỗi:", r.reason);
}
}
allSettled không bao giờ reject — nó đợi tất cả kết thúc rồi trả về một mảng các đối tượng có status là "fulfilled" hoặc "rejected". Đây là lựa chọn lý tưởng cho các dashboard, nơi một widget hỏng không nên làm sập cả trang.
3. Promise.race và Promise.any
Promise.race trả về kết quả của promise kết thúc đầu tiên, dù thành công hay thất bại. Nó là nền tảng để xây timeout. Promise.any (chuẩn ES2021) thì trả về promise thành công đầu tiên, bỏ qua các lỗi — hữu ích khi bạn có nhiều nguồn dữ liệu dự phòng (ví dụ ba CDN, lấy cái nào trả lời trước).
4. AbortController — nghệ thuật hủy bỏ
Đây là phần nhiều người bỏ qua nhưng cực kỳ quan trọng. AbortController cho phép bạn hủy một request đang chạy:
const controller = new AbortController();
const promise = fetch("/api/search?q=js", { signal: controller.signal });
// Khi không cần nữa:
controller.abort();
Khi gọi abort(), promise sẽ reject với lỗi tên AbortError. Đây là chìa khóa cho ô tìm kiếm gõ-tới-đâu-tìm-tới-đó (search-as-you-type), nơi mỗi lần gõ phím mới phải hủy request cũ.
Tình huống thực tế
Tình huống 1: Tiki và bài toán trang chi tiết sản phẩm
Giả sử bạn là kỹ sư frontend tại một sàn thương mại điện tử lớn như Tiki. Trang chi tiết một sản phẩm cần tải đồng thời: thông tin sản phẩm, đánh giá của khách, sản phẩm gợi ý, và tình trạng tồn kho ở kho gần nhất. Phiên bản đầu của một bạn dev mới viết tuần tự bốn await, mỗi cái khoảng 250ms, tổng thành 1 giây — và Google đo được điểm trải nghiệm tụt hẳn.
Đội ngũ đổi sang Promise.all, kéo thời gian xuống còn ~280ms. Nhưng họ nhận ra một vấn đề mới: dịch vụ "sản phẩm gợi ý" thỉnh thoảng treo, và khi nó reject thì Promise.all làm sập cả trang sản phẩm — khách không xem được gì cả, dù thông tin sản phẩm chính đã sẵn sàng.
Diễn giải: Họ chuyển sang Promise.allSettled. Bây giờ nếu phần "gợi ý" hỏng, trang vẫn hiển thị đầy đủ sản phẩm, đánh giá và tồn kho; chỉ riêng khối gợi ý hiện một placeholder "Đang cập nhật". Tỷ lệ trang lỗi giảm gần như về 0.
Bài học: Với dữ liệu cốt lõi không thể thiếu, hãy cân nhắc Promise.all. Với dữ liệu phụ trợ mà thiếu vẫn dùng được, hãy dùng Promise.allSettled để cô lập lỗi.
Tình huống 2: Ô tìm kiếm của một startup giao đồ ăn ở TP.HCM
Một startup giao đồ ăn xây ô tìm kiếm món ăn gợi ý theo từng phím gõ. Ban đầu, mỗi lần người dùng gõ một ký tự, code gọi một fetch mới. Người dùng gõ "bún bò huế" (10 ký tự) tạo ra 10 request. Vì mạng di động không ổn định, request của ký tự thứ 4 đôi khi về sau request của ký tự thứ 9 — kết quả là ô gợi ý nhấp nháy loạn xạ, hiển thị kết quả của từ khóa cũ. Đây gọi là lỗi race condition.
Diễn giải: Đội kỹ thuật kết hợp hai kỹ thuật. Thứ nhất là debounce: chỉ gọi API sau khi người dùng ngừng gõ 300ms. Thứ hai là AbortController: mỗi lần gõ phím mới, hủy request đang chạy dở.
let currentController = null;
async function search(query) {
if (currentController) currentController.abort();
currentController = new AbortController();
try {
const res = await fetch(`/api/search?q=${query}`, {
signal: currentController.signal,
});
return await res.json();
} catch (err) {
if (err.name === "AbortError") return; // bị hủy có chủ ý, bỏ qua
throw err;
}
}
Sau khi triển khai, hiện tượng nhấp nháy biến mất hoàn toàn, và lượng request gửi tới server giảm khoảng 70% — tiết kiệm cả băng thông lẫn chi phí máy chủ.
Bài học: Trong UI tương tác, request "cũ" không chỉ vô dụng mà còn gây hại. Hủy chúng đi vừa đúng đắn về logic, vừa tiết kiệm tài nguyên.
Tình huống 3: Đồng bộ 5.000 đơn hàng lên một API bên thứ ba
Một công ty logistics ở Đông Nam Á cần đẩy 5.000 đơn hàng mỗi đêm lên hệ thống đối tác vận chuyển qua API. Bạn dev đầu tiên viết:
await Promise.all(orders.map(o => pushOrder(o)));
Code này bắn 5.000 request cùng một lúc. Kết quả: API đối tác trả về hàng loạt lỗi 429 Too Many Requests, server nội bộ thì cạn kiệt kết nối, và quá trình đồng bộ thất bại giữa chừng.
Diễn giải: Giải pháp là giới hạn số lượng đồng thời (concurrency limiting) — chỉ cho phép tối đa, ví dụ, 10 request chạy song song tại một thời điểm, kết hợp retry cho những đơn lỗi tạm thời. Chúng ta sẽ xây pattern này ở phần hướng dẫn bên dưới. Sau khi áp dụng giới hạn 10 đồng thời cộng với retry 3 lần (có backoff), tỷ lệ đồng bộ thành công nhảy từ ~60% lên 99,8%, và không còn lỗi 429 nào.
Bài học: "Song song" không có nghĩa là "tất cả cùng lúc". Hệ thống thật luôn có giới hạn — bạn phải chủ động điều tiết tốc độ thay vì để nó tự bùng nổ.
Hướng dẫn từng bước
Hãy xây ba công cụ nền tảng mà bạn sẽ tái sử dụng mãi về sau.
Bước 1: Hàm withTimeout — không bao giờ chờ vô tận
Một request không có timeout là một quả bom hẹn giờ. Dùng AbortController để tự hủy sau N mili-giây:
async function withTimeout(promiseFactory, ms) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
return await promiseFactory(controller.signal);
} finally {
clearTimeout(timer);
}
}
// Cách dùng:
const data = await withTimeout(
(signal) => fetch("/api/slow", { signal }).then(r => r.json()),
5000 // tối đa 5 giây
);
Lưu ý khối finally để dọn dẹp timer — nếu quên, bạn sẽ rò rỉ bộ đếm thời gian.
Bước 2: Hàm retry với exponential backoff
Khi gặp lỗi tạm thời (mạng chập chờn, 503), thử lại là hợp lý — nhưng phải giãn dần thời gian chờ để không dội bom server:
async function retry(fn, { retries = 3, baseDelay = 300 } = {}) {
let lastError;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
if (attempt === retries) break;
const delay = baseDelay * 2 ** attempt; // 300, 600, 1200ms...
await new Promise(res => setTimeout(res, delay));
}
}
throw lastError;
}
2 ** attempt tạo ra cấp số nhân: lần đợi đầu 300ms, sau đó 600ms, rồi 1200ms. Trong thực tế nên thêm một chút "jitter" (ngẫu nhiên) để tránh nhiều client retry cùng nhịp, nhưng bản này đã đủ tốt để bắt đầu.
Bước 3: Hàm mapWithConcurrency — chạy song song có kiểm soát
Đây là công cụ giải quyết Tình huống 3. Ý tưởng: xử lý một mảng nhưng giới hạn số tác vụ chạy đồng thời.
async function mapWithConcurrency(items, worker, limit = 10) {
const results = new Array(items.length);
let index = 0;
async function runner() {
while (index < items.length) {
const current = index++;
results[current] = await worker(items[current], current);
}
}
// Tạo `limit` "công nhân" cùng rút việc từ hàng đợi chung
const runners = Array.from({ length: limit }, runner);
await Promise.all(runners);
return results;
}
// Cách dùng:
await mapWithConcurrency(orders, (o) => retry(() => pushOrder(o)), 10);
Mỗi "runner" liên tục rút phần tử tiếp theo từ chỉ số index chung và xử lý. Vì chỉ có limit runner, bạn không bao giờ vượt quá limit tác vụ đồng thời. Kết hợp với retry ở Bước 2, bạn đã có một đường ống đồng bộ vừa nhanh vừa bền.
Bước 4: Ghép tất cả lại
Trong thực tế bạn thường lồng các pattern: mapWithConcurrency bọc retry bọc withTimeout. Mỗi lớp giải quyết một mối lo: lớp ngoài điều tiết tốc độ, lớp giữa xử lý lỗi tạm thời, lớp trong chống treo vô tận.
Lỗi thường gặp & mẹo
- Quên rằng
awaittrong vòng lặp là tuần tự. Viếtfor (const x of arr) { await f(x); }sẽ xử lý từng phần tử một, rất chậm. Nếu các phần tử độc lập, hãy dùngPromise.all(arr.map(f))hoặcmapWithConcurrency.
- Dùng
forEachvới async.arr.forEach(async x => await f(x))không chờ gì cả —forEachlờ đi promise trả về. Hàm gọi nó sẽ chạy tiếp trước khi các tác vụ xong. Luôn dùngfor...ofhoặcPromise.all(map(...)).
- Bắt nhầm
AbortErrorthành lỗi thật. Khi bạn chủ độngabort(), promise reject vớiAbortError. Đừng log nó như lỗi nghiêm trọng hay hiển thị thông báo cho người dùng — đó là hành vi có chủ đích. Hãy kiểm traerr.name === "AbortError"và bỏ qua.
Promise.allnuốt mất kết quả thành công khi một cái hỏng. Nếu bạn cần giữ lại các kết quả tốt, dùngallSettled.
- Tạo promise rồi mới truyền vào
raceđể timeout — nhưng request gốc vẫn chạy ngầm.Promise.racechỉ bỏ qua kết quả, nó không hủy request. Để thực sự dừng request, phải dùngAbortControllernhư hàmwithTimeoutở trên.
- Retry cả những lỗi không nên retry. Lỗi
400 Bad Requesthay401 Unauthorizedsẽ không bao giờ tự khỏi khi thử lại — chỉ retry các lỗi tạm thời (lỗi mạng,429,5xx). Hãy thêm điều kiện kiểm tra mã lỗi trước khi quyết định thử lại.
- Mẹo
Promise.allkhông chờ "đủ lâu". Khi bạnmapra promise, chúng bắt đầu chạy ngay khi được tạo, không phải khiawait. Vì vậyconst a = f(); const b = g(); await a; await b;đã chạy song song rồi — không cầnPromise.allđể có tính song song, chỉ cần nó để gom kết quả gọn gàng.
Bài tập thực hành
- Tăng tốc tuần tự: Cho ba hàm
getProfile(id),getSettings(id),getNotifications(id)độc lập nhau, mỗi hàm mất ~400ms. Viết lại để tổng thời gian chỉ còn ~400ms bằngPromise.all. Đo và so sánh bằngconsole.time.
- Dashboard chịu lỗi: Dùng
Promise.allSettledđể tải 4 widget. Giả lập một widget luôn reject. Viết code sao cho 3 widget còn lại vẫn hiển thị, widget lỗi hiển thị dòng "Không tải được".
- Ô tìm kiếm thông minh: Viết hàm
search(query)dùngAbortControllerđể hủy request trước đó mỗi khi được gọi lại. Bổ sung debounce 300ms. Kiểm thử bằng cách gọi liên tiếp nhanh và xác nhận chỉ request cuối cùng trả kết quả.
- Đường ống đồng bộ: Cho mảng 100 "đơn hàng" giả lập (mỗi
pushOrdercó 20% xác suất thất bại tạm thời). DùngmapWithConcurrency(limit 5) kết hợpretry(3 lần, backoff) để đẩy tất cả thành công. In ra số lần retry tổng cộng.
- Thử thách nâng cao: Viết hàm
withTimeouthủy được request thật (quaAbortController), rồi bọc nó quanh mộtfetchtới một endpoint cố tình chậm 10 giây với timeout 2 giây. Xác nhận bạn nhận được lỗi timeout sau đúng ~2 giây, không phải 10 giây.
Tóm tắt
Async/await cơ bản giúp bạn viết được code bất đồng bộ; những pattern trong bài này giúp bạn viết code bất đồng bộ đáng tin cậy cho sản xuất. Hãy ghi nhớ những điểm cốt lõi:
- Song song khi có thể: dùng
Promise.allcho các tác vụ độc lập thay vìawaitxếp hàng. Đây là cách tăng tốc rẻ nhất. - Cô lập lỗi:
Promise.allSettledcho dữ liệu phụ trợ để một phần hỏng không kéo sập tất cả. - Hủy bỏ chủ động:
AbortControllercho UI tương tác (search-as-you-type) và cho timeout — đừng để request rác chạy ngầm. - Điều tiết tốc độ:
mapWithConcurrencyđể không bao giờ dội bom server hay API bên thứ ba. - Thử lại thông minh:
retryvới exponential backoff cho lỗi tạm thời — nhưng chỉ retry những lỗi đáng retry.
withTimeout, retry, và mapWithConcurrency là bộ đồ nghề bạn nên mang theo vào mọi dự án. Hãy gõ lại chúng từ đầu một lần, hiểu từng dòng, rồi đưa vào thư viện tiện ích của riêng bạn. Khi bạn xử lý được lỗi một phần, timeout, và giới hạn đồng thời một cách bình thản, bạn đã không còn chỉ "biết JavaScript" — bạn đang xây những hệ thống thực sự bền bỉ.