InquirerUP, MMDA team up to boost Metro Manila flood resilienceDaily MaverickQUIZ: The local elections are coming. How much do you know?The Jerusalem Post'Like a biblical battle': WP journalist wounded in West Bank credits Palestinian youth with rescuePunchUS authorities charge six over illegal immigration marriage schemeCNN TürkMERKEZ BANKASI FAİZ KARARI EKİM 2026: Ekim Ayı TCMB Faiz Kararı Ne Olacak? TCMB Ekim Toplantı Tarihi Belli OlduBollywood HungamaEXCLUSIVE: Sunny Deol's Gabru set for 2027 release; Vishal Rana promises a NEVER-SEEN-BEFORE avatar: “Sunny sir says, ‘Iss baar dhai kilo ka haath nahin, dhai kilo ka HEART leke aaya hoon’!”RTP DesportoSp. Braga procura primeiros pontos na estreia de Franclim CarvalhoInquirer EntertainmentStar Magic eyes collab with Chinoy pageant to cast winners in vertical shortsKhaosod EnglishMedical students from 29 institutions oppose easing e-cigarette and HTP restrictions
The Daily Newsstand · Free, Always
Thursday, October 22, 2026

Прощай, setTimeout(0): как современный Web API управляет приоритетами задач и разгружает главный поток

Translate

Представьте ситуацию: человек полчаса собирал важный отчёт в веб-таблице или подбирал сложную комбинацию фильтров, нажал «Сохранить» или «Применить» — и страница намертво зависла. Курсор превратился в колесико, меню не открывается, прокрутка не работает. В голове пользователя мгновенно паника: «Всё зависло? Мои данные пропали? Нажать ещё раз? Перезагрузить страницу?!»

Причина проста: если вычисления выполняются в основном потоке (Main Thread), страница будто «подвисает». JavaScript в браузере однопоточен, и пока Main Thread занят выполнением первой задачи, браузер не может обработать события клика и обновить интерфейс.

Чтобы измерить эту блокировку на практике, мы собрали тестовый стенд с двумя кнопками: первая запускает расчёт по массиву из 500 тыс. записей, вторая — увеличивает обычный счётчик. В рамках эксперимента мы запустили расчёт первой кнопкой и сразу попытались кликнуть по второй.

В результате расчёт занял 106 мс, а задержка отклика второй кнопки достигла 77 мс — всё это время интерфейс не реагировал на нажатие. В реальном приложении вместо безобидного счётчика могла быть кнопка открытия меню, переключения вкладки или сохранения формы.

С точки зрения метрики INP (Interaction to Next Paint), определяющей Core Web Vitals сайта, хорошим показателем считается задержка отклика до отрисовки кадра в пределах 200 мс. Задачи на Main Thread длительностью более 50 мс — уже считаются Long Tasks и желательно их сразу отслеживать и стараться не допускать.

Возникает фундаментальный вопрос: как выполнять ресурсоёмкую работу, сохраняя интерфейс полностью отзывчивым?

Существует два принципиально разных подхода:

  1. Дробление (Time-slicing): разбить синхронную задачу на мелкие части и возвращать управление Event Loop между ними, позволяя браузеру обрабатывать накопившиеся события

  2. Распараллеливание: полностью вынести вычисления из основного потока

В этой статье мы подробно рассмотрим оба подхода, разберём механику очередей задач, измерим их на реальном стенде и ответим на вопрос, почему setTimeout(0) постепенно становится антипаттерном в современной веб-разработке.

Почему простые решения не работают

Ошибка 1: Надежда на Async и простой setTimeout

Среди разработчиков до сих пор распространён миф: «Если обернуть функцию в Async или добавить Await, она станет асинхронной и перестанет блокировать интерфейс». На самом деле Async не создаёт параллельных потоков и не выносит код из Main Thread. Когда браузер встречает Async‑функцию, он начинает выполнять её синхронно — ровно до того момента, пока не встретит ключевое слово Await, за которым следует асинхронная операция (например, Fetch или Promise).

Если внутри Async‑функции есть тяжёлый цикл For, он будет выполнять вычисления в основном потоке:

// НЕ РАБОТАЕТ: Этот код полностью заморозит интерфейс!
async function calculateAsync() {
  // Код до первого await выполняется строго СИНХРОННО!
  let total = 0;
  for (let i = 0; i < 500_000_000; i++) {
    total += Math.sqrt(i); // Тяжёлые вычисления
  }
  return total;
}

// Запуск функции
calculateAsync(); 
// UI всё равно заблокирован намертво, несмотря на то, что функция async!

