Home / Fundamentals / Tại Sao Promise Chạy Trước setTimeout(0) trong JavaScript
Fundamentals

Tại Sao Promise Chạy Trước setTimeout(0) trong JavaScript

Hướng dẫn Micro Task vs Macro Task: hiểu Promise ưu tiên hơn setTimeout, tối ưu async code trong JavaScript

Bạn viết setTimeout(callback, 0) mong nó chạy ngay, nhưng Promise lại thực thi trước? Hoặc gặp bug về thứ tự async code không như mong đợi? Đây là vấn đề phổ biến khi chưa hiểu micro task queue và macro task queue - hai cơ chế quyết định thứ tự thực thi bất đồng bộ trong JavaScript.

Bài này giải thích tại sao Promise ưu tiên hơn setTimeout, cách Event Loop xử lý hai loại task, và các lỗi thường gặp khi làm việc với async code. Hiểu rõ điều này giúp bạn tối ưu code, debug dễ hơn và tránh bug về race condition trong dự án thực tế.

1. Asynchronous và vòng lặp sự kiện (Event Loop)

Trong môi trường trình duyệt (Browser), JavaScript có một cơ chế gọi là Event Loop (vòng lặp sự kiện) để quản lý việc thực thi các tác vụ. Khi code được thực thi, JavaScript sẽ đẩy các tác vụ bất đồng bộ vào một trong hai loại “hàng đợi”:

  1. Macro Task Queue (hay còn gọi là Task Queue)
  2. Micro Task Queue

1.1. Macro Task Queue là gì?

  • Macro Task (thường gọi tắt là Task) là những tác vụ lớn, ví dụ như:

    • setTimeout
    • setInterval
    • Lắng nghe sự kiện DOM (onclick, addEventListener, …)
    • AJAX/XHR callback
    • Một số thao tác khác của trình duyệt…
  • Mỗi khi Event Loop kết thúc một vòng xử lý, nó sẽ kiểm tra Micro Task Queue trước. Nếu hàng đợi micro còn nhiệm vụ nào, nó sẽ được xử lý ngay lập tức. Sau khi xong micro, lúc đó JavaScript mới quay lại xử lý Macro Task Queue.

1.2. Micro Task Queue là gì?

  • Micro Task là những tác vụ nhỏ, thường được ưu tiên xử lý ngay sau khi JavaScript hoàn thành xong đoạn code chính hiện tại.
  • Ví dụ:
    • Promise.resolve() hoặc promise.then(...)
    • Các callback trong MutationObserver
    • Một số cơ chế khác được xếp vào micro tasks…

Điều quan trọng cần nhớ: Micro tasks luôn được xử lý trước macro tasks, nếu cả hai cùng xuất hiện trong thời điểm gần nhau.

Trong thực tế: Hiểu rõ sự khác biệt này giúp bạn tránh bug khi kết hợp nhiều async operations. Ví dụ: khi cập nhật UI sau khi fetch data, nếu dùng setTimeout để delay thì Promise callback vẫn chạy trước, có thể gây hiển thị sai thứ tự.

2. Thứ tự ưu tiên: Promise callback vs setTimeout(0)

Khi chúng ta gọi:

1
2
3
4
5
6
7
setTimeout(() => {
  console.log("setTimeout 0ms");
}, 0);

Promise.resolve().then(() => {
  console.log("Promise resolved");
});

Kết quả in ra sẽ là:

1
2
Promise resolved
setTimeout 0ms

2.1. Vì sao Promise.resolve() chạy trước setTimeout(0ms)?

  • Lý do: Callback của Promise.resolve() được xếp vào Micro Task Queue, vốn được ưu tiên xử lý trước khi JavaScript lấy nhiệm vụ tiếp theo từ Macro Task Queue (chính là setTimeout).

  • setTimeout(() => {}, 0) nằm trong Macro Task Queue, nên phải đợi đến vòng lặp sự kiện tiếp theo mới được xử lý.

Đây là nguồn gốc của nhiều hiểu lầm, bởi nhiều người cho rằng setTimeout(..., 0) sẽ chạy ngay tức thì. Thực tế, promise callback vẫn chạy sớm hơn.

Trong thực tế: Bug này thường gặp khi bạn cố “defer” một tác vụ bằng setTimeout(fn, 0) để chạy sau, nhưng quên mất Promise (từ fetch, axios, hoặc async/await) vẫn ưu tiên hơn. Điều này gây race condition khó debug trong UI updates hoặc data processing.

3. Ví dụ về Promise, axios, fetch

3.1. Promise đơn giản

1
2
3
4
5
6
7
8
9
10
11
function fetchData() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve("Dữ liệu đã được tải");
    }, 1000);
  });
}

fetchData().then((data) => {
  console.log(data);
});

