Команда JavaScript for Devs подготовила перевод статьи о том, почему браузеры намеренно замедляют выполнение setTimeout и других таймеров. Автор объясняет, как это связано с защитой пользователей, рассказывает о своём бенчмарке разных подходов (setTimeoutMessageChannelscheduler.postTask) и делает прогноз, какие таймеры будут использоваться в будущем.


Даже если вы давно пишете на JavaScript, вас может удивить, что setTimeout(0) на самом деле не совсем setTimeout(0). Вместо этого колбэк может выполниться только через 4 миллисекунды:

const start = performance.now()
setTimeout(() => {
  // Скорее всего ~4 мс
  console.log(performance.now() - start)
}, 0)

Почти десять лет назад, когда я работал в команде Microsoft Edge, мне объяснили, что браузеры делают это, чтобы избежать «злоупотреблений». Слишком много сайтов бесконечно вызывают setTimeout, и чтобы не сажать батарею пользователя и не блокировать интерфейс, браузеры вводят специальный минимум — «clamped» — равный 4 мс.

Это также объясняет, почему некоторые браузеры ещё сильнее увеличивают задержку, если устройство работает от батареи (в старом Edge она была 16 мс) или замедляют выполнение ещё агрессивнее для фоновых вкладок (в Chrome — до 1 секунды!).

Но меня всегда мучил вопрос: если setTimeout так активно злоупотребляют, зачем браузеры продолжают вводить новые таймеры — вроде setImmediate (уже всё), Promise или даже новых штук вроде scheduler.postTask()? Если setTimeout пришлось «понерфить», то разве с этими таймерами не случится то же самое?

В 2018 году я написал большой пост про таймеры JavaScript, но до недавнего времени не видел причин возвращаться к этой теме. А потом я работал над fake-indexeddb — реализацией API IndexedDB на чистом JavaScript — и вопрос снова всплыл. Дело в том, что IndexedDB должен автоматически коммитить транзакции, когда в event loop нет незавершённой работы — то есть после выполнения всех микротасков, но до того, как начнут выполняться таски (можно я буду говорить «макротаски»?).

Чтобы это реализовать, fake-indexeddb использовал setImmediate в Node.js (который похож на старый браузерный вариант) и setTimeout в браузере. В Node setImmediate почти идеален — он срабатывает сразу после микротасков, но до любых других тасков, и без задержки. А вот в браузере setTimeout оказывается далеко не оптимальным: в одном из бенчмарков я видел, что Chrome выполнял задачу за 4,8 секунды вместо 300 миллисекунд в Node (16-кратное замедление!).

Но если посмотреть на ситуацию с таймерами в 2025 году, выбор не так очевиден. Возможные варианты:

  • setImmediate — поддерживается только в старых Edge и IE, так что сразу мимо.

  • MessageChannel.postMessage — именно этот подход использует библиотека afterframe.

  • window.postMessage — идея неплохая, но довольно корявая: может конфликтовать с другими скриптами на странице, которые используют тот же API. Впрочем, именно на этом построен полифилл setImmediate.

  • scheduler.postTask — если не хотите читать дальше, сразу скажу: победил именно он. Но давайте разберёмся почему!

Чтобы сравнить эти варианты, я написал небольшой бенчмарк. Пара важных моментов про него:

  • Нужно запускать несколько итераций setTimeout (и других таймеров), чтобы реально «выловить» clamping эффект. Согласно спецификации HTML, ограничение в 4 мс должно включаться только после того, как setTimeout вложен сам в себя 5 раз подряд.

  • Я не тестировал все возможные комбинации: 1) питание от батареи или сети, 2) разные частоты обновления монитора, 3) вкладки в фоне или на переднем плане и т. д., хотя знаю, что всё это может влиять на clamping. У меня тоже есть личная жизнь — и хотя иногда весело надеть белый халат и поиграть в исследователя, тратить на это всю субботу не хотелось.

Итак, вот результаты (в миллисекундах, медиана 101 итерации, на MacBook Pro 16” 2021):

Браузер

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

Не реализован

Примечание: этот бенчмарк было непросто написать! Сначала я использовал Promise.all, чтобы запускать все таймеры одновременно, но это, похоже, ломало эвристику вложенности в Safari и делало поведение Firefox непоследовательным. Теперь бенчмарк запускает каждый таймер по отдельности.

Не стоит слишком зацикливаться на конкретных цифрах — суть в том, что Chrome и Firefox ограничивают setTimeout до 4 мс, а остальные три варианта примерно одинаковы по скорости. Интересно, что в Safari setTimeout замедлен ещё сильнее, а MessageChannel.postMessage чуть медленнее, чем window.postMessage (хотя последний по-прежнему остаётся корявым по причинам, описанным выше).

