Tại Sao Trình Duyệt Hạn Chế JavaScript Timers?

Dù đã làm việc với JavaScript trong một thời gian dài, bạn có thể bất ngờ khi biết rằng setTimeout(0) không thực sự là setTimeout(0). Thay vào đó, nó có thể chạy sau 4 mili giây:


const start = performance.now()
setTimeout(() => {
  // Có thể là 4ms
  console.log(performance.now() - start)
}, 0)

Cách đây gần một thập kỷ khi tôi còn làm việc tại Microsoft Edge, người ta đã giải thích cho tôi rằng trình duyệt làm điều này để tránh “lạm dụng”. Tức là, có rất nhiều trang web ngoài đó spam setTimeout, vì vậy để tránh cạn kiệt pin của người dùng hoặc chặn tương tác, trình duyệt đặt một “giới hạn tối thiểu” đặc biệt là 4 mili giây.

Điều này cũng giải thích tại sao một số trình duyệt sẽ tăng cường hạn chế cho các thiết bị đang sử dụng pin (trường hợp của Edge cũ là 16ms), hoặc hạn chế mạnh mẽ hơn nữa cho các tab nền (1 giây trong Chrome!).

Tuy nhiên, một câu hỏi luôn làm tôi bận tâm: nếu setTimeout bị lạm dụng đến vậy, tại sao trình duyệt lại tiếp tục giới thiệu các bộ đếm thời gian mới như setImmediate (RIP), Promises, hoặc thậm chí là những tính năng mới mẻ như scheduler.postTask()? Nếu setTimeout phải bị hạn chế, liệu những bộ đếm thời gian này có phải chịu chung số phận không?

Tôi đã viết một bài đăng dài về JavaScript timers vào năm 2018, nhưng gần đây tôi mới có lý do tốt để xem lại câu hỏi này. Sau đó, tôi đang làm việc trên fake-indexeddb, đó là một triển khai thuần túy JavaScript của IndexedDB API, và câu hỏi này lại xuất hiện. Hóa ra, IndexedDB muốn tự động commit giao dịch khi không có công việc nào còn tồn tại trong vòng lặp sự kiện – nói cách khác, sau khi tất cả các microtask hoàn thành, nhưng trước bất kỳ task nào (có thể tôi nói đùa một chút bằng cách gọi chúng là “macro-tasks”?) bắt đầu.

Để thực hiện điều này, fake-indexeddb đang sử dụng setImmediate trong Node.js (có một số điểm tương đồng với phiên trình duyệt cũ) và setTimeout trong trình duyệt. Trong Node, setImmediate khá hoàn hảo, vì nó chạy sau microtask nhưng ngay trước bất kỳ task nào khác, và không bị giới hạn. Tuy nhiên, trong trình duyệt, thì khá không tối ưu: trong một bài benchmark, tôi thấy Chrome mất 4,8 giây cho một thao tác chỉ mất 300 mili giây trong Node (chậm 16 lần!).

Nhìn ra bối cảnh timer vào năm 2025, điều này không rõ ràng về việc nên lựa chọn gì. Một số lựa chọn bao gồm:

  • setImmediate – chỉ được hỗ trợ trong Edge và IE cũ, vì vậy không khả thi.
  • MessageChannel.postMessage – đây là kỹ thuật được sử dụng bởi afterframe.
  • window.postMessage – một ý tưởng hay, nhưng khá khó xử vì nó có thể can thiệp vào các script khác trên trang sử dụng cùng API. Cách tiếp cận này được sử dụng bởi setImmediate polyfill mặc dù.
  • scheduler.postTask – nếu bạn không đọc thêm, đây là lựa chọn chiến thắng. Nhưng hãy giải thích tại sao!

