Строка setInterval(update, 1000) читается как «выполняй update раз в секунду». На самом деле обещание другое и куда более скромное: «ставь update в очередь раз в секунду». Пока функция дешёвая и синхронная, разницы не видно. Как только внутри появляется запрос к серверу, различие становится причиной странных багов: данные мигают, устаревший ответ перетирает свежий, а на бэкенде вместо одного запроса в секунду копится пачка.

Представь конвейер, который выкладывает деталь каждую секунду, не глядя на рабочего. Если деталь обрабатывается за полсекунды — всё в порядке. Если за три — вокруг рабочего вырастает гора необработанных деталей, а конвейер продолжает подавать: он про рабочего ничего не знает.

Дальше — что именно ломается (с замерами в Chrome), почему это происходит и какой паттерн ставить вместо интервала.

Что setInterval обещает на самом деле

setInterval регистрирует таймер и возвращает целочисленный идентификатор — его нужно сохранить, иначе остановить интервал будет нечем. Мелочь, о которой мало кто знает: пул идентификаторов у setInterval и setTimeout общий, так что clearTimeout технически отменит интервал. Однако делать так не стоит — читаемость кода дороже.

Когда таймер срабатывает, браузер не врывается в выполняющийся код. Он кладёт колбэк в очередь макрозадач, и событийный цикл возьмёт его оттуда только когда стек вызовов освободится. Механику очередей мы подробно разбирали в статье про однопоточность в JavaScript — здесь важно одно следствие: таймер управляет не моментом выполнения, а моментом постановки в очередь.

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

let prevEnd = performance.now();

const timerId = setInterval(() => {
  const pause = performance.now() - prevEnd;
  console.log(`пауза с прошлого раза: ${pause.toFixed(0)} мс`);

  const busyUntil = performance.now() + 120;   // держим поток занятым
  while (performance.now() < busyUntil) {}

  prevEnd = performance.now();
}, 200);

// остановить: clearInterval(timerId)

В консоли будет не 200 мс, а примерно 80: интервал 200 мс минус 120 мс работы. Период между началами вызовов при этом честные 200 мс — браузер вычитает время работы колбэка из паузы. Именно из этой арифметики разработчики часто попадают в так называемые "ловушки".

Три ловушки фиксированного интервала

Асинхронная работа накладывается сама на себя

Классический сценарий: опрашиваем сервер раз в секунду, а запрос идёт три секунды. Интервал про запрос ничего не знает — он отправит следующий, потом ещё один, и в воздухе окажется сразу несколько незакрытых запросов.

let inFlight = 0;

setInterval(async () => {
  inFlight++;
  console.log('в полёте:', inFlight);

  const res = await fetch('/api/orders');
  render(await res.json());

  inFlight--;
}, 1000);

Проверить масштаб проще на имитации: если «запрос» занимает 300 мс, а интервал стоит 100 мс, в замере одновременно в полёте оказывается до четырёх запросов. На каждого живого пользователя это вчетверо больше нагрузки на сервер и вчетверо больше трафика мобильного клиента — при том, что полезных ответов ему нужно ровно столько же.

Вторая, менее очевидная часть проблемы — гонка ответов. Сеть не гарантирует порядок: ответ на четвёртый запрос может прийти раньше ответа на третий, и тогда render сначала покажет свежие данные, а через мгновение затрёт их устаревшими. Баг выглядит как «иногда список моргает и показывает старое» и ловится крайне неприятно. Никакая обработка ошибок здесь не поможет: с точки зрения кода оба ответа успешные. Про сами способы отправки запросов — в обзоре HTTP-запросов в JavaScript.

// то, что реально происходит на слабой сети
// t=0     запрос #1 ушёл
// t=1000  запрос #2 ушёл
// t=1400  пришёл ответ #2 → в UI свежие данные
// t=1600  пришёл ответ #1 → в UI снова старые данные

Панель Network показывает перекрывающиеся запросы, отправленные setInterval

Пауза между вызовами короче, чем в коде

