Модуль написали четыре года назад, дорабатывали три человека, и теперь его открывает тот, кому поручили добавить фичу в соседний сервис. В одном файле живут цепочка then, await внутри стрелки внутри then, forEach с асинхронным колбэком и одинокий catch, который просто печатает ошибку в журнал. Синтаксис один и тот же, язык один, а читать приходится с тремя переключателями в голове. Ниже разбор того, чем Promise и async await различаются на самом деле, где смешение стоит дорого и как выбрать стиль, который не придётся расшифровывать собственному преемнику.

Как await устроен поверх промисов без второй механики

До стандарта ECMAScript 2015 асинхронность в JavaScript строилась на колбэках, и вложенные запросы складывались в пирамиду, у которой было неформальное имя. Библиотеки вроде Q и Bluebird наводили порядок, пока промисы не вошли в сам язык. Ещё через два стандарта, в ECMAScript 2017, появился синтаксис async и await, и он не привнёс новой механики исполнения: это надстройка над теми же промисами.

Каждая async-функция возвращает промис всегда: даже простое число в return автоматически оборачивается, и вызов loadProfile это промис, а не значение. Выражение await приостанавливает функцию до завершения промиса, разворачивает его результат и пробрасывает отказ в обычный try catch. Механика одна, поэтому спор о стилях это спор о читаемости:

async function loadProfile(id) {
  const user = await fetchUser(id);
  const orders = await fetchOrders(user.id);
  return {user, orders};
}

function loadProfileOld(id) {
  return fetchUser(id).then(user =>
    fetchOrders(user.id).then(orders => ({user, orders}))
  );
}

Обе функции делают одно и то же. Разница в том, куда смотрит глаз: у первой линейная последовательность шагов, у второй вложенность растёт с каждым запросом, и возврат из середины цепочки превращается в шараду.

Словарик остаётся прежним. Промис это объект с методом then, await принимает всё, у чего этот метод есть, поэтому конструкция await работает и с промисами, и с их подобиями из старых библиотек. Слова async и await не создают потоков и не запускают параллельных вычислений: функция вне ожидания честно стоит в очереди микрозадач, и тяжёлое вычисление внутри неё блокирует остальных так же, как прежде.

Смешение стилей внутри одной функции и его цена при возврате через годы

Реальный код стареет хуже академических примеров. Типовая находка в проекте с историей:

function refreshFeed(page) {
  return fetchFeed(page).then(async (feed) => {
    const items = [];
    for (const post of feed) {
      const meta = await fetchMeta(post.id);
      items.push({...post, meta});
    }
    return items;
  }).catch(err => console.error(err));
}

Формально всё работает. По сути в одной функции смешаны три идиомы: цепочка then, async-колбэк внутри неё и цикл с последовательными await, который ходит за метаинформацией по одному и растягивает ответ пропорционально длине ленты. Через четыре года такой код расшифровывают с git blame в соседнем окне, и каждая расшифровка стоит дороже написания.

Коварнее всех забытый return:

async function save(dto) {
  return validate(dto).then(() => {
    persist(dto);
  });
}

Вызывающий код дождётся проверки, но не записи: then вернёт undefined, промис завершится раньше фактической работы, и сохранение поедет в фоне без наблюдения. Ошибка не падает и не логируется, а тихо меняет порядок событий. Именно такие места, а не выбор между then и await, составляют реальную цену смешения: каждая конструкция требует собственного набора бдительности.

Отдельный подвид это двойная упаковка: async-функция, внутри которой return await от чужого промиса, либо then, возвращающий результат async-вызова и требующий вложенного await. Обе формы работают, обе съедают лишний такт микрозадачи и добавляют уровень вложенности, и обе выдают автора, который переносил код между стилями без пересборки мысли. В чтении вторая ступень вложенности ощущается сильнее, чем в написании.

Места, где комбинаторы промисов читаются лучше последовательного await

Последовательный await по независимым запросам это самая дорогая привычка из невинных. Три вызова по 300 миллисекунд подряд дают суммарные 900, хотя запросам ничего не мешает идти одновременно:

