awesome-everything EN
↑ Обратно к восхождению

Браузер и фронтенд-рантайм

Точность таймеров, троттлинг и фоновая работа

Суть Почему setTimeout(fn, 100) редко срабатывает ровно через 100 мс — три слоя троттлинга, объединение фоновых вкладок, requestIdleCallback и семантика комбинаторов промисов.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на middle-высоте — в небе
◷ 14 min

Ваш цикл опроса дрейфует. setInterval фоновой вкладки срабатывает раз в минуту вместо каждой секунды. Диагностический таймер, измеряющий 50 мс, показывает 120 мс. Таймеры в браузере — это обещание со звёздочками, и звёздочек становится больше, как только что-то занято.

Почему setTimeout — нижняя граница, а не гарантия

setTimeout(fn, 100) не обещает запустить fn ровно через 100 мс — он обещает поставить задачу в очередь не раньше 100 мс. Фактическое время выполнения зависит от того, когда главный поток следующий раз освободится. Если во время срабатывания таймера выполняется задача длиной 300 мс, ваш колбэк ждёт все 300 мс плюс свою позицию в очереди.

Три слоя троттлинга усугубляют это:

Слой 1 — зажим вложенности 4 мс. После 5 уровней вложенности setTimeout (или setInterval) браузеры зажимают задержку до минимума 4 мс. Это правило спецификации, а не особенность: оно предотвращает busy-loop через цепочки setTimeout(0). Цикл из 100 итераций с setTimeout(0) занимает минимум 400 мс независимо от скорости работы.

Слой 2 — троттлинг фоновой вкладки. Когда вкладка скрыта (document.visibilityState === 'hidden'), браузеры зажимают таймеры до минимума 1 секунда. После того как вкладка несколько минут была скрытой, Chrome дополнительно снижает до одного раза в минуту. Ваш фоновый цикл опроса тихо замедляется до черепашьего темпа.

Слой 3 — выравнивание/объединение таймеров. Для экономии батареи браузеры округляют времена срабатывания таймеров до общей сетки, чтобы процессор просыпался один раз для многих таймеров вместо многих раз. Практическое следствие: никогда не используйте setTimeout/setInterval для чего-либо, требующего субкадровой точности. Для анимаций — requestAnimationFrame; для точного планирования — часы AudioContext или дельты performance.now() внутри rAF-цикла.

Три слоя троттлинга
Нормальная минимальная задержка
0 мс (зависит от позиции в очереди)
Зажим вложенности (глубина ≥5)
минимум 4 мс
Фоновая вкладка
≥1 000 мс
Фоновая вкладка (много минут)
≥60 000 мс
Объединение для экономии батареи
выравнивание по сетке, ~1 мс джиттер
Викторина

Страница вызывает `setTimeout(loop, 0)`, где `loop` перепланирует себя. После 5 уровней вложенности что происходит с эффективной задержкой?

Видимость страницы и жизненный цикл event loop

Event loop не останавливается, когда вкладка уходит в фон, но меняет режим. document.visibilityState переключается в hidden, requestAnimationFrame перестаёт срабатывать совсем (нет рисования = нет rAF), и таймеры троттлятся как описано выше.

Событие visibilitychange — правильное место для остановки опроса, паузы видео, сброса несохранённого состояния. Современная замена ненадёжного события unloadpagehide плюс Page Lifecycle API с состояниями frozen и terminated: браузер может полностью заморозить фоновую вкладку (остановив её цикл), чтобы освободить память, затем оттаять при возврате. Код с открытыми соединениями или таймерами должен слушать freeze/resume, иначе после оттаивания он работает с устаревшим состоянием — закрытым WebSocket, истёкшим токеном, отсоединённым observer.

Викторина

Фоновая вкладка запускает setInterval с опросом раз в секунду. После 5 минут в фоне, как часто Chrome обычно вызывает колбэк?

requestIdleCallback — планирование несрочной работы