Để so sánh các lựa chọn này, tôi đã viết một bài benchmark nhanh. Một số điểm quan trọng về benchmark này:

  • Bạn phải chạy một số lần lặp của setTimeout (và bạn bè) để thực sự hiểu được việc giới hạn. Về mặt kỹ thuật, theo đặc tả HTML, việc giới hạn 4ms chỉ nên được kích hoạt sau khi setTimeout được lồng (ví dụ: một setTimeout gọi một setTimeout khác) 5 lần.
  • Tôi không kiểm tra mọi kết hợp có thể của 1) pin cắm hoặc không, 2) tốc độ làm mới màn hình, 3) tab nền hoặc tab nền, v.v., mặc dù tôi biết tất cả những điều này có thể ảnh hưởng đến việc giới hạn. Tôi có cuộc sống, và dù mặc áo phòng thí nghiệm và thực hiện một số thí nghiệm rất vui, tôi không muốn dành cả thứ Bảy của mình để làm điều đó.

Bất kể, đây là các con số (bằng mili giây, trung vị của 101 lần lặp, trên MacBook Pro 16-inch 2021):

Trình duyệt setTimeout MessageChannel window scheduler.postTask
Chrome 139 4.2 0.05 0.03 0.00
Firefox 142 4.72 0.02 0.01 0.01
Safari 18.4 26.73 0.52 0.05 Không được triển khai

Lưu ý: bài benchmark này khó viết! Khi tôi viết lần đầu tiên, tôi đã sử dụng Promise.all để chạy tất cả các timer đồng thời, nhưng điều này dường như phá vỡ các nguyên tắc lồng ghép của Safari, và làm cho Firefox hoạt động không nhất quán. Bây giờ benchmark chạy mỗi timer độc lập.

Đừng quá lo lắng về các con số chính xác: điểm là Chrome và Firefox giới hạn setTimeout ở mức 4ms, và ba lựa chọn còn lại gần như tương đương. Đáng chú ý là trong Safari, setTimeout bị giới hạn nặng nề hơn, và MessageChannel.postMessage chậm hơn một chút so với window.postMessage (mặc dù window.postMessage vẫn khó xử vì các lý do đã nêu ở trên).

Thí nghiệm này đã trả lời câu hỏi trực tiếp của tôi: fake-indexeddb nên sử dụng scheduler.postTask (tôi ưa thích vì tính tiện dụng của nó) và dự phòng cho MessageChannel.postMessage hoặc window.postMessage. (Tôi đã thử nghiệm với các mức ưu tiên khác nhau cho postTask, nhưng chúng đều hoạt động gần như giống hệt nhau. Đối với trường hợp sử dụng của fake-indexeddb, mức ưu tiên mặc định ‘user-visible’ có vẻ phù hợp nhất, và đó là những gì benchmark sử dụng.)

Không có điều nào trong số này trả lời câu hỏi ban đầu của tôi: tại sao trình duyệt lại đi đến mức giới hạn setTimeout nếu các nhà phát triển web có thể chỉ cần sử dụng scheduler.postTask hoặc MessageChannel thay vào đó? Tôi đã hỏi bạn mình là Todd Reifsteck, người từng là đồng chủ tịch của Nhóm Làm việc Hiệu suất Web vào thời điểm nhiều cuộc thảo luận về “can thiệp” này đang được tiến hành.

Ông nói rằng về cơ bản có hai phe: một phe cho rằng các timer cần bị giới hạn để bảo vệ các nhà phát triển web khỏi chính họ, trong khi phe kia cho rằng các nhà phát triển nên “đo lường sự ngớ ngẩn của chính mình”, và các nguyên tắc giới hạn tinh vi chỉ gây ra sự nhầm lẫn. Ngắn gọn, đó là sự đánh đổi tiêu chuẩn khi thiết kế các API hiệu suất: “một số API nhanh nhưng đi kèm với các vấn đề tiềm ẩn”.

