JavaScript Async Patterns — Promises, Combinators, AbortController & Concurrency
Promise anatomy, sequential vs parallel pitfalls, all/allSettled/race/any semantics, cancellation, retries, concurrency pools, and error traps.
Vì sao async vẫn làm senior engineer vấp
Mọi frontend production đều chạm cùng một bức tường: ba tab mở, debounce search kích hoạt, mutation retry — đột nhiên request trùng, UI cũ, hoặc UnhandledPromiseRejection im lặng.
Promise và async/await không chỉ là “cú pháp ngọt hơn callback” — mà là hợp đồng về thời điểm, lỗi, và huỷ bỏ. Bài này ánh xạ hợp đồng đó sang các pattern bạn thực sự ship.
Thử trực tiếp:
Mở demo đầy đủ:
Giải phẫu Promise — Trạng thái, Thenable, và Microtask Queue
Promise là hộp chứa giá trị tương lai với đúng ba trạng thái:
| Trạng thái | Nghĩa | Chuyển |
|---|---|---|
pending | Kết quả chưa xác định | → fulfilled or rejected |
fulfilled | Thành công với giá trị | kết thúc |
rejected | Thất bại với lý do | kết thúc |
Khi đã settled, Promise không bao giờ đổi trạng thái — tính bất biến này khiến chuỗi .then dự đoán được.
const p = fetch("/api/user/42");
p.then((res) => res.json()) // runs when fulfilled {chạy khi fulfilled}
.catch((err) => console.error(err)) // runs when rejected {chạy khi rejected}
.finally(() => hideSpinner()); // always {luôn chạy}
Bên trong: .then / .catch / .finally microtask, chạy trước frame vẽ tiếp và sau khi stack đồng bộ hiện tại kết thúc. Thứ tự đó giải thích bug kinh điển “log trước khi fetch xong”.
console.log("1");
Promise.resolve().then(() => console.log("2")); // microtask {microtask}
console.log("3");
// → 1, 3, 2
Bất cứ thứ gì có .then đều là thenable; Promise.resolve(foreignThenable) hấp thụ nó thành Promise thật.
async/await — Cú pháp ngọt nhưng có ngữ nghĩa thật
Hàm async luôn trả về Promise, dù bạn return 42. await tạm dừng hàm đó đến khi giá trị awaited settled — không chặn main thread.
async function loadDashboard(userId) {
const user = await fetch(`/api/users/${userId}`).then((r) => r.json());
const orders = await fetch(`/api/users/${userId}/orders`).then((r) => r.json());
return { user, orders };
}
// Equivalent desugaring (simplified) {Tương đương desugar (đơn giản hoá)}
function loadDashboard(userId) {
return fetch(`/api/users/${userId}`)
.then((r) => r.json())
.then((user) =>
fetch(`/api/users/${userId}/orders`)
.then((r) => r.json())
.then((orders) => ({ user, orders }))
);
}
Insight quan trọng: await trong vòng lặp tạo thực thi tuần tự mặc định. Đúng khi bước B phụ thuộc bước A — thảm họa khi các bước độc lập.
Tuần tự vs Song song — Bug await trong vòng lặp
Pattern này xuất hiện trong code review hàng tuần:
// ❌ N requests × latency — dashboard takes forever
// {N request × độ trễ — dashboard load mãi}
async function fetchAllUserAvatars(userIds) {
const avatars = [];
for (const id of userIds) {
const res = await fetch(`/api/avatar/${id}`);
avatars.push(await res.blob());
}
return avatars;
}
Nếu mỗi avatar 200 ms và 20 user, mất 4 giây. Sửa bằng cách khởi chạy mọi việc trước, rồi await cùng lúc:
// ✅ ~200 ms (bounded by slowest request) {~200 ms (giới hạn bởi request chậm nhất}
async function fetchAllUserAvatars(userIds) {
const promises = userIds.map(async (id) => {
const res = await fetch(`/api/avatar/${id}`);
return res.blob();
});
return Promise.all(promises);
}
| Pattern | Khi nào dùng | Tổng thời gian |
|---|---|---|
Sequential await in loop | Mỗi bước cần kết quả trước | N × T |
Parallel Promise.all | I/O độc lập | ≈ T (slowest) |
| Batched pool | Độc lập nhưng API giới hạn tốc độ | ≈ (N / concurrency) × T |
Quy tắc kinh nghiệm: nếu viết
for … awaitmà thân vòng lặp không dùng kết quả lần trước, bạn có lẽ cần song song hoặc pool.
Bốn combinator — Ngữ nghĩa quan trọng trên production
Promise.all — Fail-fast, all-or-nothing success array
Resolve khi mọi input fulfilled; reject ở lần reject đầu. Promise khác vẫn chạy — không bị huỷ.
const [user, prefs, flags] = await Promise.all([
fetchUser(id),
fetchPrefs(id),
fetchFeatureFlags(),
]);
// If fetchPrefs throws → entire Promise.all rejects
// {Nếu fetchPrefs throw → cả Promise.all reject}
// fetchFeatureFlags() still completes in the background
// {fetchFeatureFlags() vẫn hoàn thành ở background}
Dùng khi: cần mọi kết quả và lỗi bất kỳ nên huỷ thao tác tổng vd bootstrap trang.
Promise.allSettled — Never rejects; inspect each outcome
Luôn resolve với mảng \{ status, value? | reason? \}. Lý tưởng cho thao tác hàng loạt khi thành công một phần chấp nhận được.
const results = await Promise.allSettled(
files.map((f) => uploadFile(f))
);
const ok = results.filter((r) => r.status === "fulfilled");
const failed = results.filter((r) => r.status === "rejected");
showRetryUI(failed.map((_, i) => files[i]));
Dùng khi: dashboard báo cáo, import hàng loạt, fan-out “cố gắng hết mức”.
Promise.race — First settled wins (fulfill or reject)
Settle theo promise đầu tiên fulfilled hoặc rejected. Nếu task nhanh nhất fail, cả race reject — thường gây ngạc nhiên.
const response = await Promise.race([
fetch("/api/data"),
sleep(5000).then(() => Promise.reject(new Error("timeout"))),
]);
Dùng khi: timeout, chọn mirror CDN nhanh nhất, đua cache vs network.
Promise.any — First fulfillment; AggregateError if all fail
Resolve với giá trị fulfilled đầu tiên; chỉ reject AggregateError khi mọi input reject.
try {
const data = await Promise.any([
fetchFromPrimary(),
fetchFromFallback(),
fetchFromCache(),
]);
} catch (err) {
// err instanceof AggregateError — err.errors holds each rejection
// {err instanceof AggregateError — err.errors chứa từng rejection}
}
Dùng khi: nguồn dự phòng khi bất kỳ thành công nào cũng đủ endpoint đa vùng, parser dự phòng.
| Combinator | Resolves when | Rejects when | Ignores other pending? |
|---|---|---|---|
all | all fulfill | first reject | others keep running |
allSettled | always (after all settle) | never | waits for all |
race | first settle (±) | first settle (±) | others keep running |
any | first fulfill | all reject | others keep running |
Huỷ bỏ — AbortController và AbortSignal
Trước AbortController, huỷ bỏ là cờ boolean tự chế — dễ rò rỉ và không compose được. Pattern hiện đại:
const controller = new AbortController();
const { signal } = controller;
const req = fetch("/api/search?q=react", { signal });
// User navigates away or types next character:
controller.abort(); // → fetch rejects with AbortError
try {
await req;
} catch (err) {
if (err.name === "AbortError") return; // expected cancellation {huỷ dự kiến}
throw err;
}
Signal compose được:
// Built-in timeout (Node 18+, modern browsers) {Timeout có sẵn}
const timeoutSignal = AbortSignal.timeout(8000);
// Combine: abort if user cancels OR 8s elapse {Kết hợp: huỷ nếu user huỷ HOẶC 8s}
const signal = AbortSignal.any([userController.signal, timeoutSignal]);
await fetch("/api/heavy", { signal });
Pattern React / framework: tạo một AbortController mỗi lần effect chạy, abort trong cleanup:
useEffect(() => {
const ac = new AbortController();
fetch(`/api/item/${id}`, { signal: ac.signal })
.then((r) => r.json())
.then(setData)
.catch((e) => {
if (e.name !== "AbortError") setError(e);
});
return () => ac.abort();
}, [id]);
Abort fetch không hoàn tác việc phía server — chỉ ngừng chờ response ở client. Thiết kế mutation idempotent cho phù hợp.
Timeout mà không nuốt lỗi
Bọc fetch trong Promise.race với timer được nhưng lẫn timeout với lỗi mạng. Ưu tiên AbortSignal.timeout hoặc abort rõ ràng:
async function fetchWithTimeout(url, ms = 5000) {
const res = await fetch(url, { signal: AbortSignal.timeout(ms) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
Nếu phải hỗ trợ runtime cũ:
function timeoutSignal(ms) {
const ac = new AbortController();
const id = setTimeout(() => ac.abort(new DOMException("Timeout", "TimeoutError")), ms);
ac.signal.addEventListener("abort", () => clearTimeout(id), { once: true });
return ac.signal;
}
Retry với backoff lũy thừa và jitter
Retry ngay lập tức làm trầm trọng thêm sự cố (“bầy đàn lao vào”). Retry production:
async function retry(fn, { retries = 3, baseMs = 300, maxMs = 8000 } = {}) {
let attempt = 0;
while (true) {
try {
return await fn();
} catch (err) {
if (attempt >= retries) throw err;
const exp = Math.min(baseMs * 2 ** attempt, maxMs);
const jitter = Math.random() * exp * 0.3; // spread concurrent clients {trải client đồng thời}
await sleep(exp + jitter);
attempt += 1;
}
}
}
await retry(() => fetch("/api/flaky").then((r) => r.json()));
Chỉ retry read idempotent hoặc write có idempotency key. Không bao giờ retry mù POST thanh toán.
| Attempt | Delay (base 300 ms) | With jitter (~30%) |
|---|---|---|
| 0 → 1 | 300 ms | 300–390 ms |
| 1 → 2 | 600 ms | 600–780 ms |
| 2 → 3 | 1200 ms | 1200–1560 ms |
Truyền AbortSignal vào retry để điều hướng huỷ vòng lặp:
async function retry(fn, { signal, retries = 3 } = {}) {
for (let i = 0; i <= retries; i++) {
signal?.throwIfAborted();
try {
return await fn(signal);
} catch (err) {
if (i === retries) throw err;
await sleep(300 * 2 ** i, signal);
}
}
}
Giới hạn đồng thời — Pool tự viết
Promise.all trên 500 URL sẽ bị rate-limit hoặc làm radio mobile sập. Cần pool — cùng ý tưởng p-limit:
function createPool(concurrency) {
let active = 0;
const queue = [];
const next = () => {
if (active >= concurrency || queue.length === 0) return;
active += 1;
const { fn, resolve, reject } = queue.shift();
Promise.resolve()
.then(fn)
.then(
(v) => { active -= 1; resolve(v); next(); },
(e) => { active -= 1; reject(e); next(); }
);
};
return (fn) =>
new Promise((resolve, reject) => {
queue.push({ fn, resolve, reject });
next();
});
}
const limit = createPool(4);
const results = await Promise.allSettled(
urls.map((url) => limit(() => fetch(url).then((r) => r.json())))
);
Bốn slot, nhiều task xếp hàng — throughput giới hạn mà vẫn song song một phần. Chỉnh concurrency từ header Retry-After hoặc loại thiết bị.
Cạm bẫy xử lý lỗi
Promise trôi nổi
// ❌ ESLint @typescript-eslint/no-floating-promises
async function save() { /* … */ }
save(); // rejection becomes unhandled {rejection thành unhandled}
// ✅
void save().catch reportError;
// or
await save();
Trộn await và .then trong một hàm
Chọn một style mỗi hàm cho dễ đọc. Trộn sâu che đường lỗi.
try/catch around Promise.all vs per-promise
try {
await Promise.all(tasks); // one failure → catch {một lỗi → catch}
} catch (e) {
// you don't know WHICH failed without allSettled
// {không biết CÁI NÀO fail nếu không dùng allSettled}
}
Dùng allSettled khi cần UI lỗi từng item.
Global unhandledrejection
window.addEventListener("unhandledrejection", (event) => {
reportToSentry(event.reason);
event.preventDefault(); // optional: mark handled {tuỳ chọn: đánh dấu đã xử lý}
});
Vẫn sửa gốc — handler global là lưới an toàn, không phải kiến trúc.
Re-throwing non-Error values
Luôn throw new Error(...) hoặc giữ cause để stack trace còn:
catch (err) {
throw new Error("Upload failed", { cause: err });
}
for await...of và Async Iterator (tóm tắt)
Khi data đến dưới dạng luồng promise thay vì một khối, async iterable hợp tự nhiên:
async function* paginate(url) {
let next = url;
while (next) {
const page = await fetch(next).then((r) => r.json());
yield page.items;
next = page.next;
}
}
for await (const batch of paginate("/api/items?page=1")) {
renderBatch(batch);
}
Stream Node, parser SSE, handler WebSocket thường expose async iteration. Kết hợp với AbortSignal trên transport gốc.
Bảng quyết định nhanh
Need ALL results, fail if ANY fails? → Promise.all
Need ALL outcomes for reporting/retry? → Promise.allSettled
Need fastest response (incl. errors)? → Promise.race
Need first SUCCESS among redundancies? → Promise.any
Steps depend on previous step? → sequential await in loop
Independent I/O, no rate limit? → Promise.all (parallel)
Independent I/O, rate limited? → concurrency pool
User navigates / input changes? → AbortController per request
Flaky network read? → retry + backoff + jitter + signal
Kết — Async là lựa chọn thiết kế, không phải cú pháp
Demo trên làm thời gian nhìn thấy được: bar tuần tự xếp nối đuôi; bar Promise.all chồng lên nhau; race và any ngừng quan tâm khi có winner — nhưng kẻ thua vẫn chạy nếu bạn không abort.
Ship có chủ đích: chọn combinator khớp ngữ nghĩa lỗi, giới hạn concurrency khi fan-out, luồn AbortSignal qua mọi fetch, và không bao giờ để Promise trôi nổi. User cảm nhận khác biệt bằng millisecond và megabyte — dù họ không mở DevTools.