Таймер в JavaScript: как работает и пример кода обратного отсчета

Таймер в JavaScript: как работает и пример кода обратного отсчета Полезное

Таймер в приложении — это механизм, который запускает код не сразу, а через заданное время (один раз) или повторяет его с заданным интервалом. В JavaScript для этого есть три инструмента: setTimeout (однократный запуск), setInterval (повтор) и requestAnimationFrame (запуск перед отрисовкой кадра в браузере). Задержка в них — минимальная, а не точная: функция выполнится не раньше указанного времени, но может позже.

Примеры ниже проверены в Node.js 24 LTS; браузерные фрагменты (DOM, requestAnimationFrame) помечены отдельно.

Мини-словарь

Термин Что это Пример
Задержка (delay) Время в миллисекундах до запуска, 1000 мс = 1 с setTimeout(fn, 1000)
Идентификатор таймера То, что возвращает вызов; нужен для отмены const id = setTimeout(...)
Интервал Период между запусками повторяющегося таймера setInterval(fn, 1000)
Цикл событий (event loop) Механизм, который берет готовые задачи из очереди, когда основной код закончил работу колбэк таймера ждет своей очереди

Функции таймеров не входят в стандарт ECMAScript: их дает среда выполнения (браузер по спецификации HTML, Node.js — свой модуль timers). Поэтому поведение в деталях различается, ниже это оговорено.

setTimeout и clearTimeout: запуск один раз и отмена

Синтаксис: setTimeout(функция, задержка, ...аргументы). Аргументы после задержки передаются в функцию. Вызов возвращает идентификатор, по которому clearTimeout(id) отменяет запуск.

function greet(name, punctuation) {
  console.log(`Привет, ${name}${punctuation}`);
}

const t1 = setTimeout(greet, 1000, "Анна", "!");   // через ~1 с
const t2 = setTimeout(greet, 500, "Борис", ".");   // через ~0,5 с

clearTimeout(t2);                                   // Борис не будет вызван
console.log("Таймеры запланированы");

Вывод:

Таймеры запланированы
Привет, Анна!

Строка «Таймеры запланированы» печатается первой: setTimeout не останавливает программу, а только ставит функцию в расписание. Второй таймер отменен до срабатывания, поэтому Борис не появляется.

Что важно знать про аргументы:

  • Задержку можно не указывать — тогда она равна 0. В Node.js задержка меньше 1 мс (и NaN) заменяется на 1 мс.
  • Задержка больше 2 147 483 647 мс (около 24,8 суток) не помещается в 32-битное число. В Node.js 24 такой таймер срабатывает почти сразу с предупреждением TimeoutOverflowWarning: ... Timeout duration was set to 1.
  • В браузере первым аргументом формально можно передать строку кода, но это работает как eval и блокируется политикой CSP. В Node.js 24 строка вызывает ошибку ERR_INVALID_ARG_TYPE. Передавайте функцию.
  • В браузере идентификатор — целое число, в Node.js — объект Timeout. Храните его как есть и передавайте в clearTimeout, не рассчитывайте на тип.

Потеря this в колбэке таймера

Частая ошибка — передать метод объекта без привязки. Неверно:

const counter = {
  label: "Счетчик",
  count: 0,
  tick() {
    this.count += 1;
    console.log(`${this.label}: ${this.count}`);
  },
};

setTimeout(counter.tick, 100);

Результат в Node.js 24:

undefined: NaN

Таймер вызывает функцию отдельно от объекта, поэтому this — уже не counter (в Node.js это объект Timeout, в браузере — window). Исправление — обернуть вызов стрелочной функцией или привязать контекст через bind:

setTimeout(() => counter.tick(), 100);        // вариант 1: стрелочная обертка
setTimeout(counter.tick.bind(counter), 200);  // вариант 2: bind

Вывод:

Счетчик: 1
Счетчик: 2