Что происходит «под капотом» V8

  1. При вызове CalculateAsync() на вершине Call Stack создаётся новый контекст исполнения

  2. Движок входит в цикл For; поскольку внутри цикла нет инструкций передачи управления (Yield / Await Promise) , движок выполняет все 500 млн итераций в текущем кванте времени

  3. Наличие ключевого слова Async лишь заставляет функцию вернуть Promise с результатом, но сам процесс вычислений полностью блокирует Event Loop.

А если обернуть запуск в setTimeout(..., 0)?

// Тоже НЕ РАБОТАЕТ как ожидали
setTimeout(() => {
  calculateEverything(); // Тяжёлая синхронная функция
}, 0);

Этот приём лишь откладывает старт вычислений до следующего тика Event Loop, давая браузеру шанс отрисовать кадр перед запуском. Но как только таймер срабатывает, CalculateEverything() попадает в Call Stack и монопольно захватывает главный поток до самого конца. Если пользователь кликнет по кнопке через 1 мс после запускаsetTimeout, обработчик клика встанет в очередь и будет жутко лагать.

Вывод: асинхронность в JS — это не про «параллельные вычисления», а про ожидание событий. Чтобы сделать тяжёлый алгоритм неблокирующим, недостаточно добавить слово Async — нужно физически прерывать выполнение цикла и возвращать управление Event Loop.

Ошибка 2: дробление через setTimeout(0) и проблема HTML Spec Clamp

Чтобы Event Loop мог вклиниваться между шагами алгоритма, монолитную задачу разбивают на порции (Time-Slicing). Код переписывают в итеративную форму, сохраняя состояние между шагами:

function stepComputation(state, dataset, maxRows) {
  const end = Math.min(state.cursor + maxRows, dataset.length);
  let totalRevenueCents = state.totalRevenueCents;

  for (let index = state.cursor; index < end; index++) {
    totalRevenueCents += dataset.revenueCents[index];
  }

  return { cursor: end, totalRevenueCents };
}

Примечание: функция stepComputation показывает упрощённую структуру шага на примере суммирования. В полном алгоритме тестового стенда на каждом этапе также сохраняются агрегаты показателей по месяцам и текущий список топ-200 записей.

Затем запускают асинхронный цикл, отдающий управление Event Loop на каждом шаге:

while (state.cursor < dataset.length) {
  state = stepComputation(state, dataset, chunkSize);
  if (state.cursor < dataset.length) {
    await new Promise(resolve => setTimeout(resolve, 0));
  }
}

Однако у setTimeout есть два фундаментальных недостатка:

  1. HTML Spec Clamp (4 мс): согласно спецификации, при вложенности таймеров более пяти уровней минимальная задержка ограничивается 4 мс. На практике одна уступка кадра занимает 4,5—5 мс с учётом оверхеда Event Loop.

  2. Потеря приоритета продолжения (Continuation Priority): вызов setTimeout(0) помещает продолжение функции в самый конец общей очереди задач. Если на странице выполняются другие фоновые таймеры, анимации или сетевые колбэки, ваша задача будет отдаляться с каждым квантом.

Анатомия Event Loop: механика очередей задач (Task Queue Priorities)

Чтобы понять, чем Scheduler.yield() отличается от setTimeout и MessageChannel, нужно заглянуть в приоритеты очередей браузера:

  • setTimeout(0) и MessageChannel. При отдаче управления продолжение задачи попадает в конец стандартной очереди (Standard Task Queue).

  • Scheduler.yield() сообщает браузеру, что вызывающая функция отдаёт управление для обработки пользовательского ввода, но хочет возобновить работу сразу после этого. Продолжение задачи помещается во главе очереди текущего приоритета (Boosted Continuation Priority), благодаря чему вычисления возобновляются сразу после отображения отклика интерфейса.

Методология и метрики эксперимента

Для чистоты эксперимента мы использовали единый датасет из 500 тыс. записей. Полный алгоритм фильтрует данные, агрегирует показатели по месяцам и формирует топ-200 строк с наибольшей выручкой.

Почему размер порции — 8192 записи? Одна порция из 8192 строк занимает около 1,7 мс чистого времени CPU. Это значительно меньше порога Long Task (50 мс) и позволяет браузеру гарантированно сохранять высокую плавность кадра.

Сравним следующие сценарии:

  1. Синхронный запуск: Baseline без разбиения

  2. Дробление через setTimeout(..., 0): классический паттерн уступки

  3. Дробление через MessageChannel: альтернативный способ передачи сообщений без ограничения в 4 мс

  4. Дробление через scheduler.yield(): современный API управления приоритетами задач

  5. Web Worker: перенос полного монолитного алгоритма в отдельный поток

