AbortController в Node.js: отмена чего угодно
- вторник, 24 марта 2026 г. в 00:00:02
Привет, Хабр!
У Node.js исторически была проблема с отменой операций. Запустил HTTP‑запрос — жди, пока не ответит или не упадёт по таймауту. Читаешь огромный файл — читай до конца. Запустил пачку промисов — сиди, смотри, как они доедают ресурсы. Механизма сказать «стоп, хватит» в языке просто не было. Кто‑то мастерил свои костыли на флагах, кто‑то использовал библиотеки вроде p-cancelable, но единого стандарта не существовало.
AbortController эту проблему решает. Пришёл он из браузерного API (там его придумали для отмены fetch), но в Node.js прижился настолько, что теперь поддерживается почти везде: fetch, fs, stream, child_process, setTimeout, EventEmitter, встроенный тест‑раннер.
Разберём, как он устроен, и где вообще полезен.
Вся идея укладывается в два объекта.
AbortController — тот, кто отменяет. AbortSignal — тот, кто слушает отмену. Контроллер создаёт сигнал, вы передаёте сигнал в операцию, а когда нужно отменить — дёргаете abort() на контроллере.
const controller = new AbortController(); const signal = controller.signal; // Подписываемся на отмену signal.addEventListener('abort', () => { console.log('Отменено!'); console.log(signal.reason); // причина отмены }); console.log(signal.aborted); // false — пока не отменён controller.abort('Пользователь нажал Отмена'); // → Отменено! // → Пользователь нажал Отмена console.log(signal.aborted); // true — уже отменён
Контроллер одноразовый. Один abort() — и всё. Повторно вызвать можно, но ничего не произойдёт — сигнал уже сработал. Если нужна новая операция с возможностью отмены — создавайте новый контроллер.
Аргумент abort() — это reason. Может быть строкой, ошибкой, чем угодно. Если не передать — по умолчанию будет DOMException с именем AbortError.
Самый частый кейс — ограничить время HTTP‑запроса. До AbortController это выглядело так: Promise.race([fetch(url), new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000))]). Без комментариев.
Теперь:
// Способ 1: AbortSignal.timeout() — самый простой try { const response = await fetch('https://api.example.com/data', { signal: AbortSignal.timeout(5000) // 5 секунд и всё }); const data = await response.json(); } catch (err) { if (err.name === 'TimeoutError') { console.log('Таймаут — сервер не ответил за 5 секунд'); } }
AbortSignal.timeout() — статический метод, который создаёт сигнал с автоматическим таймаутом. Не нужен контроллер, не нужен setTimeout, не нужен clearTimeout. Одна строка.
Но если нужна отмена по условию (а не только по времени), нужен ручной контроллер:
// Способ 2: ручной контроллер — отмена по кнопке/событию/условию const controller = new AbortController(); // Таймаут вручную const timerId = setTimeout(() => controller.abort('timeout'), 5000); try { const response = await fetch('https://api.example.com/data', { signal: controller.signal }); clearTimeout(timerId); // Ответ пришёл — таймер не нужен return await response.json(); } catch (err) { if (err.name === 'AbortError') { console.log('Запрос отменён:', controller.signal.reason); } else { throw err; // Сетевая ошибка или что-то другое } }
AbortSignal.timeout() бросает TimeoutError, а ручной abort() без аргумента — AbortError. Если вам нужно различать «пользователь отменил» и «сервер не ответил» — используйте ручной контроллер для первого и timeout() для второго. Или передавайте разные reason в abort().
Реальная задача: запрос должен отмениться, если прошло 5 секунд, ИЛИ если пользователь нажал «Отмена», ИЛИ если компонент размонтировался. Три причины, один fetch.
AbortSignal.any() объединяет несколько сигналов в один:
const userController = new AbortController(); const cleanupController = new AbortController(); // Отменится по ЛЮБОМУ из трёх условий const signal = AbortSignal.any([ AbortSignal.timeout(5000), // таймаут userController.signal, // пользователь нажал Отмена cleanupController.signal, // компонент размонтировался ]); try { const response = await fetch(url, { signal }); const data = await response.json(); } catch (err) { // signal.reason покажет причину того сигнала, который сработал первым console.log('Отменено:', signal.reason); } // Где-то в обработчике кнопки: userController.abort('User cancelled'); // Где-то при размонтировании: cleanupController.abort('Component unmounted');
Сработает тот сигнал, который придёт первым. Остальные игнорируются — результирующий сигнал уже aborted.
fs/promises поддерживает signal в readFile, writeFile, open, watch:
import { readFile, writeFile } from 'node:fs/promises'; const controller = new AbortController(); // Где-то может прийти отмена setTimeout(() => controller.abort(), 3000); try { const data = await readFile('/path/to/huge-file.csv', { signal: controller.signal, encoding: 'utf-8', }); console.log(`Прочитано ${data.length} символов`); } catch (err) { if (err.name === 'AbortError') { console.log('Чтение файла отменено — не успели за 3 секунды'); } else { throw err; } }
Для writeFile — то же самое. Если отмена пришла до завершения записи, файл может быть записан частично. Учитывайте это: пишите во временный файл, потом переименовывайте.
Стримовый API (createReadStream, createWriteStream) не принимает signal напрямую. Там нужно закрывать стрим вручную или использовать pipeline() из stream/promises, который signal поддерживает:
import { pipeline } from 'node:stream/promises'; import { createReadStream, createWriteStream } from 'node:fs'; await pipeline( createReadStream('input.csv'), createWriteStream('output.csv'), { signal: AbortSignal.timeout(10000) } );
Промис‑версии таймеров из node:timers/promises тоже поддерживают сигнал:
import { setTimeout as delay } from 'node:timers/promises'; const controller = new AbortController(); try { // Ждём 10 секунд, но можем отменить раньше const result = await delay(10000, 'готово', { signal: controller.signal }); console.log(result); // 'готово' — если дождались } catch (err) { if (err.name === 'AbortError') { console.log('Не дождались, отменили'); } }
Удобно для поллинга, ретраев, любых «подождать, но с возможностью прервать».
Малоизвестная, но очень полезная штучка. on() на EventEmitter принимает signal для автоматической отписки:
import { EventEmitter } from 'node:events'; const emitter = new EventEmitter(); const controller = new AbortController(); emitter.on('data', (chunk) => { console.log('Получено:', chunk); }, { signal: controller.signal }); emitter.emit('data', 'первый'); // → Получено: первый emitter.emit('data', 'второй'); // → Получено: второй controller.abort(); emitter.emit('data', 'третий'); // → тишина, слушатель отписан
Ещё один кейс — events.on() (async итератор по событиям):
import { on } from 'node:events'; const controller = new AbortController(); // Асинхронный итератор по событиям — с возможностью остановки setTimeout(() => controller.abort(), 5000); try { for await (const [data] of on(emitter, 'data', { signal: controller.signal })) { console.log(data); if (data === 'stop') controller.abort(); } } catch (err) { if (err.name === 'AbortError') { console.log('Итерация остановлена'); } }
Клиент может закрыть вкладку или оборвать соединение. Если вы в этот момент ждёте ответ от тяжёлого внешнего API или считаете отчёт — зачем продо��жать? Получатель ушёл.
// Express app.get('/api/report', async (req, res) => { const controller = new AbortController(); // Клиент закрыл соединение — отменяем всё downstream req.on('close', () => controller.abort('Client disconnected')); try { // Тяжёлый запрос к внешнему API const raw = await fetch('https://analytics.internal/heavy-report', { signal: controller.signal, }); const report = await raw.json(); // Тяжёлая обработка — тоже проверяем отмену controller.signal.throwIfAborted(); const processed = processReport(report); res.json(processed); } catch (err) { if (err.name === 'AbortError') { // Клиент ушёл — ответ отправлять некому, просто выходим return; } res.status(500).json({ error: 'Internal error' }); } });
Паттерн экономит серверные ресурсы.
Если пишете библиотеку или утилиту, добавить поддержку отмены несложно. Просто принимаете signal в options, проверяете throwIfAborted() перед каждым шагом, передаёте signal во все вложенные операции.
async function pollUntilReady(url, { signal, interval = 2000 } = {}) { while (true) { // Проверяем перед каждой итерацией signal?.throwIfAborted(); try { const res = await fetch(url, { signal }); const data = await res.json(); if (data.status === 'ready') { return data; } } catch (err) { // Если отмена — пробрасываем наверх if (err.name === 'AbortError') throw err; // Если сетевая ошибка — пробуем ещё раз console.log('Retry after error:', err.message); } // Пауза между попытками — тоже отменяемая await new Promise((resolve, reject) => { const timer = globalThis.setTimeout(resolve, interval); signal?.addEventListener('abort', () => { clearTimeout(timer); reject(signal.reason); }, { once: true }); }); } } // Использование: поллинг с таймаутом 30 секунд const result = await pollUntilReady('https://api.example.com/job/123', { signal: AbortSignal.timeout(30000), interval: 3000, });
signal.throwIfAborted() — бросает исключение, если сигнал уже сработал. Вызывайте в начале каждого шага, чтобы не делать лишнюю работу.
Отменяемый sleep — настолько частый паттерн, что имеет смысл вынести в утилиту:
function abortableSleep(ms, { signal } = {}) { return new Promise((resolve, reject) => { const timer = setTimeout(resolve, ms); signal?.addEventListener('abort', () => { clearTimeout(timer); reject(signal.reason); }, { once: true }); }); }
Переиспользование контроллера. Один контроллер — одна логическая операция. Отменили — создали новый. Если повесить один контроллер на цикл запросов и вызвать abort() — отменятся все разом, включая будущие.
Забыли обработать AbortError. Без try/catch отмена уронит процесс. Всегда проверяйте err.name === 'AbortError' и решайте — это штатная ситуация или нет.
AbortController на синхронном коде. JSON‑парсинг, сортировка массива, валидация — отменять нечего. AbortController для async‑операций.
Утечка таймеров. Если создали setTimeout для ручного abort — не забудьте clearTimeout при успехе. Иначе таймер сработает уже после завершения операции.

Если хочется не просто писать серверный код на JavaScript, а уверенно разбираться в архитектуре Node.js-приложений, здесь нужна не подборка приёмов, а системная практика. На курсе «Node.js разработчик» как раз дают TypeScript, базы данных, тестирование и ключевые инструменты бэкенд-разработки в связке.
Для знакомства с форматом обучения и экспертами приходите на бесплатные уроки:
26 марта в 20:00. «NestJS и архитектура масштабируемых серверных приложений на Node.js». Записаться
9 апреля в 20:00. «Работа в реальном времени на Node.js и TypeScript: создаём WebSocket-чат и разбираем архитектуру серверной части». Записаться
22 апреля в 20:00. «Bun + искусственный интеллект: создаём быстрый сервер нового поколения на JavaScript». Записаться