В старом коде для этого встречается $.proxy() из jQuery — сегодня его заменяет нативный bind или стрелочная функция.

setInterval и clearInterval: повтор с интервалом

setInterval принимает те же аргументы, но вызывает функцию повторно, пока интервал не остановят через clearInterval.

let ticks = 0;
const id = setInterval(() => {
  ticks += 1;
  console.log(`Тик ${ticks}`);
  if (ticks === 3) {
    clearInterval(id);
    console.log("Интервал остановлен");
  }
}, 1000);

Вывод (по строке в секунду):

Тик 1
Тик 2
Тик 3
Интервал остановлен

Забытый clearInterval — типичная утечка: пока интервал активен, функция и все переменные, на которые она ссылается, остаются в памяти, а в Node.js активный таймер еще и не дает процессу завершиться.

Вложенный setTimeout против setInterval

Повтор можно сделать и цепочкой: функция в конце планирует саму себя. Разница видна, если работа внутри колбэка занимает время. В тесте ниже каждый вызов «работает» 60 мс при интервале 100 мс:

function busy(ms) {               // имитация тяжелой работы, блокирует поток
  const end = Date.now() + ms;
  while (Date.now() < end) {}
}

const start = Date.now();
let n = 0;
const id = setInterval(() => {
  n += 1;
  console.log(`interval ${n}: ${Date.now() - start} мс`);
  busy(60);
  if (n === 4) clearInterval(id);
}, 100);

setTimeout(() => {
  const s2 = Date.now();
  let m = 0;
  function step() {
    m += 1;
    console.log(`nested ${m}: ${Date.now() - s2} мс`);
    busy(60);
    if (m < 4) setTimeout(step, 100);
  }
  setTimeout(step, 100);
}, 600);

Вывод одного прогона (миллисекунды на другой машине будут немного другими):

interval 1: 101 мс
interval 2: 202 мс
interval 3: 302 мс
interval 4: 403 мс
nested 1: 101 мс
nested 2: 262 мс
nested 3: 422 мс
nested 4: 583 мс
setInterval вложенный setTimeout
Что выдерживает 100 мс Период между стартами вызовов Паузу между концом вызова и следующим стартом
Пауза после работы 100 — 60 = 40 мс 100 мс
Если работа дольше интервала Вызовы идут подряд без паузы Пауза сохраняется
Менять задержку на ходу Нельзя, только пересоздать Можно на каждом шаге

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

Опрос сервера с увеличением паузы

Типичный сценарий вложенного таймера — повтор запроса с растущей паузой, если сервер перегружен. Здесь сервер имитирован, базовая пауза уменьшена до 100 мс для наглядности:

let calls = 0;
function fetchStatus() {           // первые три вызова падают
  calls += 1;
  return calls <= 3 ? Promise.reject(new Error("503")) : Promise.resolve("ok");
}

const MAX = 800;                   // потолок паузы, мс
let delay = 100;                   // в реальном сервисе, например, 5000
let attempts = 0;
const start = Date.now();

function poll() {
  attempts += 1;
  fetchStatus()
    .then((status) => console.log(`${Date.now() - start} мс: ответ ${status}`))
    .catch((err) => {
      if (attempts >= 6) return console.log("Лимит попыток исчерпан");
      console.log(`${Date.now() - start} мс: ошибка ${err.message}, пауза ${delay} мс`);
      setTimeout(poll, delay);
      delay = Math.min(delay * 2, MAX);
    });
}

poll();

Вывод одного прогона (миллисекунды меняются от запуска к запуску):

1 мс: ошибка 503, пауза 100 мс
112 мс: ошибка 503, пауза 200 мс
313 мс: ошибка 503, пауза 400 мс
713 мс: ответ ok

Потолок паузы и лимит попыток обязательны: без них при долгом сбое клиент будет ждать бесконечно.

Нулевая задержка и порядок выполнения

