Promise.all, отмена и таймауты

«Как выполнить три запроса параллельно?» — вопрос-разминка. Настоящая проверка начинается со следующего: «А что будет, если один из них упадёт?»

Promise.all работает по принципу fail-fast: он отклоняется сразу при первой ошибке, но не отменяет остальные операции — они продолжают выполняться.

Что проверяет интервьюер

Понимаете ли вы разницу между «запустить параллельно» и «дождаться всех»; знаете ли комбинаторы кроме all; и — самое ценное — думаете ли о нагрузке, которую создаёт бездумный параллельный запуск сотен операций.

Последовательно против параллельно

// последовательно: суммарно ~900 мс
const user    = await fetchUser(id);        // 300 мс
const orders  = await fetchOrders(id);      // 300 мс
const reviews = await fetchReviews(id);     // 300 мс

// параллельно: ~300 мс, запросы независимы
const [user2, orders2, reviews2] = await Promise.all([
  fetchUser(id),
  fetchOrders(id),
  fetchReviews(id),
]);

Правило простое: если следующий запрос не использует результат предыдущего, последовательное ожидание — это потерянное время. Обратное тоже верно: если fetchOrders нужен user.id, параллелить нечего.

Четыре комбинатора

МетодКогда разрешаетсяКогда применять
Promise.allкогда выполнены все; отклоняется при первой ошибкенужны все результаты, частичный ответ бессмысленен
Promise.allSettledкогда завершились все, независимо от исходасбор данных из нескольких источников, часть может упасть
Promise.raceпо первому завершившемуся, успех это или ошибкатаймауты
Promise.anyпо первому успешномунесколько зеркал/реплик, годится любой ответ
const results = await Promise.allSettled([fetchA(), fetchB(), fetchC()]);

for (const r of results) {
  if (r.status === 'fulfilled') console.log('ок:', r.value);
  else logger.warn({ err: r.reason }, 'источник недоступен');
}

Ловушка первая: остальные промисы не отменяются

Promise.all отклонился — это значит только то, что вы перестали ждать. Запросы к базе продолжают жить, соединения заняты, а если у «опоздавшего» промиса не окажется обработчика ошибки, вы получите unhandled rejection уже после того, как вернули клиенту 500. Промис в JavaScript в принципе нельзя отменить — для этого существует отдельный механизм, AbortController.

Ловушка вторая: неограниченная конкурентность

// 10 000 запросов к API стартуют одновременно — почти гарантированный 429 или ECONNRESET
await Promise.all(ids.map((id) => fetchItem(id)));

Это самая частая ошибка в реальном коде. Лечится ограничением параллелизма — библиотекой вроде p-limit или простым пулом воркеров:

async function mapWithLimit(items, limit, worker) {
  const results = new Array(items.length);
  let cursor = 0;

  async function run() {
    while (cursor < items.length) {
      const i = cursor++;                 // забираем следующий индекс
      results[i] = await worker(items[i]);
    }
  }

  await Promise.all(Array.from({ length: limit }, run));
  return results;
}

const items = await mapWithLimit(ids, 10, fetchItem);   // не больше 10 одновременно

Отмена и таймауты

Наивный таймаут через Promise.race знать нужно, но нужно и понимать его недостаток:

function withTimeout(promise, ms) {
  let timer;
  const timeout = new Promise((_, reject) => {
    timer = setTimeout(() => reject(new Error('timeout ' + ms + 'ms')), ms);
  });
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}

finally с clearTimeout здесь обязателен: без него незакрытый таймер держит event loop и процесс не завершится. Но даже с ним исходный запрос продолжает выполняться — мы лишь перестали ждать ответ. Настоящая отмена делается через сигнал:

const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);

try {
  const res = await fetch('https://api.example.com/items', {
    signal: controller.signal,        // соединение реально разорвётся
  });
  return await res.json();
} catch (err) {
  if (err.name === 'AbortError') throw new Error('внешний сервис не ответил за 3 с');
  throw err;
} finally {
  clearTimeout(timer);
}

// короткая форма для той же задачи
await fetch(url, { signal: AbortSignal.timeout(3000) });

AbortSignal понимают fetch, fs.promises, streams и многие клиенты баз данных — это стандартный способ сказать «работу можно прекращать».

Типичные ошибки кандидатов

  • Считают, что при ошибке Promise.all остальные операции отменяются.
  • Не знают allSettled и городят .catch(() => null) на каждый промис.
  • Запускают Promise.all по массиву из тысяч элементов без ограничения конкурентности.
  • Делают таймаут через race и забывают clearTimeout — процесс не завершается.
  • Путают «перестал ждать» с «отменил»: без AbortController запрос продолжает висеть.

Как ответить кратко

Promise.all ждёт все промисы и падает по первой ошибке, при этом остальные операции продолжают выполняться — отмены в промисах нет. Если частичные ошибки допустимы, берут allSettled; для таймаута — race, для «любой из реплик» — any. Массовый Promise.all по большому массиву опасен: нужна ограниченная конкурентность через пул или p-limit. А реальная отмена запроса с разрывом соединения делается через AbortController и AbortSignal.timeout.

Проверьте себя
1. Что происходит с остальными промисами, когда Promise.all отклоняется из-за ошибки в одном из них?
AОни автоматически отменяются
BОни продолжают выполняться, просто их результат больше никто не ждёт
CОни переходят в состояние pending навсегда
DNode завершает процесс
2. Какой комбинатор выбрать, если нужно опросить три источника данных и обработать результат даже при падении одного из них?
APromise.all
BPromise.race
CPromise.allSettled
DPromise.any
3. Зачем в реализации таймаута через Promise.race нужен clearTimeout в finally?
AЧтобы промис не разрешился дважды
BЧтобы висящий таймер не удерживал event loop и процесс мог завершиться
CЧтобы отменить исходный запрос
DЭто необязательно, современный Node чистит таймеры сам