Product Management
Đăng nhập
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Bài 58 — Async/Await Advanced Patterns (2026)

Đã đọc 0/18 mục · còn khoảng 12 phút
Cách chúng tôi ghi nhận

Một mục được tính là đã đọc khi nó nằm giữa màn hình đủ lâu so với độ dài của nó (bảng và sơ đồ tính một nửa), bạn vẫn đang cuộn hoặc bấm phím, và bạn đã thấy tới cuối mục. Lướt nhanh hoặc để tab mở không được tính. Chúng tôi chỉ lưu thời gian đọc từng mục — không lưu gì khác.

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 await trong vòng lặp là tuần tự. Viết for (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ùng Promise.all(arr.map(f)) hoặc mapWithConcurrency.
  • Dùng forEach với async. arr.forEach(async x => await f(x)) không chờ gì cả — forEach lờ đ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ùng for...of hoặc Promise.all(map(...)).
  • Bắt nhầm AbortError thành lỗi thật. Khi bạn chủ động abort(), promise reject với AbortError. Đừ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 tra err.name === "AbortError" và bỏ qua.
  • Promise.all nuố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ùng allSettled.
  • 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.race chỉ bỏ qua kết quả, nó không hủy request. Để thực sự dừng request, phải dùng AbortController như hàm withTimeout ở trên.
  • Retry cả những lỗi không nên retry. Lỗi 400 Bad Request hay 401 Unauthorized sẽ 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.all không chờ "đủ lâu". Khi bạn map ra promise, chúng bắt đầu chạy ngay khi được tạo, không phải khi await. Vì vậy const a = f(); const b = g(); await a; await b; đã chạy song song rồi — không cần Promise.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ằng Promise.all. Đo và so sánh bằng console.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ùng AbortController để 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 pushOrder có 20% xác suất thất bại tạm thời). Dùng mapWithConcurrency (limit 5) kết hợp retry (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 withTimeout hủy được request thật (qua AbortController), rồi bọc nó quanh một fetch tớ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.all cho các tác vụ độc lập thay vì await xếp hàng. Đây là cách tăng tốc rẻ nhất.
  • Cô lập lỗi: Promise.allSettled cho 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: AbortController cho 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: retry với exponential backoff cho lỗi tạm thời — nhưng chỉ retry những lỗi đáng retry.
Ba công cụ 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ỉ.

Học xong bài này rồi? Tạo tài khoản miễn phí để lưu lại — lần sau vào là biết ngay đang dở ở đâu, và học hết khóa thì có chứng chỉ. Lưu tiến độ của tôi