Node.js Promise: промисы и async/await на практике
✓ Все примеры выполнены на Node.js v22.23.1 (LTS), июль 2026
Содержание
Асинхронность — причина, по которой Node.js вообще существует: пока один запрос ждёт ответа от базы, процесс обслуживает другие. Вопрос лишь в том, как этим ожиданием управлять из кода. За десять лет ответ менялся дважды: сначала колбэки, потом промисы, теперь async/await.
Эта статья — про то, как асинхронный код на Node пишут сегодня, и почему часть привычек из туториалов пятилетней давности пора забыть.
Что такое Promise простыми словами
Promise — это объект-заглушка, который функция возвращает сразу, обещая положить туда результат позже. Вызывающий код получает его немедленно и продолжает работу, а когда операция завершится, промис перейдёт в финальное состояние и отдаст либо значение, либо ошибку.
Аналогия — номерок в гардеробе. Вы отдаёте пальто и получаете номерок мгновенно. Номерок — не пальто, но по нему пальто можно забрать, когда понадобится. И у него ровно два возможных исхода: вещь вернут либо скажут, что её потеряли.
Три состояния промиса
Состояний ровно три, и переход возможен только из pending и только один раз — «расстроить» уже выполненный промис нельзя.
| Состояние | Что означает | Что делает await |
|---|---|---|
pending |
операция идёт | ждёт |
fulfilled |
завершилась успешно | возвращает значение |
rejected |
завершилась с ошибкой | бросает исключение |
import { setTimeout as sleep } from 'node:timers/promises';
const pending = new Promise(() => {}); // никогда не завершится
const fulfilled = Promise.resolve(42);
const rejected = Promise.reject(new Error('boom'));
console.log(await Promise.race([pending, sleep(10, 'still pending')]));
console.log(await fulfilled);
console.log(await rejected.catch((e) => 'поймали: ' + e.message));
Вывод:
still pending
42
поймали: boom
Обратите внимание на .catch() у rejected. Отклонённый промис, который никто не обработал, — это unhandledRejection, и начиная с Node 15 такое поведение завершает процесс с ненулевым кодом. Раньше это было предупреждение, сейчас — падение.
Нужно ли вообще создавать промисы вручную
Чаще всего — нет. Это главное отличие от туториалов, где каждый пример начинается с new Promise((resolve, reject) => ...). Сегодня промис почти всегда уже есть.
У встроенных модулей есть промис-версии. Достаточно импортировать их из другого пути:
import { readFile, writeFile, unlink } from 'node:fs/promises';
await writeFile('/tmp/demo.txt', 'привет из fs/promises');
console.log(await readFile('/tmp/demo.txt', 'utf8'));
await unlink('/tmp/demo.txt');
привет из fs/promises
Промис-версии есть не только у файловой системы, но и у таймеров (node:timers/promises), и у потоков (node:stream/promises).
Старые колбэк-функции оборачивает util.promisify. Работает с любой функцией, где колбэк идёт последним аргументом и имеет сигнатуру (err, result):
import { promisify } from 'node:util';
import { execFile } from 'node:child_process';
const execFileAsync = promisify(execFile);
const { stdout } = await execFileAsync(process.execPath, ['-e', 'console.log("из дочернего процесса")']);
console.log(stdout.trim());
из дочернего процесса
Писать new Promise осмысленно ровно в одном случае: когда нужно обернуть источник, который не является функцией с колбэком, — сокет, эмиттер событий, внешний вызов.
Параллельно или последовательно: сколько это стоит
Каждый await останавливает функцию до завершения операции. Если операции не зависят друг от друга — это потерянное время.
Замерим. Три задачи по 300 мс, сначала последовательно, потом через Promise.all:
const task = (ms, name) => sleep(ms, name);
let t0 = performance.now();
const a = await task(300, 'a');
const b = await task(300, 'b');
const c = await task(300, 'c');
console.log('последовательно:', Math.round(performance.now() - t0), 'мс');
t0 = performance.now();
const all = await Promise.all([task(300, 'a'), task(300, 'b'), task(300, 'c')]);
console.log('Promise.all:', Math.round(performance.now() - t0), 'мс');
последовательно: 925 мс
Promise.all : 315 мс
Данные таблицей
| Показатель | Время |
|---|---|
| Последовательно (await подряд) | 925 мс |
| Параллельно (Promise.all) | 315 мс |
Источник: собственный замер на Node.js v22.23.1
Последовательный вариант занял сумму всех задержек, параллельный — время самой долгой задачи плюс накладные расходы. Логика простая: Promise.all не делает код многопоточным, он лишь запускает операции до того, как начнёт их ждать.
Главный антипаттерн: await внутри цикла
Эта ошибка чаще всего встречается в реальном коде и почти никогда — в туториалах. Пять задач по 200 мс:
// ❌ каждая итерация ждёт предыдущую
const result = [];
for (const id of ids) {
result.push(await task(200, id));
}
// ✅ запускаем все, ждём один раз
const result = await Promise.all(ids.map((id) => task(200, id)));
for..of + await : 1021 мс
map + Promise.all: 204 мс
Оговорка, без которой совет вредный: Promise.all запускает все операции сразу. Для пяти задач это отлично, для пяти тысяч запросов к базе — способ положить и базу, и процесс. Когда список большой, нужен ограничитель параллелизма: пул на 10–20 одновременных операций или готовое решение вроде p-limit.
А если каждая итерация зависит от результата предыдущей — цикл с await не антипаттерн, а единственный корректный вариант.
Promise.all, allSettled, any, race: что выбрать
Четыре комбинатора, и разница между ними — в реакции на ошибку.
| Метод | Когда завершится | При ошибке | Когда применять |
|---|---|---|---|
Promise.all |
все выполнены | падает на первой ошибке | нужны все результаты, ошибка = провал |
Promise.allSettled |
все завершились | не падает никогда | нужен отчёт по каждой задаче |
Promise.any |
первый успешный | падает, если упали все | достаточно одного ответа из нескольких источников |
Promise.race |
первый завершившийся | падает, если первым упал | таймауты, гонка за скорость |
Поведение при одной упавшей задаче из трёх:
const ok = (v) => Promise.resolve(v);
const fail = (m) => Promise.reject(new Error(m));
await Promise.all([ok(1), fail('нет'), ok(3)]); // → бросит Error: нет
await Promise.allSettled([ok(1), fail('нет'), ok(3)]); // → статусы по каждой
await Promise.any([fail('a'), ok('второй успел'), fail('b')]);
await Promise.race([sleep(50, 'быстрый'), sleep(200, 'медленный')]);
all -> REJECT: нет
allSettled-> ["fulfilled","rejected","fulfilled"]
any -> ok второй успел
race -> быстрый
Ключевой нюанс Promise.all: он падает на первой ошибке, но остальные операции не отменяются — они продолжают выполняться, просто их результат никто не получит. Если побочные эффекты важны, берите allSettled.
Отменить их по-настоящему можно только там, где операция принимает signal: так устроены fetch, fs/promises и дочерние процессы. Про сам сигнал — ниже.
Promise.withResolvers: без обёртки вокруг resolve
Раньше, чтобы «вынести» resolve наружу, переменные объявляли до new Promise. С Node 22 для этого есть штатный метод:
const { promise, resolve } = Promise.withResolvers();
setTimeout(() => resolve('разрешён снаружи'), 20);
console.log(await promise);
разрешён снаружи
Метод доступен начиная с Node.js 22 — то есть на всех поддерживаемых сегодня LTS-линиях.
Что писала эта страница в 2017 году
HackerX ведётся с 2015 года, и предыдущая версия этой самой статьи вышла 19 августа 2017-го. Сравнить её с сегодняшним днём полезнее, чем любой список «что нового»: видно, что именно изменилось за восемь лет, а что оказалось верным сразу.
Сбывшийся прогноз. Та версия описывала async/await так:
«…новейший синтаксис async await, который будет введён в JavaScript как часть спецификации ECMAScript 2017»
Будущее время — не оговорка, а точный снимок момента. Прогноз сбылся полностью: сегодня async/await не «одна из альтернатив», а способ писать асинхронный код по умолчанию, и вопрос «брать ли его» никто не задаёт.
Верное решение, которое устояло. Статья 2017 года уже тогда советовала нативные промисы, а не библиотеки: «доступны в Node.js начиная с версии 4 и не требуют подключения внешних библиотек». Совет был правильный и остаётся правильным — изменился только масштаб: тогда «версия 4» была свежей, сегодня Active LTS — v24.
Что устарело целиком. Отдельный раздел был посвящён реализациям спецификации Promises/A+ — Bluebird, Q, When.js — и их способам превращать колбэки в промисы: Q.denodeify(), Promise.promisify() у Bluebird, node.lift() у When.js. Сегодня всё это заменено одной встроенной функцией — util.promisify — и промис-версиями самих модулей. Ставить библиотеку ради промисов больше незачем.
Чего в той версии не было и быть не могло: Promise.allSettled (ES2020), Promise.any (ES2021), Promise.withResolvers (Node 22) и AbortSignal.timeout(). Половина этой статьи посвящена инструментам, которых в 2017-м просто не существовало.
Обработка ошибок и таймауты
Внутри async-функции ошибка промиса — обычное исключение, поэтому работает привычный try/catch.
Отдельная задача — оборвать операцию, которая висит слишком долго. Раньше это делали через Promise.race с таймером; сейчас большинство асинхронных API Node принимают параметр signal, а AbortSignal.timeout() создаёт сигнал, срабатывающий сам:
try {
await sleep(1000, 'never', { signal: AbortSignal.timeout(100) });
} catch (err) {
console.log('оборвалось:', err.name);
}
оборвалось: AbortError
Тот же signal принимают fetch, fs/promises и stream/promises — это общий механизм отмены, а не трюк для таймеров.
Последняя страховка — глобальный обработчик. Он не заменяет try/catch, но показывает, что вы что-то упустили:
process.on('unhandledRejection', (reason) => {
console.log('unhandledRejection:', reason.message);
});
Promise.reject(new Error('никто не поймал'));
unhandledRejection: никто не поймал
Промисы — основа всего асинхронного на платформе: на них работают файловая система, потоки, дочерние процессы и события. Остальные разборы собраны в справочнике по Node.js.
Частые вопросы
Промисы делают код многопоточным?
worker_threads, а не промисы.`async/await` медленнее, чем `.then()`?
async/await — синтаксис поверх тех же промисов. Разница в читаемости и в том, что с await работают обычный try/catch и корректный стек ошибки.Нужен ли `bluebird` или другая промис-библиотека?
Что будет, если не поставить `await` перед вызовом async-функции?
unhandledRejection и уронит процесс. Такой «повисший» вызов и называют floating promise.