Эти API кажутся удобными для таймаутов и отмены, но в production под нагрузкой они порождают неочевидные баги: двойные вызовы обработчиков, утечки памяти и потерю контроля над ресурсами. Особенно это заметно в Node.js сервисах с высокой конкуренцией запросов или в SPA с частыми опросами.
Классический таймаут: скрытая утечка
async function fetchWithTimeout(url, timeoutMs) {
const signal = AbortSignal.timeout(timeoutMs);
return fetch(url, { signal });
}Запрос завершился за 50 мс, а таймаут на 5000 мс ещё активен. В Node 18+ сигнал очищается автоматически, но в браузерах, особенно Safari, слушатель
abort может висеть до срабатывания. При 1000 таких вызовов — привет, рост памяти и недетерминированные гонки при отписке.AbortController.any: двойной abort
const combined = AbortController.any([
controller.signal,
AbortSignal.timeout(5000)
]);
Первый сигнал сработал, создался composite signal. Если второй догоняет до фактической отписки — по спецификации флаг
aborted должен быть один, но на практике реализации в разных средах (Edge, Safari) кидают событие abort дважды. Обработчик отрабатывает два раза: гонка в бизнес-логике и лишний вызов колбэка.Production-практика: контроль через ручной таймер
Для горячих путей (частые запросы, опросы графиков) лучше завести один
AbortController и таймер через setTimeout с явным reject:const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const result = await fetch(url, { signal: controller.signal });
return result;
} finally {
clearTimeout(timer);
}
Так вы сами управляете жизненным циклом таймера и избегаете лишних слушателей. Минус — больше кода, плюс — предсказуемое поведение без гонок.
Вывод: Иногда старый
setTimeout с ручной очисткой оказывается надёжнее встроенного AbortSignal.timeout, особенно под нагрузкой с частыми вызовами и конкурентными таймаутами.