const [user, settings, balance] = await Promise.all([
  fetchUser(id), fetchSettings(id), fetchBalance(id)
]);

Общее время падает примерно до самого медленного из трёх вызовов. Здесь await не отменяет промисы, а опирается на их лучшую часть: комбинатор остаётся честным Promise.all, а чтение остаётся линейным.

Для пакетных операций, где допустимы частичные неудачи, есть allSettled из редакции ECMAScript 2020: он ждёт всех, а не рушится от первого отказа:

const results = await Promise.allSettled(ids.map(refresh));
const failed = results.filter(r => r.status === "rejected");

Вариант any из редакции 2021 года решает обратную задачу: нужен первый успешный результат, например от самого быстрого из зеркал. Польза комбинаторов в том, что намерение видно в первой строке: параллельно, с остатками, до первого успеха. Последовательность await выражает то же намерение заметно дороже, временем пользователя.

Комбинатор не отменяет ограничений. Сотня одновременных запросов через Promise.all поверх map способна перегрузить и клиентское соединение, и чужой сервис, у которого собственный лимит. Практика в ограничении пачки: массив режется на срезы, и каждая пачка ждёт предыдущую.

const chunks = [];
for (let i = 0; i < ids.length; i += 50) {
  chunks.push(ids.slice(i, i + 50));
}
for (const part of chunks) {
  await Promise.all(part.map(refresh));
}

Готовые утилиты с параметром параллелизма делают то же самое, важно, что ограничение видно в коде явно, а не держится в голове автора.

Вызов без await как брошенный промис и его асинхронные последствия

Вторая по частоте авария не заметна в ревью. Строчка sendEmail(user); без await выглядит безобидно, но если sendEmail вернёт отказ, промис останется необработанным. До версии 15 Node.js печатал предупреждение и работал дальше. С версии 15, вышедшей в 2020 году, поведение по умолчанию сменилось с предупреждения на исключение: необработанный отказ роняет процесс целиком. Смена сделала брошенный промис проблемой продакшена, а не стилистической небрежностью.

Ещё одна тишина перед аварией это forEach с асинхронным колбэком:

items.forEach(async (item) => {
  await save(item);
});
res.json({ok: true});

forEach не ждёт промисов, ответ уходит раньше сохранений, часть записей теряется, ошибок нет. Лечение одно из двух: честный цикл for of с await, когда порядок важен, либо Promise.all поверх map, когда важна скорость.

Тонкий момент с обработкой: try catch вокруг await ловит отказы, а вокруг вызова без await ловит только синхронные исключения. Отказ прилетит позже, в другом месте, чужой трассировкой. Симметрия простая: await внутри try, либо catch в конце цепочки, но всегда в одном месте, а не в обоих.

Последним рубежом в Node.js остаётся глобальный обработчик события unhandledRejection: он ловит то, что проскочило мимо всех правил, и превращает невидимку в строку журнала с трассировкой. Держать его обязательно, полагаться на него нельзя: это страховка, а не стиль. В браузере ту же роль играет событие unhandledrejection, и его тоже логируют, а не игнорируют.

Единая обработка ошибок и связность стека при долгой поддержке

Через годы читается не синтаксис, а обработка ошибок. Функция, где часть отказов ловится через try catch, часть через catch в цепочке, а часть уезжает в необработанные, требует полной симуляции выполнения в голове. Дешевле всего обходится договорённость: внутри async-функции ошибки ловит один try catch на верхнем уровне, в цепочках промисов один catch в конце, и формулы не смешиваются в одной функции.

Стеки достались await исторически непросто: в ранних версиях движков трассировка теряла асинхронные кадры, и падение из глубины цепочки показывало последнюю функцию, а не путь к ней. Современные движки печатают цепочку целиком, включая переходы через await, и это ещё один аргумент за линейный стиль: точка отказа видна сразу, без реконструкции вручную.

