По умолчанию у fetch нет ни таймаута, ни кнопки «отмени». Запрос ушёл — и висит, пока сервер не ответит или браузер не сдастся (а сдастся он минут через пять). Для боевого фронта это никуда не годится. Решает обе задачи одна штука — AbortController.
Зачем отменять запросы
Классический случай — автокомплит в поисковой строке. Пользователь печатает «f» — ушёл запрос. Печатает «fr» — ушёл второй. «fru» — третий. «frun» — четвёртый. К моменту, когда пользователь дописал «fruntend», по сети летят восемь параллельных запросов, и ответы могут вернуться в произвольном порядке.
Если каждый ответ пишет результаты в UI, может произойти неприятное: ответ на старый «f» пришёл последним — и переписал результаты для свежего «fruntend». Пользователь видит подсказки на букву «f» и недоумевает.
Правильное решение — отменять предыдущий запрос, когда стартует новый. Чтобы старые ответы вообще не возвращались.
Базовый AbortController
const controller = new AbortController();
fetch('/api/search?q=fruntend', { signal: controller.signal })
.then(response => response.json())
.then(render)
.catch(error => {
if (error.name === 'AbortError') return; // запрос мы сами отменили — это норма
throw error;
});
// Где-то позже, например при следующем нажатии клавиши:
controller.abort();
Что происходит. Создали AbortController — у него есть свойство .signal и метод .abort(). Передали signal в fetch. Когда зовём controller.abort() — запрос отменяется, Promise отвергается с AbortError. В catch такую ошибку отличаем по error.name и игнорируем (это не настоящая ошибка — мы сами попросили).
Полный пример с автокомплитом
let currentController = null;
input.addEventListener('input', async () => {
// Отменяем предыдущий запрос, если он ещё жив:
if (currentController) currentController.abort();
currentController = new AbortController();
try {
const params = new URLSearchParams({ q: input.value });
const response = await fetch(`/api/search?${params}`, {
signal: currentController.signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const items = await response.json();
renderResults(items);
} catch (error) {
if (error.name !== 'AbortError') showError(error.message);
}
});
Теперь, сколько бы букв пользователь ни набил, в UI попадёт результат только последнего запроса. Остальные молча оборвутся.
Таймаут
Тот же AbortController делает и таймаут. Просто запускаем setTimeout, который через N миллисекунд позовёт .abort():
function fetchWithTimeout(url, options = {}, ms = 5000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
return fetch(url, { ...options, signal: controller.signal })
.finally(() => clearTimeout(timer));
}
// Использование:
try {
const response = await fetchWithTimeout('/api/slow', {}, 3000);
// ...
} catch (error) {
if (error.name === 'AbortError') {
showError('Сервер не отвечает дольше 3 секунд');
} else {
showError('Ошибка сети: ' + error.message);
}
}
Короче: AbortSignal.timeout
В современных браузерах есть встроенный сахар — не нужно собирать setTimeout руками:
fetch('/api/slow', {
signal: AbortSignal.timeout(3000), // отменить через 3 секунды
});
Если нужно одновременно и таймаут, и ручная отмена — есть AbortSignal.any([signal1, signal2]): сработает, как только сработает любой из них.
const userController = new AbortController();
fetch(url, {
signal: AbortSignal.any([
userController.signal,
AbortSignal.timeout(5000),
]),
});
AbortController сам работает с 2017 года везде. AbortSignal.timeout и AbortSignal.any появились позже — в безопасных версиях с примерно 2022 года.
Когда отменять обязательно
- Автокомплит, поиск, фильтры с задержкой.
- Размонтирование компонента, который сделал запрос (React, Vue) — чтобы не пытаться setState на удалённом компоненте.
- Долгие запросы, которые пользователь может захотеть прервать (большая выгрузка, AI-генерация).
- Любые запросы без таймаута, если за SLA сервера ручаться нельзя.