Skip to content

Promise & async/await ​

Gần như mọi thứ ở backend Node.js đều bất đồng bộ: query database, gọi API, đọc file. Trang này giải thích Promise là gì và chạy thế nào, các hàm gộp nhiều promise, những lỗi hay gặp khi xử lý lỗi bất đồng bộ, cách chạy tuần tự hay song song trong vòng lặp, và cách huỷ một việc đang chạy. Cách callback của Promise xếp hàng trong event loop (microtask) có trang chính ở Event loop.

Từ callback tới Promise và async/await ​

Ban đầu Node dùng callback: hàm bất đồng bộ nhận thêm một hàm để gọi khi xong, theo quy ước (err, result). Lồng nhiều bước thì code thụt vào mãi (callback hell), và mỗi bước phải tự kiểm tra err.

Promise là một object đại diện cho kết quả sẽ có trong tương lai. Nó ở một trong ba trạng thái:

  • pending: đang chờ.
  • fulfilled: đã xong, có giá trị.
  • rejected: đã lỗi, có lý do (thường là một Error).

Promise chỉ chuyển trạng thái một lần, từ pending sang fulfilled hoặc rejected, rồi giữ nguyên mãi. .then() và .catch() đăng ký hàm chạy khi promise xong; nếu promise đã xong từ trước thì hàm vẫn chạy, nhưng luôn chạy bất đồng bộ, dưới dạng microtask sau khi code hiện tại chạy xong.

async/await là cú pháp trên Promise. Hàm async luôn trả về một Promise: return x thành fulfilled với x, throw e thành rejected với e. await p tạm dừng hàm cho tới khi p xong, rồi trả về giá trị hoặc ném lỗi, nên try/catch dùng được như code đồng bộ. Trong lúc tạm dừng, thread không bị giữ: event loop chạy việc khác (Event loop).

Các API kiểu callback cũ đổi sang Promise bằng util.promisify, hoặc dùng bản Promise có sẵn (fs/promises, timers/promises, dns/promises).

Gộp nhiều promise ​

HàmXong khiKết quảKhi có lỗi
Promise.allTất cả thành công, hoặc có một lỗiMảng kết quả theo thứ tự đưa vàoReject ngay với lỗi đầu tiên
Promise.allSettledTất cả xongMảng { status, value } hoặc { status, reason }Không bao giờ reject
Promise.racePromise đầu tiên xongKết quả của promise đóReject nếu promise đầu tiên lỗi
Promise.anyPromise đầu tiên thành côngGiá trị của promise đóChỉ reject khi tất cả đều lỗi, với AggregateError

Điều quan trọng nhất, và hay bị hiểu sai: khi hàm gộp đã trả kết quả, các promise còn lại vẫn chạy tới cùng. Promise.all reject vì một query lỗi không làm các query khác dừng lại. Chúng vẫn được gửi tới database, vẫn chạy xong, chỉ là không ai dùng kết quả. Promise không có cơ chế huỷ. Đổi thời gian và kết quả của từng promise để xem:

Promise APromise BPromise CPromise DPromise.all60 msPromise.allSettled200 msPromise.race60 msPromise.any90 ms0 ms100 ms200 ms300 ms
HàmXong lúcKết quảVì sao
Promise.all60 msreject: B lỗiDừng chờ ngay khi B lỗi, bỏ qua kết quả của các promise khác.
Promise.allSettled200 ms[fulfilled, rejected, fulfilled, fulfilled]Luôn chờ tất cả, không bao giờ reject. Mỗi phần tử cho biết promise đó thành công hay lỗi.
Promise.race60 msreject: B lỗiLấy kết quả của promise xong đầu tiên (B), dù thành công hay lỗi.
Promise.any90 ms'D'Bỏ qua lỗi, lấy promise thành công đầu tiên (D).

Dù hàm gộp đã trả kết quả, các promise còn lại vẫn chạy tới cùng: Promise không có cơ chế huỷ. Request HTTP, query database vẫn được gửi và chạy xong, chỉ là không ai dùng kết quả. Muốn dừng thật thì phải truyền AbortSignal vào từng việc.

Chọn hàm theo ý nghĩa nghiệp vụ:

  • all khi cần mọi kết quả và một lỗi thì cả việc thất bại, ví dụ trang chi tiết đơn hàng cần cả đơn, khách và sản phẩm.
  • allSettled khi các việc độc lập và muốn biết từng cái thành công hay không, ví dụ gửi thông báo cho 50 người.
  • any khi gọi nhiều nguồn và chỉ cần một nguồn trả lời, ví dụ hedged request (Latency).
  • race chủ yếu để đặt timeout, nhưng nên dùng AbortSignal (phần cuối trang).