Из арифметики выше следует неприятное: чем тяжелее колбэк, тем меньше поток отдыхает. При интервале 200 мс и работе 120 мс на всё остальное (обработка кликов, отрисовка, скролл) остаётся 80 мс из каждых 200.

А если колбэк работает дольше интервала, пауза исчезает совсем. В замере с интервалом 100 мс и работой 250 мс период между вызовами стал ровно 250 мс: вызовы идут вплотную, без единого промежутка. Хорошая новость: собственные колбэки setInterval в очереди не копятся — браузер держит не больше одного ожидающего. Плохая: интервал перестал быть интервалом, а страница получила бесконечную загруженность потока.

const starts = [];

const timerId = setInterval(() => {
  starts.push(performance.now());

  const busyUntil = performance.now() + 250;   // работы больше, чем интервал
  while (performance.now() < busyUntil) {}

  if (starts.length === 5) {
    clearInterval(timerId);
    // периоды между вызовами: [250, 250, 250, 250] вместо [100, 100, 100, 100]
    console.log(starts.slice(1).map((t, i) => Math.round(t - starts[i])));
  }
}, 100);

Тики — это не часы

Соблазнительно посчитать время по количеству тиков: раз колбэк вызывается раз в секунду, то на сотом вызове прошло сто секунд. На практике это неправда, и не только из-за тяжёлых колбэков.

Браузеры сознательно тормозят таймеры в фоне. Firefox в неактивной вкладке даёт минимум 1 секунду (исключение — вкладка со звуком через AudioContext), Firefox для Android — до 15 минут. Chrome с версии 88 применяет усиленное торможение: если вкладка невидима дольше пяти минут и молчит дольше 30 секунд, таймеры с уровнем вложенности от пяти проверяются раз в минуту. Есть и обязательный минимум: после пяти вложенных таймеров интервал меньше 4 мс поднимается до 4 мс — это уже требование спецификации, а не политика конкретного браузера.

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

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

// так врёт: 60 тиков ≠ 60 секунд
let left = 60;
setInterval(() => {
  left--;
  label.textContent = `${left} с`;
}, 1000);

// так честно: тик отвечает только за перерисовку
const deadline = Date.now() + 60_000;
const tickId = setInterval(() => {
  const left = Math.max(0, Math.round((deadline - Date.now()) / 1000));
  label.textContent = `${left} с`;
  if (left === 0) clearInterval(tickId);
}, 1000);

Во втором варианте вкладка может проспать в фоне сколько угодно — вернувшись, таймер сразу покажет правильное значение, а не отставание на все пропущенные тики.

Забытый интервал переживёт свой код

Интервал не заканчивается сам. Пока его не отменили, движок держит ссылку на колбэк, а колбэк — на всё, что читает из окружения: переменные, DOM-узлы, данные ответа. Это одна из самых частых причин утечек, подробнее — в разборе утечек памяти в JavaScript. Правило: на каждый setInterval в живом коде есть парный clearInterval, а в компонентах он вызывается на размонтировании (onUnmounted во Vue, функция возврата из useEffect в React, ngOnDestroy в Angular).

Отдельный сюрприз ждёт тех, кто надеется, что упавший колбэк остановит интервал. Не остановит: каждый вызов — отдельная задача, исключение из неё улетает в консоль как необработанная ошибка, а таймер живёт дальше. В замере колбэк с интервалом 50 мс падал 12 раз за 600 мс, и таймер был по-прежнему активен. То есть сломанный опрос будет молотить по серверу до закрытия вкладки.

У замены на цепочку таймаутов (о ней ниже) обратная беда: там исключение убивает всё, потому что следующий шаг просто не успевает запланироваться. В том же замере цепочка отработала ровно один раз и тихо умерла. Лечится планированием в finally:

function schedule() {
  setTimeout(async () => {
    try {
      await poll();
    } catch (err) {
      console.warn('опрос не удался, продолжаем', err);
    } finally {
      schedule();     // следующий шаг планируем всегда
    }
  }, 1000);
}

