Строка 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 снова старые данные

Пауза между вызовами короче, чем в коде
Из арифметики выше следует неприятное: чем тяжелее колбэк, тем меньше поток отдыхает. При интервале 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, так что если поведение зависит от имени ошибки — проверяй оба.
Раз пауза теперь наша, ею можно управлять. Самое полезное — растягивать её после ошибок, чтобы не добивать уже упавший сервер, и сбрасывать после успеха:
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.
Комментарии (0)