Что именно мы измеряем

  • Time to Result (время до получения результата): интервал от старта до получения готового массива данных в Main Thread (включая все паузы, задержки таймеров и накладные расходы на обмен сообщениями)

  • Input Delay: задержка между отправкой синтетического события Pointerdown (через DevTools Protocol) и моментом, когда вызван наш Capture-Listener на клик по второй кнопке.

Как проводились замеры: тесты выполнялись в Chromium 151. Для каждого метода проведено пять прогревочных и 30 контрольных запусков с искусственной эмуляцией ввода в случайные моменты времени (через 20, 50 и 80 мс после старта).

Способ

До результата, мс

p95 до результата, мс

Активные вычисления, мс

Input delay, мс

Long tasks, медиана

Синхронно

105,8

111,0

105,8

45,4

1

setTimeout, порция 8192

380,0

392,2

105,7

0,3

0

scheduler.yield(), порция 8192

125,1

132,2

107,6

1,1

0

Повторно используемый Worker

109,8

121,1

105,8

0,3

0

Сравнение времени полного выполнения и задержки отклика UI. Слева — время до получения ответа, справа — задержка ввода (Input Delay). Точка — один запуск, черта — медиана, ромб — p95

Сравнение времени полного выполнения и задержки отклика UI. Слева — время до получения ответа, справа — задержка ввода (Input Delay). Точка — один запуск, черта — медиана, ромб — p95

Разбор механики каждого подхода

1. Синхронный расчёт: быстро, но ценой полных блокировок

Профиль производительности синхронного варианта: весь расчёт выполняется внутри одной длинной задачи Task (Long Task) на Main Thread. Обработка события Click вынуждена ждать завершения этой задачи

Профиль производительности синхронного варианта: весь расчёт выполняется внутри одной длинной задачи Task (Long Task) на Main Thread. Обработка события Click вынуждена ждать завершения этой задачи

В синхронном режиме весь алгоритм выполняется за 105,8 мс — это самый быстрый способ получить итоговый результат, поскольку отсутствуют любые накладные расходы на планирование задач.

Однако плата за это — блокировка интерфейса. Задержка ввода (Input Delay) напрямую зависит от момента нажатия кнопки пользователем:

Зависимость задержки ввода от момента команды. Чем ближе к концу расчёта поступает ввод, тем меньше ему приходится ждать. Три других способа сохраняют околонулевой Input Delay независимо от момента клика

Зависимость задержки ввода от момента команды. Чем ближе к концу расчёта поступает ввод, тем меньше ему приходится ждать. Три других способа сохраняют околонулевой Input Delay независимо от момента клика

  • Клик на 20-й миллисекунде работы → задержка 77 мс

  • Клик на 50-й миллисекунде → задержка 45,4 мс

  • Клик на 80-й миллисекунде → задержка 20,1 мс

Вся операция помечается браузером как одна большая Long Task, во время которой реактивность страницы падает до нуля.

2. setTimeout: отзывчивость ценой четырёхкратного замедления

Если заменить прямой цикл на дробление по 8192 записи с вызовом Await new Promise(r => setTimeout(r, 0)), интерфейс мгновенно становится отзывчивым — медиана Input Delay снижается до 0,3 мс.

Однако время получения полного результата увеличивается со 105,8 до 380 мс (в 3,6 раза). Почемс.у?

Передача управления через setTimeout(..., 0) не бесплатна. Согласно спецификации HTML, при глубокой вложенности таймеров (более пяти уровней) браузер автоматически применяет минимальный порог задержки в 4,5 мс. Для 500 тыс. строк с размером порции 8192 требуется 61 уступка управления. 61 * 4,5 мс ≈ 274 мс простоя Main Thread! Именно поэтому время до ответа выросло более чем в 3,6 раза. Если же уменьшить размер порции до 256 записей (что даёт 1953 уступки), суммарное время выполнения достигает катастрофических 9,65 с.

3. scheduler.yield(): эффективное компромиссное дробление

scheduler.yield() — это специализированный API для отдачи управления Event Loop с последующим продолжением задачи в приоритетном порядке.

Порции выполняются практически непрерывно. Браузер успевает вклинить обработку ввода, не создавая искусственных пауз в 4 мс

Порции выполняются практически непрерывно. Браузер успевает вклинить обработку ввода, не создавая искусственных пауз в 4 мс

При размере порции в 8192 записи scheduler.yield() продемонстрировал отличный баланс:

  • Время до результата: 125,1 мс (всего на 18% дольше по сравнению с синхронным вариантом и сравнительно быстрее, чем 380 мс у setTimeout)

  • Input Delay: 1,1 мс (интерфейс остаётся плавно работающим)

