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.