Lỗi trong code bất đồng bộ ​

Quên await. try { save() } catch (e) { … } không bắt được lỗi, vì save() trả về một promise ngay lập tức và try đã xong trước khi promise reject. Lỗi thành unhandled rejection. Từ Node 15, mặc định (--unhandled-rejections=throw) unhandled rejection được ném lên như một exception không ai bắt, và process chết với exit code 1. Trên lab, quên await một hàm async ném lỗi là đủ để process thoát. Luật no-floating-promises của typescript-eslint bắt được lỗi này lúc viết code.

Lỗi trong callback. try { setTimeout(() => { throw err }) } catch không bắt được, vì callback chạy sau khi khối try đã xong, trong một lượt khác của event loop. Lỗi này thành uncaught exception và làm process chết. Đây cũng là lý do nên bọc API kiểu callback thành Promise rồi dùng await.

async trong forEach. items.forEach(async (x) => { await save(x) }) không chờ gì cả: forEach bỏ qua promise mà callback trả về, nên hàm bên ngoài chạy tiếp ngay, và lỗi của từng save thành unhandled rejection. Dùng for…of với await, hoặc Promise.all(items.map(…)).

Gắn .catch muộn. Node quyết định một rejection là "không ai xử lý" ngay sau lượt microtask hiện tại. Trên lab, một promise reject rồi 10 ms sau mới gắn .catch vẫn làm process chết. Tạo promise ở đâu thì gắn xử lý lỗi ở đó, hoặc await nó ngay.

process.on('unhandledRejection') dùng để ghi log, không dùng để "nuốt" lỗi rồi chạy tiếp, vì process có thể đang ở trạng thái hỏng (Xử lý lỗi & graceful shutdown).

Tuần tự hay song song trong vòng lặp ​

js
for (const id of ids) await load(id)                 // tuần tự: 10 việc × 50 ms = khoảng 510 ms
await Promise.all(ids.map((id) => load(id)))          // song song: khoảng 52 ms

Mỗi await trong vòng lặp chờ việc trước xong mới bắt đầu việc sau. Đây là cách đúng khi các việc phụ thuộc nhau, hay khi cần đúng thứ tự, nhưng thường là lỗi hiệu năng khi các việc độc lập: 10 query chờ nhau thay vì chạy cùng lúc.

Song song không giới hạn cũng có giá. Promise.all trên 10.000 phần tử gửi 10.000 query cùng lúc: pool database chỉ có vài chục connection nên phần lớn phải xếp hàng, và API phía bên kia có thể từ chối vì quá tải. Với danh sách lớn, chạy song song có giới hạn (ví dụ 10 việc cùng lúc), bằng thư viện như p-limit hay tự viết một hàng đợi nhỏ (Race condition trong Node.js).

Hàm async chạy song song chỉ có ích với việc chờ I/O. Việc tốn CPU thì Promise.all không nhanh hơn chút nào, vì mọi hàm vẫn chạy trên một thread (Process, thread).

Huỷ việc đang chạy ​

Promise không huỷ được, nên Node và trình duyệt dùng chung một cơ chế riêng: AbortController và AbortSignal. Hàm nào hỗ trợ huỷ thì nhận signal trong tham số. Khi signal bị abort, hàm đó dừng việc của nó và reject.

js
// Timeout cho một request: tự abort sau 300 ms
const res = await fetch(url, { signal: AbortSignal.timeout(300) })

// Huỷ theo ý mình, ví dụ khi client đóng connection
const ac = new AbortController()
req.on('close', () => ac.abort())
await fetch(url, { signal: ac.signal })

// Gộp nhiều lý do huỷ
const signal = AbortSignal.any([ac.signal, AbortSignal.timeout(2000)])

Trên lab, fetch tới một server trả lời sau 2 giây với AbortSignal.timeout(300) reject với TimeoutError sau khoảng 300 ms, và connection bị đóng thật. Còn timeout bằng Promise.race chỉ làm bên gọi thôi chờ: việc gốc vẫn chạy tới xong ở phía sau, tiếp tục giữ connection và tài nguyên. Vì vậy với mọi lời gọi ra ngoài (HTTP, query có hỗ trợ), nên truyền signal để có timeout thật (Xử lý lỗi & idempotency).

Hàm tự viết cũng nên nhận signal và kiểm tra nó giữa các bước (signal.throwIfAborted()), hoặc truyền tiếp xuống các hàm nó gọi.

