Tips

setTimeout(fn, 0) mà 50ms sau mới chạy? Event loop giải thích

Tóm tắt

JavaScript chỉ có một luồng chính. setTimeout(fn, 0) chỉ đưa callback vào hàng đợi macrotask; nó phải chờ đoạn code hiện tại chạy xong và chờ toàn bộ microtask (Promise.then) được xử lý trước. Vì thế “0ms” trên thực tế có thể là hàng chục mili-giây.

Xem trên Facebook

Bạn viết setTimeout(fn, 0) và kỳ vọng hàm chạy “ngay lập tức”. Nhưng đo thực tế thấy nó chạy sau 50ms. Lỗi không nằm ở trình duyệt hay máy tính — mà ở cách event loop của JavaScript hoạt động. JavaScript chỉ có một luồng chính: setTimeout(fn, 0) chỉ đưa callback vào hàng đợi macrotask, nó phải chờ đoạn code hiện tại chạy xong và chờ toàn bộ microtask (Promise.then) được xử lý trước.

Thứ tự thực thi

  1. Một task đang chiếm call stack 26ms — không ai chen vào được.
  2. Task đó tạo 1 timer 0ms (vào hàng macrotask) và 3 promise (vào hàng microtask).
  3. Stack rỗng → event loop xả hết microtask trước, kể cả các then sinh thêm.
  4. Mới đến lượt macrotask: lúc này timer “0ms” đã chờ 50ms.
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
console.log('sync');
// sync → promise → timeout

Mô hình tư duy

Thành phầnChứa gìKhi nào chạy
Call stackCode đang chạyNgay lập tức, tới khi xong
Microtask queuePromise.then, queueMicrotaskSau khi stack rỗng, xả hết trước khi sang task khác
Macrotask queuesetTimeout, sự kiện UI, I/OMỗi vòng event loop lấy một task

Vì vậy “0ms” nghĩa là “không sớm hơn lúc này” chứ không phải “ngay bây giờ”. Trình duyệt cũng có thể áp thời gian tối thiểu cho timer lồng nhau.

Hệ quả thực tế

  • Task dài chặn mọi thứ: giao diện không phản hồi, timer trễ, animation giật.
  • Thứ tự không như mong đợi khi trộn setTimeout và Promise.
  • Microtask vô hạn có thể “bỏ đói” macrotask và làm treo giao diện.

Áp dụng thế nào?

  • Đừng dùng setTimeout(0) để đo thời gian hay giả định thứ tự chính xác — dùng Promise/await khi cần thứ tự rõ ràng.
  • Chia nhỏ việc nặng: tách thành nhiều phần, dùng requestIdleCallback hoặc Web Worker.
  • Cẩn thận vòng lặp microtask: promise liên tục sinh promise mới.
  • Node.js có thêm process.nextTick và các phase riêng — nguyên tắc ưu tiên microtask vẫn giữ nguyên.

Mẹo debug

  • Dùng tab Performance của DevTools để thấy các task dài (long task).
  • Ghi thời gian bằng performance.now() thay vì giả định.
  • Khi cần “nhường” cho trình duyệt vẽ lại, cân nhắc requestAnimationFrame hoặc chia task theo từng khung hình.

Nguồn & tham khảo

  1. The event loop — MDN Web Docs