В отличие от таймеров, продолжение задачи через scheduler.yield() ставится в начало очереди текущего приоритета, что позволяет избежать искусственных задержек в 4 мс.

Практический фолбэк для эксплуатации

Так как API scheduler.yield() пока поддерживается не во всех браузерах (например, отсутствует в Safari), в реальных проектах рекомендуется предусмотреть безопасный фолбэк с использованием MessageChannel. Этот метод также позволяет обойти задержку в 4 мс:

function yieldToBrowser() {
  if (typeof globalThis.scheduler?.yield === 'function') {
    return globalThis.scheduler.yield();
  }
  return new Promise(resolve => {
    const channel = new MessageChannel();
    channel.port1.onmessage = resolve;
    channel.port2.postMessage(null);
  });
}

4. Web Worker: распараллеливание без блокировок Main Thread

Вынос алгоритма в Web Worker решает проблему кардинально — вычисления полностью уходят с дорожки Main Thread.

Длинный расчёт находится на дорожке Worker, внутри On Message. На Main в это время выполняются короткие задачи. Внизу выбран Worker, поэтому показанное там Scripting относится к нему

Длинный расчёт находится на дорожке Worker, внутри On Message. На Main в это время выполняются короткие задачи. Внизу выбран Worker, поэтому показанное там Scripting относится к нему

В нашем тесте использовался предварительно созданный и прогретый Worker. При запуске расчёта массив отправлялся через postMessage:

  • Медианное время до результата: 109,8 мс (практически сопоставимо с синхронным вариантом)

  • Input Delay: 0,3 мс (Main Thread абсолютно свободен)

О чём важно помнить при работе с Worker:

  1. Прогрев потока: создание new Worker() «на лету» под каждый запрос добавляет накладные расходы на инициализацию экземпляра и загрузку скрипта (~20–30 мс к первичному ответу). Потоки следует переиспользовать (Worker Pool).

  2. Сериализация: на нашем стенде передача исходного массива осуществлялась через postMessage с использованием TypedArrays (ArrayBuffer). Для обычных JSON-объектов затраты на сериализацию через Structured Clone могут быть выше.

  3. Изоляция: внутри Worker недоступен DOM API. Поток возвращает лишь «сырые» данные, а формирование элементов интерфейса остаётся задачей Main Thread.

Адаптивное квантование: как подбирать размер порции

Вместо прописывания в коде количества итераций (8192) рекомендуется использовать Time-Based Chunking (динамическую уступку по времени):

const TIME_BUDGET_MS = 8; // Квант работы не более 8 мс (пол-кадра при 60fps)

async function processDataAdaptively(dataset) {
  let lastYieldTime = performance.now();

  for (let i = 0; i < dataset.length; i++) {
    // Выполняем полезную работу
    processItem(dataset[i]);

    // Проверяем израсходованный лимит времени
    if (performance.now() - lastYieldTime > TIME_BUDGET_MS) {
      await yieldToBrowser();
      lastYieldTime = performance.now();
    }
  }
}

Ограничения замеров

В реальных условиях итоговая скорость зависит от множества факторов:

  • Нагрузка на GPU и процесс отрисовки Layout

  • Мобильные CPU с ограниченной однопоточной производительностью

  • Наличие конкурирующих задач от сторонних скриптов и аналитики

Архитектурный выбор: что использовать в проекте

Сценарий

Рекомендуемый подход

Почему

Короткий расчёт (< 30 мс)

Синхронно

Накладные расходы на асинхронность превысят выгоду

Итеративная работа с DOM/State

scheduler.yield()

Позволяет разбить задачу, сохраняя доступ к контексту главного потока

Тяжёлые вычисления без DOM (Math, парсинг)

Web Worker

Идеальное решение: сохраняет скорость синхронного кода и 100%-ную отзывчивость UI

Поддержка старых браузеров без Worker

MessageChannel

Обходит порог в 4 мс setTimeout

Заключение

Инженеры часто стремятся найти универсальное решение, однако данные показывают: «серебряной пули» не существует — есть информированный компромисс.

  1. Использование setTimeout(0) для разбивки задач приводит к значительным потерям производительности из-за 4-миллисекундного HTML Spec Clamp, замедляя вычисления в 3,6 раза

  2. scheduler.yield() становится новым стандартом для уступки управления на Main Thread, отдавая приоритет отклику интерфейса без заметного снижения общей скорости

  3. Web Worker остаётся оптимальным выбором для фоновых задач, полностью разгружая поток от вычислительной нагрузки

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.