Điều này phù hợp với trực giác của tôi về chủ đề này. Các can thiệp của trình duyệt thường được áp dụng vì các nhà phát triển web hoặc đã sử dụng quá nhiều thứ tốt (ví dụ: setTimeout), hoặc hoàn toàn không biết về các lựa chọn tốt hơn (tranh cãi trình nghe chạm là một ví dụ tốt). Cuối cùng, trình duyệt là một “tác nhân người dùng” hành động thay mặt người dùng, và thứ tự ưu tiên của W3C cho thấy rõ rằng nhu cầu của người dùng cuối luôn vượt trội hơn nhu cầu của nhà phát triển web.

Đó nói, các nhà phát triển web thường muốn làm điều đúng đắn. (Tôi xem bài đăng blog này là một nỗ lực trong hướng đó.) Chúng tôi chỉ không luôn luôn có công cụ để làm điều đó, vì vậy thay vào đó, chúng tôi nắm lấy bất kỳ công cụ thô b nào gần đó và bắt đầu hành động. Cung cấp cho chúng tôi nhiều kiểm soát hơn về các tác vụ và lập lịch có thể tránh được nhu cầu sử dụng setTimeout và gây ra một mớ hỗn loạn đòi hỏi phải can thiệp.

Dự đoán của tôi là postTask/postMessage sẽ không bị giới hạn trong thời gian tới. Trong hai “phe” của Todd, sự tồn tại tại của API Scheduler, cung cấp một loạt các công cụ chi tiết cho việc lập lịch tác vụ, dường như chỉ ra rằng phe “ủng hộ kiểm soát” là phe đang định hướng con tàu. Mặc dù Todd xem API này như một sự thỏa hiệp giữa hai nhóm: đúng, nó cung cấp rất nhiều kiểm soát, nhưng nó cũng phù hợp với đường ống kết xuất thực tế của trình duyệt thay vì các thời gian ngẫu nhiên.

Tuy nhiên, người bi quan trong tôi tự hỏi liệu API có thể vẫn bị lạm dụng – chẳng hạn như sử dụng mức ưu tiên user-blocking khắp mọi nơi. Có thể trong tương lai, một số nhà cung cấp trình duyệt đầy tham vọng sẽ đặt chân của mình lên bàn đạp tăng tốc (nói theo nghĩa bóng) và phát hiện ra điều đó làm cho các trang web trở nên phản hồi nhanh hơn, mượt mà hơn và ít tiêu tốn pin hơn. Nếu điều đó xảy ra, chúng ta có thể thấy một vòng can thiệp khác. (Có lẽ chúng ta sẽ cần API scheduler2 để tự đào mình ra khỏi mớ hỗn loạn đó!)

Tôi không còn tham gia nhiều vào các tiêu chuẩn web nữa và chỉ có thể suy đoán. Trong thời gian này, tôi sẽ làm như hầu hết các nhà phát triển web làm: chọn bất kỳ API nào đạt được mục tiêu của tôi hôm nay, và hy vọng trình duyệt không thay đổi quá nhiều trong tương lai. Chừng nào chúng tôi cẩn thận và không đưa vào quá nhiều “sự ngớ ngẩn”, tôi nghĩ đó không phải là một yêu cầu quá lớn.

Cảm ơn Todd Reifsteck vì đã phản hồi về bản nháp của bài đăng này.

Lưu ý: mọi thứ tôi nói về setTimeout cũng có thể được áp dụng cho setInterval. Về mặt trình duyệt, đây gần như là các API giống hệt nhau.

Lưu ý: để cho đáng giá, fake-indexeddb vẫn đang dự phòng sang setTimeout thay vì MessageChannel hoặc window.postMessage trong Safari. Bất kể các benchmark của tôi ở trên, tôi chỉ có thể làm cho window.postMessage vượt trội hơn hai phương pháp còn lại trong benchmark của riêng fake-indexeddb – Safari có vẻ có một số giới hạn bổ sung cho MessageChannel mà benchmark độc lập của tôi không thể tìm ra. Và window.postMessage vẫn có vẻ lỗi tiềm ẩn với tôi, vì vậy tôi e ngại khi sử dụng nó. Đây là benchmark của tôi cho những ai tò mò.