Ghi nhớ

  1. Promise chỉ chuyển trạng thái một lần; hàm async luôn trả về Promise, await tạm dừng hàm mà không giữ thread.
  2. all reject ở lỗi đầu tiên, allSettled chờ tất cả, race lấy cái xong đầu tiên, any lấy cái thành công đầu tiên. Các promise còn lại vẫn chạy tới cùng.
  3. Quên await, lỗi trong callback, async trong forEach, gắn .catch muộn đều làm lỗi thoát khỏi try/catch; từ Node 15, unhandled rejection làm process chết.
  4. await trong vòng lặp là tuần tự; việc độc lập thì chạy song song có giới hạn.
  5. Huỷ và timeout bằng AbortSignal, không bằng Promise.race, để việc gốc dừng thật.

Nguồn tham khảo ​

Đã đối chiếu (đọc nguồn ngày 2026-10-26):

NguồnDùng cho phần
Node.js docs, --unhandled-rejectionsMặc định throw: nếu không có handler unhandledRejection thì ném lên như uncaught exception
MDN, Promise.any()Reject với AggregateError khi mọi promise đều reject
Node.js docs, Globals: AbortSignalAbortSignal.timeout, AbortSignal.abort

Kiểm bằng lab: mọi hành vi trong trang. Từ kiến thức chung, chưa đối chiếu lần này: mặc định này có từ Node 15; AbortSignal.any; luật no-floating-promises của typescript-eslint.

Số đo chi tiết ​

Kết quả chạy lab/async/promises.mjs (Node 24.14)

A xong sau 100 ms, B lỗi sau 50 ms, C xong sau 150 ms:

HàmKết quảCác task
Promise.allreject "B lỗi" sau 51 msCả A, B, C vẫn chạy tới cùng
Promise.allSettled[fulfilled, rejected, fulfilled] sau 150 ms
Promise.racereject "B lỗi" sau 51 ms
Promise.any"A" sau 100 ms; mọi task lỗi thì AggregateError

10 việc mỗi việc chờ 50 ms: for…of + await 510 ms, Promise.all 51–52 ms, forEach(async…) trả về sau 0 ms vì không chờ.

LỗiKết quả
Quên await trong trycatch không bắt được, process thoát với exit code 1
throw trong callback setTimeout bọc try bên ngoàicatch không bắt được, exit code 1
Promise.reject không ai catchexit code 1
Có process.on('unhandledRejection')exit code 0, chạy tiếp
Gắn .catch sau 10 msexit code 1
fetch với AbortSignal.timeout(300)TimeoutError sau 306 ms
ac.abort(new Error('người dùng huỷ')) sau 200 msreject với chính lý do đó sau 202 ms
Promise.race với timeout 100 msreject sau 102 ms, nhưng việc gốc 500 ms vẫn chạy tới xong

Code đã dùng để chạy thử ​

Chạy bằng npm run lab -- async, chỉ cần Node.js, vài giây. Xem Lab.

lab/async/promises.mjs
js
// Trang nodejs/async: hành vi thật của Promise trên Node.
import { spawnSync } from 'node:child_process'
import http from 'node:http'

const t0 = performance.now()
const at = () => Math.round(performance.now() - t0)
const log = (label, v) => console.log(`${label.padEnd(58)} ${v}`)
const task = (name, ms, ok = true, done = []) => new Promise((res, rej) => setTimeout(() => { done.push(`${name}@${ms}`); ok ? res(name) : rej(new Error(`${name} lỗi`)) }, ms))
const settle = async (p) => { const s = performance.now(); try { return `ok ${JSON.stringify(await p)} sau ${Math.round(performance.now() - s)} ms` } catch (e) { return `reject "${e.message ?? e}" sau ${Math.round(performance.now() - s)} ms` } }

console.log('\n=== Promise.all / allSettled / race / any: A 100 ms ok, B 50 ms lỗi, C 150 ms ok ===')
for (const name of ['all', 'allSettled', 'race', 'any']) {
  const done = []
  const r = await settle(Promise[name]([task('A', 100, true, done), task('B', 50, false, done), task('C', 150, true, done)]).then((v) => (name === 'allSettled' ? v.map((x) => x.status) : v)))
  await new Promise((res) => setTimeout(res, 170))
  log(`Promise.${name}`, `${r}; các task vẫn chạy tới cùng: ${done.join(', ')}`)
}
log('Promise.any khi mọi task lỗi', await settle(Promise.any([task('A', 10, false), task('B', 20, false)]).catch((e) => { throw new Error(`${e.constructor.name}, errors = ${e.errors.length}`) })))