Этот эксперимент дал ответ на мой ближайший вопрос: в fake-indexeddb стоит использовать scheduler.postTask (он мне больше нравится по эргономике), а в качестве запасного варианта — MessageChannel.postMessage или window.postMessage. (Я попробовал разные приоритеты для postTask, но они показали почти одинаковые результаты. Для кейса fake-indexeddb оптимальным оказался приоритет 'user-visible', именно он и использовался в бенчмарке.)

Но на мой изначальный вопрос это не ответило: почему браузеры вообще утруждают себя тем, чтобы ограничивать setTimeout, если веб-разработчики могут просто взять scheduler.postTask или MessageChannel?

Я спросил об этом у своего друга Тодда Райфстека, который был сопредседателем Web Performance Working Group, когда шли все эти обсуждения про «интервенции» (прим. редакции: под интервенциями автор подразумевает вмешательства со стороны браузера).

Он сказал, что по сути было два лагеря. Один считал, что таймеры надо ограничивать, чтобы защитить веб-разработчиков от самих себя. Другой полагал, что разработчики должны «сами измерять свою глупость», а любые тонкие эвристики throttling только создадут путаницу.

Короче говоря, это классический компромисс при проектировании API производительности: «некоторые API быстрые, но при этом — с миной-ловушкой».

Это полностью совпадает с моими собственными ощущениями на эту тему. Обычно браузеры вмешиваются тогда, когда разработчики либо слишком злоупотребляют какой-то возможностью (например, setTimeout), либо просто не знают о более правильных альтернативах (хороший пример — история со слушателями touch). В конце концов, браузер — это «user agent», действующий в интересах пользователя, и приоритеты W3C чётко говорятпотребности конечного пользователя всегда важнее потребностей веб-разработчиков.

Тем не менее, веб-разработчики часто и сами хотят сделать всё правильно. (Считайте этот пост моей попыткой пойти в этом направлении.) Просто у нас не всегда есть подходящие инструменты, и тогда мы хватаем первое, что под руку попадётся, и начинаем махать этой кувалдой. Если дать нам больше контроля над задачами и планированием их выполнения, нам не придётся забивать всё подряд setTimeout-ами и устраивать хаос, который потом приходится «чинить» браузеру.

Мой прогноз — postTask и postMessage останутся без ограничений как минимум в ближайшее время. Если вернуться к двум лагерям, о которых говорил Тодд, то сам факт существования Scheduler API — с целым набором инструментов для тонкой настройки планирования задач — намекает, что сейчас рулит именно «лагерь за контроль». Хотя сам Тодд считает API скорее компромиссом между двумя подходами: да, он даёт много контроля, но при этом синхронизируется с реальным рендеринг-пайплайном браузера, а не с произвольными таймаутами.

Пессимист во мне, впрочем, сомневается: API всё ещё можно «переиспользовать не по делу» — например, бездумно везде ставить приоритет user-blocking. Возможно, в будущем какой-нибудь инициативный вендор браузера решит «закрутить гайки» посильнее и обнаружит, что сайты от этого становятся шустрее, отзывчивее и меньше расходуют батарею. Если так и случится, нас может ждать очередной виток интервенций. (Может, ещё и какой-нибудь scheduler2 придётся придумывать, чтобы выпутаться!)

Я уже мало участвую в веб-стандартах и могу только гадать. Пока же поступлю так, как делает большинство веб-разработчиков: выберу тот API, который решает мои задачи сегодня, и буду надеяться, что браузеры не сильно всё поменяют в будущем. Если мы будем аккуратнее и не будем приносить слишком много «глупостей», это не такая уж большая просьба.

Спасибо Тодду Райфстеку за комментарии к черновику этого поста.

Примечание: всё, что я сказал про setTimeout, в равной степени относится к setInterval. С точки зрения браузера это почти одни и те же API.

Примечание: если это что-то меняет, в Safari fake-indexeddb всё ещё откатывается к setTimeout, а не к MessageChannel или window.postMessage. Несмотря на мои результаты выше, в собственном бенчмарке fake-indexeddb мне удалось добиться превосходства window.postMessage над двумя другими только там — похоже, в Safari есть дополнительное ограничение для MessageChannel, которое мой отдельный бенчмарк не уловил. И window.postMessage по-прежнему кажется мне склонным к ошибкам, так что использовать его не хочется. Вот мой бенчмарк для любопытных.

Русскоязычное JavaScript сообщество

Друзья! Эту статью перевела команда «JavaScript for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Frontend. Подписывайтесь, чтобы быть в курсе и ничего не упустить!

Комментарии (0)