setTimeout(fn, 0) не запускает функцию немедленно. Колбэк попадает в очередь задач и выполнится после текущего синхронного кода и после микрозадач (обработчиков Promise):

console.log("1: синхронный код");
setTimeout(() => console.log("4: setTimeout 0"), 0);
Promise.resolve().then(() => console.log("3: микрозадача Promise"));
console.log("2: синхронный код");

Вывод:

1: синхронный код
2: синхронный код
3: микрозадача Promise
4: setTimeout 0

Задержка в браузере еще и ограничена снизу: по спецификации HTML после пяти уровней вложенных таймеров она не меньше 4 мс, а во вкладке в фоне браузеры обычно запускают таймеры не чаще раза в секунду. Chrome для вкладки, скрытой дольше 5 минут и без звука, ограничивает цепочки из 5 и более вложенных таймеров еще сильнее — до раза в минуту.

Пауза через await

Для «подождать N миллисекунд» внутри асинхронной функции таймер оборачивают в Promise. В Node.js есть готовый вариант в node:timers/promises (файл .mjs, ESM):

import { setTimeout as sleep } from "node:timers/promises";

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

console.log("Старт");
await wait(500);
console.log("Прошло ~0,5 с (Promise-обертка)");
await sleep(500);
console.log("Прошло еще ~0,5 с (node:timers/promises)");

Обертка wait работает и в браузере, импорт node:timers/promises — только в Node.js.

Пример: таймер обратного отсчета

Наивный отсчет уменьшает счетчик на 1 каждую секунду. Он «уплывает»: каждый вызов приходит чуть позже, а в фоновой вкладке таймеры замедляются. Надежнее хранить момент окончания (deadline) и на каждом тике считать остаток от текущего времени:

function formatRemaining(ms) {
  const total = Math.max(0, Math.ceil(ms / 1000));
  const m = String(Math.floor(total / 60)).padStart(2, "0");
  const s = String(total % 60).padStart(2, "0");
  return `${m}:${s}`;
}

function startCountdown(seconds, onTick, onDone) {
  if (!Number.isFinite(seconds) || seconds < 0) {
    throw new RangeError(`seconds: нужно число >= 0, получено ${seconds}`);
  }
  const deadline = Date.now() + seconds * 1000;
  let timerId = null;
  let stopped = false;

  function tick() {
    const left = deadline - Date.now();
    onTick(formatRemaining(left));
    if (stopped) return;                // stop() вызвали внутри onTick
    if (left <= 0) {
      onDone();
      return;
    }
    // следующий тик - к ближайшей границе секунды, а не ровно через 1000 мс
    timerId = setTimeout(tick, left % 1000 || 1000);
  }

  tick();
  return () => { stopped = true; clearTimeout(timerId); };   // функция остановки
}

console.log(formatRemaining(0), formatRemaining(1), formatRemaining(61000), formatRemaining(599001));
const stop = startCountdown(3, (text) => console.log(text), () => console.log("Время вышло"));

Вывод (строки отсчета — по одной в секунду):

00:00 00:01 01:01 10:00
00:03
00:02
00:01
00:00
Время вышло

Первая строка проверяет форматирование на границах: 0 мс, 1 мс (округляется вверх до секунды), 61 с и 599,001 с. startCountdown возвращает функцию stop — вызовите ее, чтобы прервать отсчет.

Две защиты в коде не для красоты. Без проверки входа startCountdown(NaN, ...) печатает NaN:NaN раз в секунду бесконечно: остаток NaN никогда не проходит условие left <= 0. Теперь NaN, строка "10" и отрицательное число сразу дают RangeError. Флаг stopped нужен, чтобы stop() работала и внутри onTick: без него clearTimeout отменял бы уже сработавший таймер, а следующий тик все равно планировался бы строкой ниже.

Чтобы вывести отсчет на страницу, те же функции подключаются к разметке (браузерный фрагмент):