Flow thực thi:

  1. Gọi fetchData() để tạo Promise mới:

    • Callback trong new Promise(...) chạy NGAY LẬP TỨC.
    • setTimeout(...) được đăng ký với timer 1000ms.
  2. Call stack trống, event loop tiếp tục vòng lặp đợi các task.

  3. Sau khi 1000ms trôi qua:

    • Callback () => { resolve('Dữ liệu đã được tải'); } được đưa vào Macro Task Queue.
    • Event loop kiểm tra thấy call stack trống thì sẽ đẩy callback này vào call stack và thực thi nó.
  4. Thực thi hàm resolve(...):

    • Callback (data) => { console.log(data); } được đưa vào Micro task Queue.
  5. Event loop xử lý Microtask Queue:

    • Event loop kiểm tra call stack, nếu call stack trống, callback trên được đẩy vào call stack.
    • Thực thi: console.log('Dữ liệu đã được tải').

Trong thực tế: Hiểu flow này giúp debug Promise chains phức tạp. Khi gặp lỗi “Promise pending forever”, bạn biết cần kiểm tra xem resolve() hoặc reject() có được gọi chưa, hoặc có bị kẹt ở macro task nào không.

3.2. fetch

1
2
3
4
5
fetch("https://jsonplaceholder.typicode.com/posts/1")
  .then((response) => response.json())
  .then((json) => {
    console.log(json);
  });
  • fetchWeb API. Khi có response, promise của fetch được resolve (trong một task), và các callback .then(...) của bạn được đưa vào Micro Task Queue.

  • Điều này tương tự với axios:

3.3. axios

1
2
3
axios.get("https://jsonplaceholder.typicode.com/posts/1").then((response) => {
  console.log(response.data);
});
  • axios bên trong cũng sử dụng XHR hoặc fetch (tùy cấu hình). Kết quả trả về cuối cùng cũng được xử lý qua callback then(...) trong micro task queue.

4. XHR callback không nằm trong micro task queue

Khi bạn dùng XHR (XMLHttpRequest):

1
2
3
4
5
6
7
8
9
10
const xhr = new XMLHttpRequest();
xhr.open("GET", "https://jsonplaceholder.typicode.com/posts/1");

xhr.onreadystatechange = function () {
  if (xhr.readyState === 4 && xhr.status === 200) {
    console.log("XHR response:", xhr.responseText);
  }
};

xhr.send();

Nhiều người tưởng onreadystatechange cũng chạy như một micro task giống .then của Promise, nhưng thực tế callback này được xử lý qua Macro Task Queue. Đây là điểm dễ gây nhầm lẫn.

4.1. Tại sao lại là macro task?

XHR dùng chung cơ chế dispatch với các sự kiện DOM (click, load…). Khi response về tới, trình duyệt không gọi callback ngay — nó đẩy callback vào macro task queue, rồi đợi Event Loop sang vòng lặp kế tiếp mới lấy ra chạy.

Đây chính là điểm khác biệt cốt lõi so với Promise. .then được đẩy vào micro task queue, và micro task queue luôn được xử lý cạn trước khi Event Loop chạm tới macro task tiếp theo. Vì callback XHR nằm ở macro task queue, nó không có được sự ưu tiên đó — dù bạn có viết nó “trông giống” .then đến đâu.

4.2. Điều gì thay đổi khi migrate XHR sang fetch?

Khác biệt không nằm ở việc gửi request, mà ở hạng của callback. Với XHR, code xử lý response chạy trong một macro task. Khi bạn migrate sang fetch (hoặc axios dùng fetch), cùng đoạn code đó giờ chạy trong chuỗi .then — tức là micro task.

Hệ quả thực tế: nếu trong lúc xử lý response bạn lên lịch một setTimeout, thì với fetch, toàn bộ chuỗi .then sẽ drain xong trước khi setTimeout chạy — vì micro task luôn được ưu tiên hơn macro task. Thứ tự này có thể khác với bản XHR cũ, nên là điểm cần để ý khi migrate.

Xem ví dụ sau để thấy rõ thứ tự đó:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fetch("https://jsonplaceholder.typicode.com/posts/1")
  .then((response) => response.json())
  .then((data) => {
    console.log("1. Bắt đầu xử lý data");

    // Trong lúc xử lý response, ta schedule một setTimeout (macro task)
    setTimeout(() => {
      console.log("3. setTimeout chạy sau cùng");
    }, 0);
  })
  .then(() => {
    // .then này là micro task, chạy trước setTimeout ở trên
    console.log("2. .then tiếp theo chạy trước setTimeout");
  });

Kết quả:

1
2
3
1. Bắt đầu xử lý data
2. .then tiếp theo chạy trước setTimeout
3. setTimeout chạy sau cùng