Чем заменить: цепочка setTimeout

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

const sleep = (ms) => new Promise((res) => setTimeout(res, ms));

async function pollOrders(signal, pause = 1000) {
  while (!signal.aborted) {
    try {
      const res = await fetch('/api/orders', {
        signal: AbortSignal.timeout(5000),
      });
      render(await res.json());
    } catch (err) {
      console.warn('пропускаем цикл:', err.name);
    }
    await sleep(pause);
  }
}

const controller = new AbortController();
pollOrders(controller.signal);

// когда экран закрыли
controller.abort();

Одна честная оговорка про такую отмену: она не мгновенная. Цикл проверяет signal.aborted только после текущей паузы, то есть опрос может прекратиться с задержкой до одного шага. Для интерфейса этого обычно достаточно, а если нужно обрывать сразу — паузу тоже делают прерываемой, по событию abort на сигнале.

Обрати внимание на AbortSignal.timeout в опциях fetch — это не украшение. У интервала есть кривая, но страховка: зависший запрос не мешает следующему тику. У цепочки такой страховки нет — один запрос, который висит без ответа, останавливает весь опрос навсегда. Таймаут на запрос закрывает эту дыру: по истечении срока сигнал прерывает fetch исключением с именем TimeoutError, цикл ловит его и идёт на следующую итерацию. В Chrome до 124-й версии тот же случай прерывался с именем AbortError, так что если поведение зависит от имени ошибки — проверяй оба.

Поддержка браузерами
chrome
Chrome
124
firefox
Firefox
100
edge
Edge
124
safari
Safari
16
opera
Opera
110

Раз пауза теперь наша, ею можно управлять. Самое полезное — растягивать её после ошибок, чтобы не добивать уже упавший сервер, и сбрасывать после успеха:

let pause = 1000;

try {
  await loadOnce();
  pause = 1000;                          // всё хорошо — вернулись к базовой паузе
} catch {
  pause = Math.min(pause * 2, 30_000);   // плохо — отступаем, но не дольше 30 с
}
await sleep(pause);

Заодно стоит не опрашивать невидимую вкладку вообще: браузер всё равно затормозит таймеры, а пользователю эти данные сейчас не нужны. Останавливаем опрос по visibilitychange и делаем один свежий запрос при возврате — это дешевле и честнее, чем полагаться на троттлинг.

Когда setInterval всё-таки уместен

Интервал — не антипаттерн, у него просто узкая область применения: дешёвый синхронный колбэк, к точности которого нет требований. Ниже — ориентир по типовым задачам.

Задача Инструмент Почему
Перерисовать таймер или «был онлайн 5 минут назад» setInterval Колбэк дешёвый и синхронный, значение всё равно считается от Date.now
Анимация, движение, плавные переходы requestAnimationFrame Синхронизируется с кадром браузера и сам замирает в фоне
Опрос сервера, любые запросы по расписанию Цепочка setTimeout с await Пауза гарантирована, наложений и гонок ответов нет
Данные, которые нужны сразу при изменении WebSocket или Server-Sent Events Сервер сам присылает событие, опрос не нужен вообще
Тяжёлые расчёты по расписанию Web Worker Свой поток, основной не блокируется независимо от длительности

Коротко

  • setInterval обещает расписание постановки в очередь, а не завершение работы.
  • Внутри интервала не должно быть fetch и другой асинхронной работы: вызовы накладываются, ответы приходят вперемешку.
  • Пауза между вызовами короче, чем в коде, ровно на время работы колбэка. Если колбэк дольше интервала — паузы нет вообще.
  • Считать время по тикам нельзя: в фоне браузер тормозит таймеры вплоть до одного срабатывания в минуту. Только Date.now и целевой момент.
  • Упавший колбэк интервал не остановит, а цепочку таймаутов остановит. Первое лечится clearInterval, второе — планированием в finally.
  • Замена по умолчанию для сетевых задач — цепочка setTimeout с await, таймаутом на запрос и отменой через AbortController.