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 мс
Три независимые задачи по 300 мс: время выполнения
Данные таблицей
Показатель Время
Последовательно (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 мс
5.0x
Ускорение на пяти задачах
1021 мс → 204 мс
Источник: собственный замер, Node v22.23.1
2.9x
Ускорение на трёх задачах
925 мс → 315 мс
Источник: собственный замер, Node v22.23.1
1
Переход состояния
промис меняет состояние ровно один раз

Оговорка, без которой совет вредный: 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.

Частые вопросы

Промисы делают код многопоточным?
Нет. JavaScript в Node выполняется в одном потоке. Промис лишь позволяет не блокировать этот поток на время ожидания ввода-вывода. Если нужна параллельность вычислений — это worker_threads, а не промисы.
`async/await` медленнее, чем `.then()`?
Практической разницы нет: async/await — синтаксис поверх тех же промисов. Разница в читаемости и в том, что с await работают обычный try/catch и корректный стек ошибки.
Нужен ли `bluebird` или другая промис-библиотека?
Нет. Внешние промис-библиотеки решали проблемы Node 0.x–4.x. Сегодня всё, ради чего их брали, есть в платформе.
Что будет, если не поставить `await` перед вызовом async-функции?
Функция запустится, но код пойдёт дальше, не дожидаясь её. Если внутри произойдёт ошибка, она станет unhandledRejection и уронит процесс. Такой «повисший» вызов и называют floating promise.