setTimeout được đặt trước .then cuối cùng trong code, nó vẫn chạy sau — vì micro task (.then) luôn được ưu tiên xử lý hết trước khi tới macro task (setTimeout) tiếp theo.

5. Bảng tổng hợp: API nào thuộc Macro Task hay Micro Task

Dưới đây là bảng phân loại một số API phổ biến trong trình duyệt:

API/CallbackThuộc Macro Task (Task)Thuộc Micro Task
setTimeout, setIntervalX
requestAnimationFrame (xem ghi chú)
XHR/AJAX (onreadystatechange)X
DOM Events (click, keyup, …)X
Promise (.then, .catch, .finally)X
fetch (callback .then sau khi nhận response)X
MutationObserverX
  • Ghi chú: requestAnimationFrame không thuộc queue nào — callback của nó chạy trong bước render của Event Loop, ngay trước khi trình duyệt vẽ frame. Vì vậy nó không xếp hàng FIFO cùng setTimeout: nhiều timer task có thể chạy xen giữa hai lần gọi requestAnimationFrame.

Trong thực tế: Khi làm việc với animation hoặc game loop, dùng requestAnimationFrame (chạy ở bước render) kết hợp với Promise (micro task) để xử lý data. Promise sẽ chạy trước để chuẩn bị data, sau đó requestAnimationFrame render UI - đảm bảo smooth animation.

6. Các lỗi thường gặp và cách tối ưu

6.1. Quên thứ tự thực thi giữa Promise và setTimeout

Nhiều người tin rằng setTimeout(callback, 0) sẽ chạy trước Promise. Tuy nhiên, như đã nói, Promise nằm trong micro tasks và được ưu tiên cao hơn. Điều này có thể gây nhầm lẫn khi ta mong chờ kết quả hiển thị sớm hơn.

Cách khắc phục

  • Hãy nhớ quy tắc: Micro tasks (Promise) xử lý trước Macro tasks (setTimeout).
  • Tận dụng điều này để sắp xếp code theo thứ tự mong muốn.

6.2. Các vấn đề phạm vi biến trong Execution Context

Khi viết code bất đồng bộ, ta dễ quên phạm vi biến. Ví dụ:

1
2
3
4
5
6
for (var i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log("Index:", i);
  }, 10);
}
// Kết quả: 3, 3, 3

Sử dụng var khiến i không bị “giữ” giá trị tại thời điểm tạo hàm. Lúc setTimeout chạy, i đã thành 3.

Cách khắc phục

  • Dùng let thay cho var để tránh hiện tượng “rò rỉ” biến. Hoặc đóng gói i trong một hàm mới:
1
2
3
4
5
for (let i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log('Index:', i);
  }, 10);
}

Kết quả lúc này: 0, 1, 2.

Trong thực tế: Lỗi var trong vòng lặp với async callbacks cực kỳ phổ biến khi tạo multiple event listeners, xử lý array items với delay, hoặc tạo animation sequence. Luôn dùng let trong vòng lặp có async code để tránh bug khó debug này.

Vậy là bạn đã hiểu tại sao Promise chạy trước setTimeout(0): micro task queue luôn được xử lý ưu tiên hơn macro task queue trong Event Loop. Promise, fetch, axios callbacks nằm trong micro tasks, còn setTimeout, DOM events, XHR callbacks là macro tasks. Khi migrate code từ XHR sang fetch hoặc kết hợp async operations, nhớ kiểm tra thứ tự thực thi để tránh race condition. Quan trọng nhất: dùng let thay var trong vòng lặp có async code, và nhớ quy tắc “micro trước, macro sau” khi debug timing bugs!

← Previous
Tránh Lãng Phí Bộ Nhớ JavaScript: Chia Sẻ Phương Thức với Prototype
Table of Contents
  1. 1. Asynchronous và vòng lặp sự kiện (Event Loop)
    1. 1.1. Macro Task Queue là gì?
    2. 1.2. Micro Task Queue là gì?
  2. 2. Thứ tự ưu tiên: Promise callback vs setTimeout(0)
    1. 2.1. Vì sao Promise.resolve() chạy trước setTimeout(0ms)?
  3. 3. Ví dụ về Promise, axios, fetch
    1. 3.1. Promise đơn giản
    2. 3.2. fetch
    3. 3.3. axios
  4. 4. XHR callback không nằm trong micro task queue
    1. 4.1. Tại sao lại là macro task?
    2. 4.2. Điều gì thay đổi khi migrate XHR sang fetch?
  5. 5. Bảng tổng hợp: API nào thuộc Macro Task hay Micro Task
  6. 6. Các lỗi thường gặp và cách tối ưu
    1. 6.1. Quên thứ tự thực thi giữa Promise và setTimeout
    2. 6.2. Các vấn đề phạm vi biến trong Execution Context