Tại Sao Promise Chạy Trước setTimeout(0) 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. Lời hứa (promise) của nó cũng sẽ được đưa vào Micro Task Queue khi có kết quả.

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

#3.3. axios

1
2
3
4
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();

Callback onreadystatechange được xử lý qua Macro Task Queue, không phải Micro Task. Đây là điểm dễ gây nhầm lẫn:

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

  • Bởi XHR callback được đưa vào vòng lặp sự kiện giống như các sự kiện DOM. Khi trình duyệt nhận được dữ liệu, nó sẽ “thông báo” và đợi Event Loop chuyển sang vòng lặp tiếp theo để gọi callback. Vì thế, callback XHR thuộc nhóm macro tasks.

Trong thực tế: Điều này giải thích tại sao khi migrate từ XHR sang fetch/axios, bạn có thể gặp timing bugs. XHR callbacks chạy sau Promise callbacks trong cùng một event loop cycle, nên thứ tự xử lý có thể khác so với code cũ.

#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
requestAnimationFrameX (trình duyệt kiểm soát riêng)
XHR/AJAX (onreadystatechange)X
DOM Events (click, keyup, …)X
Promise (.then, .catch, .finally)X
fetch (sau khi nhận response)X
MutationObserverX
  • requestAnimationFrame cũng được xem như một loại macro task đặc thù, do trình duyệt tối ưu cho việc vẽ và cập nhật giao diện.

Trong thực tế: Khi làm việc với animation hoặc game loop, dùng requestAnimationFrame (macro task) 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

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!