jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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áiNghĩaChuyển
pendingKết quả chưa xác địnhfulfilled or rejected
fulfilledThành công với giá trịkết thúc
rejectedThất bại với lý dokế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ếpsau 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);
}
PatternKhi nào dùngTổng thời gian
Sequential await in loopMỗi bước cần kết quả trướcN × T
Parallel Promise.allI/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 … await mà 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.

CombinatorResolves whenRejects whenIgnores other pending?
allall fulfillfirst rejectothers keep running
allSettledalways (after all settle)neverwaits for all
racefirst settle (±)first settle (±)others keep running
anyfirst fulfillall rejectothers keep running

Huỷ bỏ — AbortControllerAbortSignal

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.

AttemptDelay (base 300 ms)With jitter (~30%)
0 → 1300 ms300–390 ms
1 → 2600 ms600–780 ms
2 → 31200 ms1200–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.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; raceany 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.