Не вся работа срочна. Аналитические маяки, предзагрузка, прогрев кеша и сброс логов могут подождать, пока цикл ничем лучшим не занят. requestIdleCallback(fn) ставит в очередь колбэк, который браузер запускает только когда итерация заканчивается с запасным временем до следующего дедлайна кадра. Колбэк получает объект deadline, чей timeRemaining() сообщает, сколько мс можно безопасно использовать (ограничено примерно 50 мс).

Дисциплина: делайте небольшой срез, проверяйте timeRemaining(), и если он близок к нулю — перепланируйте остаток ещё одним requestIdleCallback. Необязательный параметр { timeout } принудительно запускает колбэк после дедлайна, чтобы работа не голодала вечно.

requestIdleCallback — кооперативная противоположность requestAnimationFrame: rAF говорит «запусти меня прямо перед следующей отрисовкой», ridle говорит «запусти меня только если никому больше не нужен поток». Вместе они позволяют держать срочную работу на rAF и откладывать всё остальное с критического пути.

Посчитай

deadline.timeRemaining() у requestIdleCallback ограничен примерно этим количеством миллисекунд — тем же, что и порог длинной задачи.

мс

Комбинаторы промисов и планирование

Promise.all, Promise.allSettled, Promise.race и Promise.any меняют что вы ожидаете, но не то, как цикл планирует. Все четыре запускают каждый переданный промис немедленно — параллелизм достигается за счёт начала асинхронных операций до их ожидания.

  • Promise.all — отклоняется при первой ошибке (fail-fast). Подходит, когда любая ошибка делает весь результат бесполезным.
  • Promise.allSettled — никогда не отклоняется; резолвится с массивом статусов. Подходит когда нужны частичные результаты (пять независимых виджетов дашборда, где ошибка одного не должна обнулять остальные).
  • Promise.race — завершается при первом урегулировании, успехе или ошибке. Классическое использование: таймаут: Promise.race([fetchData(), rejectAfter(5000)]).
  • Promise.any — резолвится при первом успехе, игнорируя отклонения пока все не провалятся. Подходит для избыточности, например гонки трёх CDN-зеркал.

Выбор неправильного комбинатора — ошибка корректности, а не производительности: Promise.all для дашборда означает, что ошибка одного медленного виджета обнуляет страницу.

Распространённый баг: написать const a = await fetchA(); const b = await fetchB(); когда оба fetch независимы. Два запроса выполняются последовательно, потому что второй await не начинается до резолва первого. const [a, b] = await Promise.all([fetchA(), fetchB()]) запускает оба немедленно. Разница в стоимости — один полный RTT. Признак в DevTools — два сетевых запроса с непересекающимся временем там, где оба могли бы выполняться параллельно.

Викторина

У вас пять независимых API-вызовов для дашборда. Один из них может провалиться. Какой комбинатор подходит?

Викторина

Срабатывает событие `visibilitychange` фоновой вкладки. Код держит открытый WebSocket и цикл опроса setInterval. Что делать?

Вспомните перед уходом
  1. 01
    Назовите три слоя троттлинга, влияющих на точность setTimeout.
  2. 02
    Когда использовать requestIdleCallback вместо setTimeout(fn, 0)?
  3. 03
    Почему `const a = await fetchA(); const b = await fetchB();` — баг когда оба fetch независимы?
Итог

setTimeout(fn, delay) ставит задачу в очередь не раньше delay миллисекунд, но фактическое время срабатывания зависит от доступности главного потока и трёх слоёв троттлинга: зажим вложенности 4 мс (предотвращает busy-loop через setTimeout(0)), троттлинг фоновой вкладки (1 с → 1 мин) и объединение таймеров для экономии батареи. Для точного времени в анимациях — requestAnimationFrame или часы AudioContext: оба управляются конвейером рендеринга, а не очередью таймеров. Для несрочной работы requestIdleCallback выполняется только в настоящие промежутки простоя и даёт бюджет timeRemaining() для безопасного нарезания работы. Комбинаторы промисов не меняют планирование цикла, но меняют корректность: Promise.all для fail-fast, Promise.allSettled для частичных результатов, Promise.race для таймаутов, Promise.any для избыточности при первом успехе.

Связанные уроки
встречается в193
Продолжить восхождение ↑Голодание микрозадач, длинные задачи и LoAF
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.