<p id="clock">00:10</p>
<button id="go">Старт</button>
<script>
  // сюда вставить formatRemaining и startCountdown из примера выше
  const clock = document.getElementById("clock");
  let stop = null;
  document.getElementById("go").addEventListener("click", () => {
    if (stop) stop();                      // повторный клик перезапускает отсчет
    stop = startCountdown(10,
      (text) => { clock.textContent = text; },
      () => { clock.textContent = "Время вышло"; });
  });
</script>

textContent вместо innerHTML — чтобы в элемент не попала разметка.

requestAnimationFrame: таймер для анимации

requestAnimationFrame(callback) вызывает функцию один раз перед следующей отрисовкой кадра, передавая ей метку времени. Частота совпадает с частотой обновления экрана (часто 60 Гц, но бывает 120 Гц и выше), а в фоновой вкладке вызовы приостанавливаются. Поэтому скорость анимации считают от прошедшего времени, а не от числа кадров (браузерный фрагмент):

const box = document.getElementById("box");
let startTime = null;
let frameId = null;

function rotate(now) {
  if (startTime === null) startTime = now;
  const angle = ((now - startTime) / 1000) * 90;   // 90 градусов в секунду
  box.style.transform = `rotate(${angle}deg)`;
  frameId = requestAnimationFrame(rotate);
}

frameId = requestAnimationFrame(rotate);
// остановка: cancelAnimationFrame(frameId);

Какой механизм выбрать

Задача Что взять
Выполнить действие один раз через время setTimeout
Легкая периодическая работа (часы, опрос раз в минуту) setInterval
Повтор с долгим шагом или переменной паузой вложенный setTimeout
Анимация и изменение отрисовки requestAnimationFrame
Пауза в async-функции await + Promise-обертка над setTimeout
Точный отсчет до события deadline + Date.now() на каждом тике

Выводы

  • Таймер в приложении запускает код через заданное время или с интервалом; в JavaScript это setTimeout, setInterval и requestAnimationFrame, их предоставляет среда (браузер, Node.js).
  • Задержка — минимальная, а не гарантированная: колбэк ждет окончания текущего кода и микрозадач.
  • Каждый таймер нужно уметь остановить: сохраняйте идентификатор и вызывайте clearTimeout/clearInterval/cancelAnimationFrame.
  • Вложенный setTimeout держит паузу между вызовами и позволяет менять задержку, setInterval держит период между стартами.
  • Обратный отсчет считайте от момента окончания, а не уменьшением счетчика, иначе он будет отставать.

Где применяется / связь с практикой

Освойте тему на практике

Таймеры встречаются почти в каждом интерфейсе: автосохранение черновика, задержка перед поиском при вводе, повтор неудачного запроса, слайдеры, отсчет до конца акции или теста. Чтобы уверенно писать такой код, нужно понимать асинхронность и цикл событий целиком — это разбирают на курсе «JavaScript-разработчик. Базовый уровень». Попробовать формат обучения можно на бесплатных открытых уроках Otus.

FAQ

Можно ли остановить таймер изнутри его же колбэка?
Да. Для setInterval так и делают — вызывают clearInterval(id) внутри функции, как в примере выше. Для setTimeout отмена изнутри не нужна: он уже сработал; достаточно не планировать следующий вызов.

Почему таймер в фоновой вкладке «тормозит»?
Браузеры экономят ресурсы: в скрытой вкладке таймеры запускаются реже, а requestAnimationFrame приостанавливается. Поэтому время и остаток отсчета берут из Date.now() или performance.now(), а не из числа сработавших тиков.

Чем отличается clearTimeout от clearInterval?
В браузере оба работают с общим списком идентификаторов, и технически взаимозаменяемы, но для читаемости вызывайте парную функцию: clearTimeout для setTimeout, clearInterval для setInterval.

OTUS Журнал
Скидка 5% 14-20 сентября на курсы (popup)