С очисткой ресурсов симметрия полная: у промиса есть метод finally, у await есть блок finally, и оба выполняются независимо от исхода. Отличие читательское: в линейной функции блок finally стоит на видном месте, а в длинной цепочке промисов он пристраивается в конец и легко теряется за тремя then. Для файла, который будут читать через годы, разница снова не про механику, а про цену понимания.

Автоматика удерживает договорённости лучше уговоров. Правило no-floating-promises из набора проверок для TypeScript требует await или явной пометки промиса, правило prefer-await-to-then предлагает линейную форму вместо цепочки, и оба включаются на весь проект за минуты. Отдельным подарком большим проектам стал top-level await из стандарта ECMAScript 2022: ожидание прямо в теле модуля легально, и последний повод писать загрузочные скрипты цепочками исчез.

Выравнивание старого кода через правило касания без отдельного рефакторинга

Переписать разом пятьсот файлов не выйдет ни у одной команды, да и не нужно. Рабочая практика звучит как правило касания: файл, в который всё равно вносятся правки, выравнивается целиком, нетронутые файлы ждут своей очереди. Оценка простая: если правка функции занимает час, выравнивание её стиля добавляет минут двадцать, зато следующий визит в файл начинается без расшифровки.

Механика выравнивания примитивна. Цепочка then переписывается в async-функцию шаг за шагом: каждый then становится строкой с await, вложенные колбэки выпрямляются в последовательность, крайний catch превращается в try catch вокруг тела. Тесты до и после обязательны: механическая переписка меняет порядок обработки ошибок в редких углах, и ловить это по ревью дороже, чем прогоном.

Пределы у правила тоже есть. Библиотечный код с публичными интерфейсами, возвращающими промисы, менять ради стиля нельзя: внешние потребители не обязаны узнавать о вкусах команды. И файлы, которые не трогали годы, лучше вскрывать только по нужде: нулевая польза при ненулевом риске.

Скорость выравнивания предсказуема: в проекте, где касание файла случается раз в пару месяцев, стилевой долг сокращается вдвое примерно за год без единой отдельной задачи в трекере. Это не подвиг, а фоновая гигиена, и именно поэтому она доезжает до конца там, где большие кампании по переписыванию бросают на середине.

Правила выбора стиля для кодовой базы и границы разумного смешения

Рабочий набор правил для живого проекта выглядит компактно:

  1. новая логика пишется через async и await, цепочки then остаются в легаси и переписываются при касании;
  2. независимые вызовы объединяются через Promise.all, последовательность оставляется там, где следующий шаг реально требует предыдущий;
  3. смешение then и await внутри одной функции запрещено, стиль выбирается на уровне функции целиком;
  4. вызов асинхронной функции без await допускается только с комментарием и осознанным обработчиком отказа;
  5. перебор с ожиданием идёт через for of, а forEach с асинхронным колбэком вылавливается ревью и линтером;
  6. обработка ошибок живёт в одном месте функции, try catch или catch в цепочке, но никогда в обоих.

Правила не запрещают промисы, они запрещают держать в голове две модели одновременно. Promise.all, allSettled и any остаются строительными блоками и внутри линейного стиля: единый стиль это не запрет половины языка, а выбор одного каркаса для девяноста процентов кода и осознанных исключений для остальных десяти.

Код-ревью по таким правилам занимает меньше времени не потому, что правок меньше, а потому что спорных мест меньше. Ревьюер не выясняет вкусы автора, а сверяется с шестью пунктами, и разногласия закрываются ссылкой на документ, а не голосованием.

Итоговый выбор скучнее любого спора на собрании. Promise и async await это один механизм в двух одеждах, поэтому решающим оказывается не вкус, а скорость чтения через годы. Проект, где оркестрация линейная, параллельность явная, а ошибки ловятся в одном месте, дорабатывается за вечер. Проект, где идиомы перемешаны ради вкусовщины, превращает каждую доработку в расшифровку, и плата собирается не сразу, а потом, когда авторов уже не спросишь.