Как опрашивать API, не перегружая сервер
Создайте надежный API-поллер: один запрос за раз, отмена, экспоненциальные повторы с джиттером, обработка Retry-After, пауза в скрытых вкладках и ETag.
Надёжный поллер для продакшена отправляет следующий запрос только после обработки предыдущего ответа, останавливается, когда задача переходит в терминальное состояние, при ошибках увеличивает интервал повтора с джиттером, соблюдает Retry-After для ответов 429 и 503, ничего не делает, пока вкладка скрыта, и использует ETag, чтобы неизменившиеся данные возвращались пустым ответом 304.
Большинство поллеров начинается с setInterval и fetch. Такой вариант работает, пока эндпоинт не начнёт тормозить, API не станет возвращать 429 или кто-нибудь не оставит вкладку открытой на выходные, и в понедельник она всё ещё будет слать запросы.
Если сервер умеет сам отправлять обновления, поллинг может и не понадобиться. Сравните WebSockets, SSE и long polling или рассмотрите вебхуки вместо поллинга для взаимодействия между серверами. Если же поллинг — подходящий инструмент, в этой статье мы шаг за шагом соберём поллер без зависимостей, исправляя по одной проблеме за раз. В каждом разделе сначала показан неработающий вариант, а затем исправление.
Ключевые выводы
- Если запускать
setTimeoutпосле каждого ответа, одновременно выполняется только один запрос.setIntervalэтого не гарантирует. - При full jitter задержка каждого повтора равна
random(0, min(cap, base * 2 ** attempt)), поэтому повторяющие запросы клиенты распределяются во времени, а не обрушиваются на восстанавливающийся сервер одновременно. Retry-Afterбывает в двух формах: число секунд задержки или HTTP-дата. Поллер должен разбирать обе и переходить на backoff, если заголовок отсутствует или не распознаётся.- Браузеры троттлят таймеры в фоновых вкладках, но троттлинг лишь замедляет поллер. Остановить его по-настоящему позволяет проверка
document.hidden. - Если передавать последний
ETagвIf-None-Match, сервер может ответить304 Not Modifiedбез тела, когда ничего не изменилось.
Какие четыре ошибки чаще всего допускают при поллинге API?
Наивный поллинг API обычно ломается четырьмя способами: запросы накладываются друг на друга, опрос никогда не прекращается, при сбоях поллер нагружает сервер ещё сильнее, а скрытые вкладки продолжают опрашивать. Все четыре проблемы присутствуют в этом фрагменте:
setInterval(async () => {
const res = await fetch('/api/jobs/42');
render(await res.json());
}, 2000); // overlaps when slow, never stops, ignores errors and hidden tabs
- Наложение запросов.
setIntervalсрабатывает по фиксированному расписанию, а не после получения предыдущего ответа. Когда эндпоинт отвечает дольше интервала, запросы начинают накапливаться. - Нет выхода. Ничто не останавливает цикл после завершения задачи, поэтому он продолжает запрашивать результат, который уже никогда не изменится.
- Нет backoff. Когда сервер возвращает 500, все клиенты продолжают повторять запросы на полной скорости, добавляя нагрузку именно тогда, когда сервер меньше всего способен с ней справиться.
- Скрытые вкладки. Вкладка, на которую никто не смотрит, продолжает опрос.
Как выстроить цепочку setTimeout и остановиться по условию завершения?
Если планировать следующий опрос только после обработки текущего ответа, одновременно выполняется не более одного запроса. Это обеспечивает async-цикл с паузой между итерациями, причём он завершается, как только задача переходит в терминальное состояние:
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function pollJob(url, isDone, interval = 2000) {
while (true) {
const res = await fetch(url);
const data = await res.json();
render(data);
if (isDone(data)) return data;
await sleep(interval);
}
}
pollJob('/api/jobs/42', (job) => job.status === 'done' || job.status === 'failed');
Условие завершения должно учитывать не только успех, но и терминальные ошибки. Если проверять только 'done', задача, завершившаяся со статусом 'failed', будет опрашиваться бесконечно.
Как отменить поллер с помощью AbortController?
Отмена поллера означает остановку двух вещей: выполняющегося запроса и ожидающего таймера. Сигнал AbortController, переданный в fetch, отменяет запрос, но никак не влияет на уже запланированный setTimeout. Поэтому функция паузы тоже должна слушать этот сигнал:
function sleep(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const onAbort = () => {
clearTimeout(id);
reject(signal.reason);
};
const id = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
signal?.addEventListener('abort', onAbort, { once: true });
});
}
Вызов controller.abort() отклоняет тот промис, который в данный момент ожидает, — fetch или sleep, — и цикл завершается. Обработчик abort удаляется при срабатывании таймера, поэтому длительный опрос, использующий один и тот же сигнал, не добавляет новый обработчик на каждой итерации.
Как реализовать экспоненциальный backoff с джиттером и верхним пределом?
При неудачном запросе подождите random(0, min(cap, base * 2 ** attempt)), прежде чем повторить попытку. Это стратегия «full jitter» из анализа Exponential Backoff and Jitter от AWS. Экспоненциальный backoff с ограничением, но без случайности, всё равно оставляет клиентов, повторяющих запросы синхронными группами. Случайный выбор каждой задержки в диапазоне от нуля до экспоненциального потолка разбивает эти группы.
const backoff = (attempt, base = 1000, cap = 30000) =>
Math.random() * Math.min(cap, base * 2 ** attempt);
Эта однострочная функция backoff соответствует функции full jitter в исходном коде симулятора backoff от AWS. В симуляции AWS 2015 года, где 100 клиентов конкурировали за один ресурс, добавление джиттера сократило общее число вызовов более чем вдвое. Учтите, что симуляция моделировала конкуренцию при записи, а не поллинг на чтение, поэтому воспринимайте эту цифру как результат AWS для конкретного сценария.
В цикле увеличивайте attempt после каждой неудачи и сбрасывайте его в 0 после любого успешного ответа. Без сброса единичный сбой в начале приведёт к тому, что каждая последующая ошибка будет начинаться с большой задержки. Серверные воркеры сталкиваются с той же проблемой повторов, и очереди задач решают её с помощью похожих политик повторов.
Как поллеру обрабатывать 429, 503 и Retry-After?
Когда сервер отправляет ответ 429 или 503 с заголовком Retry-After, ждите столько, сколько указано в заголовке. Собственный backoff нужен как запасной вариант на случай, если заголовок отсутствует. Заголовок Retry-After имеет одну из двух форм: целое число секунд ожидания после получения ответа или HTTP-дата, после которой можно повторить запрос. Обе формы определены в RFC 9110, а MDN описывает его использование с 429 Too Many Requests.
const MAX_RETRY_AFTER = 5 * 60_000; // design choice, not a spec rule
function retryAfterMs(res) {
const value = res.headers.get('Retry-After');
if (!value) return null;
if (/^\d+$/.test(value.trim())) return Math.min(Number(value) * 1000, MAX_RETRY_AFTER);
const date = Date.parse(value);
if (Number.isNaN(date)) return null;
return Math.min(Math.max(0, date - Date.now()), MAX_RETRY_AFTER);
}
Дата сравнивается с часами клиента. Если часы расходятся и дата уже в прошлом, задержка ограничивается нулём. Если Date.parse возвращает NaN, функция возвращает null, и вызывающий код использует backoff. Верхнее ограничение не позволяет некорректно настроенному заголовку «припарковать» поллер на несколько часов.
При кросс-доменных запросах JavaScript может прочитать Retry-After, только если сервер указал его в Access-Control-Expose-Headers, поскольку он не входит в число CORS-safelisted заголовков ответа. Если заголовок не открыт, res.headers.get() возвращает null, и поллер без каких-либо ошибок переходит на обычный backoff.
Как приостановить поллинг, когда вкладка скрыта?
Браузеры троттлят таймеры в фоновых вкладках, но затроттленный поллер всё равно отправляет запросы, просто реже. Чтобы действительно остановить его, проверяйте document.hidden и слушайте событие visibilitychange — оба входят в Page Visibility API. Эта вспомогательная функция сразу разрешается, если страница видима, а иначе ждёт, пока она не станет видимой:
function whenVisible(signal) {
if (signal?.aborted) return Promise.reject(signal.reason);
if (!document.hidden) return Promise.resolve();
return new Promise((resolve, reject) => {
const done = () => {
document.removeEventListener('visibilitychange', onChange);
signal?.removeEventListener('abort', onAbort);
};
const onChange = () => { if (!document.hidden) { done(); resolve(); } };
const onAbort = () => { done(); reject(signal.reason); };
document.addEventListener('visibilitychange', onChange);
signal?.addEventListener('abort', onAbort, { once: true });
});
}
Если вызывать её через await в начале каждой итерации, скрытая вкладка не отправляет запросов, а первый запрос после возвращения пользователя уходит сразу. Одно ограничение: если в момент, когда вкладка становится видимой, уже идёт долгая пауза backoff, поллер сначала дождётся её окончания.
Используйте ETag и соберите всё вместе
Если отправлять последний ETag обратно в заголовке If-None-Match, сервер может ответить 304 Not Modified без тела, если ресурс не изменился. Когда вы сами устанавливаете If-None-Match при режиме кеширования по умолчанию, согласно спецификации Fetch HTTP-кеш пропускается, поэтому ваш код получает 304 напрямую и должен его обработать. При кросс-доменных запросах важны две детали CORS. Сервер должен открыть ETag. А поскольку If-None-Match не входит в число CORS-safelisted заголовков запроса, его отправка вызывает preflight-запрос, поэтому сервер также должен указать его в Access-Control-Allow-Headers.
В таблице показано, как готовый поллер реагирует на каждый исход (значения кодов статуса приведены в справочнике по HTTP-кодам по ссылке выше):
| Ответ | Действие поллера | Следующая задержка |
|---|---|---|
| 200 | Обновить UI, проверить завершение, сбросить попытки | interval |
| 304 | Без обновления, сбросить попытки | interval |
| 429 / 503 | Увеличить счётчик попыток | Retry-After, иначе backoff |
| Прочие 5xx / сетевая ошибка | Увеличить счётчик попыток | Backoff |
| Прочие 4xx | Выбросить исключение и показать ошибку | Нет |
| Отмена (abort) | Пробросить исключение и выйти | Нет |
Функция ниже использует sleep, backoff, retryAfterMs и whenVisible из предыдущих разделов. Ветвление по статусу вынесено за пределы try/catch, поэтому ответ 4xx никогда не будет принят за сетевую ошибку и не будет повторён.
async function poll(url, { isDone = () => false, onUpdate, signal, interval = 2000, maxAttempts = 8 } = {}) {
let etag = null;
let attempt = 0;
while (true) {
await whenVisible(signal);
let res = null;
try {
res = await fetch(url, { signal, headers: etag ? { 'If-None-Match': etag } : {} });
} catch (err) {
if (signal?.aborted) throw err; // otherwise: network error, res stays null
}
let wait = interval;
if (res?.status === 304) {
attempt = 0; // no body: never call res.json() here
} else if (res?.ok) {
attempt = 0;
etag = res.headers.get('ETag'); // null if absent or not exposed
const data = await res.json();
onUpdate?.(data);
if (isDone(data)) return data;
} else if (!res || res.status === 429 || res.status >= 500) {
if (++attempt > maxAttempts) throw new Error('Polling gave up');
wait = (res && retryAfterMs(res)) ?? backoff(attempt);
} else {
throw new Error(`Polling failed: HTTP ${res.status}`);
}
await sleep(wait, signal);
}
}
Поскольку используется префиксный инкремент attempt, первый повтор уже выполняется с attempt = 1, что ограничивает его задержку двумя секундами.
Оборачиваем в React-хук
import { useEffect, useState } from 'react';
function usePoll(url, isDone) {
const [state, setState] = useState({ status: 'polling', data: null });
useEffect(() => {
const controller = new AbortController();
poll(url, { isDone, signal: controller.signal, onUpdate: (data) => setState({ status: 'polling', data }) })
.then((data) => setState({ status: 'done', data }))
.catch((error) => { if (!controller.signal.aborted) setState({ status: 'error', error }); });
return () => controller.abort();
}, [url]);
return state;
}
React выполняет очистку эффекта при размонтировании и перед повторным запуском эффекта при изменении url, поэтому старый поллер всегда отменяется. Определяйте isDone вне компонента, чтобы ссылка на функцию оставалась стабильной. Статус 'error' нужен, чтобы UI мог показать состояние вроде «Мы перестали проверять. Повторить?». В записях пользовательских сессий (session replay) поллеров, которые не отображают своё состояние, часто виден бесконечно крутящийся спиннер, хотя сетевые запросы под ним уже прекратились или продолжают завершаться ошибкой.
Заключение
Поллер, который не перегружает сервер, сводится к нескольким правилам: один запрос за раз, чёткое условие выхода, backoff с джиттером и сбросом после успеха, уважение к Retry-After, отсутствие запросов из скрытых вкладок и условные запросы, благодаря которым неизменившиеся данные почти ничего не стоят. Замените свой setInterval функцией poll, приведённой выше, а затем убедитесь, что сервер возвращает ETag и Retry-After и открывает оба заголовка для кросс-доменных клиентов. Без такой поддержки на стороне сервера клиентский код молча переходит на обычный backoff и полные ответы.
Часто задаваемые вопросы
Приостанавливается ли поллер, когда пользователь переключается на окно другого приложения?
Не всегда. Страница становится скрытой, когда её вкладка уходит в фон, окно браузера сворачивается или срабатывает блокировка экрана ОС. Страница, которая остаётся частично видимой на экране, сохраняет visibilityState со значением 'visible', а потеря фокуса в пользу другого окна не делает её скрытой. В этом случае поллер продолжает работать. Слушайте события blur и focus, если хотите приостанавливать опрос и при потере фокуса окном.
Чем отличаются full jitter, equal jitter и decorrelated jitter?
Каждый из них по-своему вносит случайность в экспоненциальный backoff с ограничением. Full jitter выбирает задержку от нуля до min(cap, base * 2 ** attempt). Equal jitter фиксирует половину этого потолка и рандомизирует вторую половину. Decorrelated jitter выбирает задержку от base до утроенной предыдущей задержки с учётом ограничения. В симуляции AWS equal jitter оказался самым слабым из трёх подходов с джиттером, а full jitter выполнил меньше работы, чем decorrelated jitter, но занял чуть больше времени.
Можно ли использовать этот поллер в Node.js?
Да, с одним изменением. В Node.js 18 и новее есть глобальный fetch, ставший стабильным в Node.js 21, и глобальный AbortController. В Node.js нет объекта document, поэтому whenVisible выбросит ReferenceError. Добавьте проверку typeof document, которая сразу разрешает промис вне браузера. Обработка backoff, Retry-After и ETag работает без изменений. Node.js не применяет CORS, поэтому все заголовки ответа доступны для чтения.
Почему fetch никогда не возвращает 304, если я сам не устанавливаю If-None-Match?
HTTP-кеш браузера выполняет ревалидацию самостоятельно. Когда сохранённый ответ требует ревалидации, браузер сам добавляет условные заголовки. Если сервер отвечает 304, fetch отдаёт вашему коду закешированный ответ со статусом 200 и полным телом, поэтому вы не можете определить, что запрос был условным. Именно ручная установка If-None-Match делает ответ 304 видимым для JavaScript.