console.log('\n=== Tuần tự hay song song: 10 việc, mỗi việc chờ 50 ms ===')
const ids = Array.from({ length: 10 }, (_, i) => i)
let s = performance.now(); for (const id of ids) await task(id, 50); log('for … of + await', `${Math.round(performance.now() - s)} ms`)
s = performance.now(); await Promise.all(ids.map((id) => task(id, 50))); log('Promise.all(ids.map(…))', `${Math.round(performance.now() - s)} ms`)
s = performance.now(); ids.forEach(async (id) => { await task(id, 50) }); log('ids.forEach(async …): hàm trả về sau', `${Math.round(performance.now() - s)} ms (forEach không chờ)`)
await new Promise((r) => setTimeout(r, 60))
s = performance.now(); const p2 = []; for (const id of ids) p2.push(task(id, 50)); await Promise.all(p2); log('Tạo hết promise trước, rồi Promise.all', `${Math.round(performance.now() - s)} ms`)

console.log('\n=== Lỗi trong async ===')
async function save() { throw new Error('DB lỗi') }
// Quên await: catch không bắt được, lỗi thành unhandled rejection và làm process chết (chạy trong process con)
const forgot = spawnSync(process.execPath, ['--input-type=module', '-e',
  `async function save() { throw new Error('DB lỗi') }
   try { save() } catch { console.log('catch bắt được') }
   setTimeout(() => console.log('vẫn chạy tiếp'), 50)`], { encoding: 'utf8' })
log('try { save() } catch (quên await)', `catch ${forgot.stdout.includes('catch bắt được') ? 'bắt được' : 'không bắt được'}, process thoát với exit code ${forgot.status}`)
try { await save() } catch (e) { log('try { await save() } catch', `bắt được: ${e.message}`) }
// Chạy trong process con để thấy hành vi mặc định khi có lỗi không ai bắt
const child = (code) => spawnSync(process.execPath, ['--input-type=module', '-e', code], { encoding: 'utf8' })
let r = child(`try { setTimeout(() => { throw new Error('lỗi trong callback') }, 0) } catch { console.log('catch bắt được') }`)
log('try { setTimeout(() => { throw … }) } catch', `catch ${r.stdout.includes('catch bắt được') ? 'bắt được' : 'không bắt được'}: callback chạy sau khi try đã xong; exit code ${r.status}`)
r = child(`Promise.reject(new Error('quên catch')); setTimeout(() => console.log('vẫn chạy tiếp'), 50)`)
log('Promise.reject không ai catch (Node mặc định)', `exit code ${r.status}, "vẫn chạy tiếp" ${r.stdout.includes('vẫn chạy tiếp') ? 'có' : 'không'} in ra; stderr: ${r.stderr.split('\n').find((l) => /^\w*Error/.test(l)).trim()}`)
r = child(`process.on('unhandledRejection', (e) => console.log('bắt ở handler:', e.message)); Promise.reject(new Error('quên catch')); setTimeout(() => console.log('vẫn chạy tiếp'), 50)`)
log('Có handler process.on("unhandledRejection")', `exit code ${r.status}; stdout: ${r.stdout.trim().replace(/\n/g, ' | ')}`)
r = child(`const p = Promise.reject(new Error('catch muộn')); setTimeout(() => p.catch(() => console.log('đã catch')), 10)`)
log('Gắn .catch muộn 10 ms', `exit code ${r.status}; ${r.stdout.trim() || '(không in gì)'}`)

console.log('\n=== Huỷ việc đang chạy: AbortController và timeout ===')
const server = http.createServer((req, res) => setTimeout(() => res.end('xong'), 2000))
await new Promise((res) => server.listen(0, '127.0.0.1', res))
const url = `http://127.0.0.1:${server.address().port}/`
s = performance.now()
try { await fetch(url, { signal: AbortSignal.timeout(300) }) } catch (e) { log('fetch với AbortSignal.timeout(300) tới server trả lời sau 2 s', `${e.name} sau ${Math.round(performance.now() - s)} ms: ${e.message}`) }
const ac = new AbortController()
s = performance.now()
setTimeout(() => ac.abort(new Error('người dùng huỷ')), 200)
try { await fetch(url, { signal: ac.signal }) } catch (e) { log('ac.abort(reason) sau 200 ms', `${e.name} "${e.message}" sau ${Math.round(performance.now() - s)} ms`) }
// Promise.race với timeout không huỷ việc gốc: việc vẫn chạy tiếp ở phía sau
let finished = false
const slow = new Promise((res) => setTimeout(() => { finished = true; res('xong') }, 500))
s = performance.now()
log('Promise.race([việc 500 ms, timeout 100 ms])', await settle(Promise.race([slow, new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 100))])))
await slow
log('… việc gốc sau đó', `vẫn chạy tới xong (finished = ${finished}), chỉ là không ai đợi kết quả`)
server.close()